Merge master and regenerate Better Auth migration

Keep the account identity upgrade after current migration history. Preserve the three-statement migration and leave workflows unchanged relative to master.

Co-Authored-By: Paperclip <[email protected]>
This commit is contained in:
cryppadottaandPaperclip committed 2026-11-07 15:08:01 +00:00
commit dc2387224c
2504 files changed
+259194 -84730

No files matched your search

@@ -45,7 +45,7 @@ export function loadKit(id: DirectionId): Promise<DirectionKit> {
- `ThreadWorkProduct`: a task document becomes a `TaskChatProtocolCard` resource item with ``subtitle: `Document · rev ${artifact.revision}` ``; anything else becomes `RichWorkProductCard` with `workProductHref(workProduct)` (`ThreadWorkProduct.tsx:13-31`; the product split is `ui/src/components/TaskChatThread.tsx:1290-1312`).
- `ThreadAttachments`: mirrored; uses `@/components/ui/attachment`, `ArtifactPreview`, `fileKindForAttachment`, `formatFileSize`, `isImageAttachment`, `isVideoLikeOutput` (`ThreadAttachments.tsx:1-15`).
**GENERIC**, flag-gated components. Before mounting, grep the feature's flag hook under `ui/src/components` and `ui/src/pages`. For artifacts, `useArtifactsV1Enabled()` is called at `IssuePropertiesArtifactsTab.tsx:414`, `TaskChatBubble.tsx:149`, `TaskChatProtocolCard.tsx:1045` and in `pages/Artifacts.tsx`; it resolves `settings?.enableArtifactsV1 === true` (`ui/src/hooks/useArtifactsV1Enabled.ts:7-9`) from `GET /api/instance/settings/experimental` (`ui/src/api/instanceSettings.ts:20-21`). The artifact stub has no route for it, answers `{}`, and the flag is false: Today's panel and its task-document thread row render the flag-OFF presentation (the generic card from `TaskChatProtocolCard.tsx:1072`), while `ArtifactCard`, `ArtifactGroupCard` and `RichWorkProductCard` are flag-independent. To show a flagged presentation under Today, register a route returning `{ enableArtifactsV1: true }` for that pathname. A generated lab already has that route: `api-routes.ts` answers any pathname ending `/instance/settings/experimental` with `registry.today.flags` (`{}` as scaffolded), so set the flag in `registry.ts` (`today: { flags: { enableArtifactsV1: true } }`) or at run time with `setLabFlags`.
**GENERIC**, flag-gated components. Before mounting, grep the feature's flag hook under `ui/src/components` and `ui/src/pages`. For artifacts, `useArtifactsV1Enabled()` is called at `IssuePropertiesArtifactsTab.tsx:414`, `TaskChatBubble.tsx:149`, `TaskChatProtocolCard.tsx:1045` and in `pages/Artifacts.tsx`; it resolves to on unless a managed `enableArtifactsV1: false` holds it off (`resolveArtifactsV1Enabled` in `ui/src/hooks/useArtifactsV1Enabled.ts`), read from `GET /api/instance/settings/experimental` (`ui/src/api/instanceSettings.ts:20-21`). The artifact stub has no route for it and answers `{}`, so Today's panel and its task-document thread row render the V1 rows, while `ArtifactCard`, `ArtifactGroupCard` and `RichWorkProductCard` are flag-independent. To show the previous cards under Today, return `{ enableArtifactsV1: false, managedKeys: { enableArtifactsV1: { managed: true, managedBy: "paperclip-cloud" } } }` for that pathname. A generated lab already has that route: `api-routes.ts` answers any pathname ending `/instance/settings/experimental` with `registry.today.flags` (`{}` as scaffolded), so set those values in `registry.ts` or at run time with `setLabFlags`.
## 2. Adapters: lab fixtures to product types
@@ -29,6 +29,10 @@ available tools and records before asking the user for missing information:
architecture, adapter, model/effort, execution workspace, trust mode, and credential
source. Trace effective values back through task overrides, inherited settings,
defaults, and saved session state. Redact credential values.
- Whether a deploy or restart separated save from resume. Compare the served
revision and runner/provider pack versions that saved the session, checkpoint,
lease, or launch input with those that resumed it. Failures clustered after
a deploy usually mean the new release refuses state the old one saved.
- Which endpoint is authorized: verified fix and green PR, or also rollout and
recovery. Preserve the requested work and success criteria throughout.
@@ -67,6 +71,10 @@ Separate these questions:
3. Why did recovery or continuation fail to complete the already-authorized work?
4. Which later errors are consequences, misleading summaries, or independent bugs?
Answer early whether the failing step resumed state saved by a different release.
If it did, compare what the old release persisted with what the new one requires
before pursuing provider, model, or capacity explanations.
Trace the causal value or transition to its producer and shared contract. Verify
the hypothesis against a counterexample or a reproduction. Check logs before and
after the apparent error: a recovered authorization warning may not be fatal, and
@@ -137,12 +145,25 @@ assumptions into observable regression cases, not just assurances in the PR:
| Waiting or task disposition | Real approval/budget/credential gates still stop work; actionable work has a durable wake; recovery is bounded; no recursive recovery tree. |
| Input, tool, or credential plumbing | Company and trust boundaries hold; intentional configuration reaches execution without ambient secret forwarding; distinct tools still dispatch correctly; sensitive logging remains redacted. |
| Packaging, compatibility, or session identity | Relevant local/sandbox, platform, fresh/resumed, and old/new artifact combinations work; obsolete saved values are handled by a contract rather than a list of incident exceptions. |
| Persisted run identity across releases | State saved by the previous release resumes on the new one, or stops with a permanent, named code that recovery does not retry. A deploy landing mid-run follows the same contract. |
| Performance or lifecycle | Warm reuse remains warm where intended; queueing and finalization do not regress; configured deadlines retain their meaning. |
Select cases that exercise the actual blast radius. Do not create an unrelated
platform matrix. Test the sibling success path and the negative case that the old
guard protected. Keep reproduction and safety tests through later revisions.
Upgrade safety is part of every repair that touches persisted run identity:
launch arguments, provider/runner pack digests, leases, listeners, session or
checkpoint schema, and workspace authority. Each must either carry forward across
releases or fail with a permanent, named code, never a generic retryable one such
as `native_session_interrupted`. A retry cannot change saved identity; it repeats
the refusal or replaces saved state. Add an old-saved/new-resumed regression using
state shaped by the previous release, and a deploy-lands-mid-run case when the run
can be in flight at the cutover. Prefer one class-level rule at the shared
admission or recovery boundary over another per-error-code exception. For remote
sandbox paths, test with the provider's confinement rules applied; a local test
without them does not prove the remote path.
With the red established and the safety assumptions identified, make the repair
and run the same regression green. Run the relevant safety cases and existing
tests. Refactor only while they stay green. Investigate unexpected failures
@@ -22,6 +22,23 @@ Repeat an interruption to prove the recovery budget and genuine stops still work
strings does not define compatibility. Disabling warm reuse may hide the failure
while imposing a new startup cost on every turn.
## Saved run state meets a different release
**Mechanism:** One release saves a session, checkpoint, lease, or launch input;
a deploy or restart lands; another release resumes it. Launch arguments, a pack
digest, a listener, or workspace authority changed in between, so the new code
refuses the saved state. The refusal surfaces as a generic retryable interruption,
and recovery retries or replaces saved state over a refusal that cannot change.
**Prove:** Save state with the previous release's shape, then resume with the
current code. Also land the restart while the run is in flight. Assert the state
carries forward, or recovery stops once with a permanent, named code and keeps the
saved state. For remote sandboxes, apply the provider's workspace confinement.
**Trap:** Adding one more code to a non-retryable list fixes one identity, not the
class. A fresh local session and a test run outside the sandbox's confinement both
skip the cutover the incident depended on.
## A successful result gets stranded during finalization
**Mechanism:** The provider completed useful work, but result persistence, artifact
+2
View File
@@ -19,6 +19,8 @@ skills/** @cryppadotta @devinfoley @nickyleach @forgottendev
# package.json matches recursively at all depths (covers root + all workspaces)
package.json @cryppadotta @devinfoley @nickyleach @forgottendev
pnpm-lock.yaml @cryppadotta @devinfoley @nickyleach @forgottendev
/tests/runner-e2e/report-lock.json @cryppadotta @devinfoley @nickyleach @forgottendev
/tests/runner-e2e/report-lock.patch @cryppadotta @devinfoley @nickyleach @forgottendev
pnpm-workspace.yaml @cryppadotta @devinfoley @nickyleach @forgottendev
.npmrc @cryppadotta @devinfoley @nickyleach @forgottendev
+6 -4
View File
@@ -4,7 +4,7 @@
> - Contributors need predictable CI feedback.
> - Test shards use historical per-file costs to distribute their work.
> - New and changed tests make those costs stale.
> - This automated PR refreshes the costs from successful protected-master runs.
> - This automated PR refreshes the costs from successful protected-master runs and, for the e2e and Runner lanes that only run on PRs, from merged PRs' final-head runs.
> - The refresh changes placement, while preserving test inventory and runner authorization.
## Linked Issues or Issue Description
@@ -19,15 +19,16 @@ Parallel test jobs should have similar workloads. Timing metadata must never aut
**Steps to reproduce**
Run `node scripts/ci/refresh-test-durations.mjs` with GitHub Actions read access and a full master checkout.
Run `node scripts/ci/refresh-test-durations.mjs` with GitHub Actions read access and a full master checkout. Add `--dry-run` to print the changes without writing manifests.
## What Changed
- Refresh general-server and serialized timing weights from recent successful master verification runs.
- Refresh e2e spec and Runner package-lane weights from successful PR-workflow runs of recently merged PRs, each pinned to the PR's final head SHA. A lane with fewer than two valid runs keeps its current weights.
## Verification
- The collector validates repository, workflow, branch, event, attempt, successful jobs, commit ancestry and measurement coverage.
- The collector validates repository, workflow, branch, event, attempt, successful jobs, commit ancestry and measurement coverage. Merged-PR sources must be `pull_request` runs of `.github/workflows/pr.yml` whose head SHA equals the merged PR's final head and whose merge commit is in this checkout.
- Timing validation and full shard coverage tests pass before this branch is pushed.
- Review the source run links and coverage recorded in both manifests.
- Required PR checks and review must pass before merge. GitHub may require approval to start checks for a PR created with `GITHUB_TOKEN`.
@@ -36,10 +37,11 @@ Run `node scripts/ci/refresh-test-durations.mjs` with GitHub Actions read access
- Runner load can distort measurements. Medians reduce this noise. Missing measurements retain previous weights.
- The changes affect balance, not which tests execute. There is no automatic merge or AWS authorization change.
<!-- oversized-test-files -->
## Model Used
- No model ran this refresh. The generator was authored with OpenAI Codex (GPT-6); exact deployment ID and context window were not exposed. It uses code execution and repository inspection.
- No model ran this refresh. The generator was authored with OpenAI Codex (GPT-6); exact deployment ID and context window were not exposed. The merged-PR lanes and oversized-file report were added with Anthropic Claude Opus 5.5 (`claude-opus-5-5`). Both use code execution and repository inspection.
## Checklist
+23 -3
View File
@@ -140,6 +140,11 @@
"task": "ci:docker"
}
],
"sandbox_provider_tests": [
{
"task": "ci:sandbox-providers"
}
],
"verify_paperclip_runner": [
{
"lane_label": "static checks",
@@ -153,13 +158,23 @@
"task": "ci:runner-rust"
},
{
"lane_label": "vitest package",
"command": "test:typescript:vitest --shard=1/2",
"lane_label": "vitest package (1/3)",
"command": "test:typescript:vitest --package-lane=1/3",
"task": "ci:runner-vitest"
},
{
"lane_label": "vitest package (2/3)",
"command": "test:typescript:vitest --package-lane=2/3",
"task": "ci:runner-vitest"
},
{
"lane_label": "vitest package (3/3)",
"command": "test:typescript:vitest --package-lane=3/3",
"task": "ci:runner-vitest"
},
{
"lane_label": "vitest native server",
"command": "test:typescript:vitest --shard=2/2",
"command": "test:typescript:vitest --native-server",
"release_crate": true,
"task": "ci:runner-vitest"
}
@@ -279,5 +294,10 @@
"shard_label": "8/8",
"task": "ci:e2e"
}
],
"verify_runner_e2e_support": [
{
"task": "ci:runner-e2e-support"
}
]
}
+69
View File
@@ -30,6 +30,75 @@ updates:
update-types:
- "version-update:semver-major"
# Sandbox providers (packages/plugins/sandbox-providers/*). pnpm-workspace.yaml
# keeps them out of the root workspace on purpose, so the "/" entry above never
# sees them. Each provider is a standalone published package with its own
# version ranges.
#
# Each lockfile has exactly one entry, so every pull request changes a
# manifest and the lockfile that installs it together, and CI can pass.
#
# scripts/typecheck-sandbox-providers is a small pnpm workspace that lists every
# provider without its own lockfile, and holds one lockfile for them. CI
# installs that lockfile frozen. Dependabot updates this workspace as one unit:
# one pull request per dependency changes every provider that uses the
# dependency, and the shared lockfile, together. So this entry needs no
# `groups`. One entry per provider would open a separate pull request for each
# provider for the same package, and each one would fail CI because the shared
# lockfile would stay old.
#
# Dependabot does not expand a workspace glob that starts with ../, so that
# workspace lists each provider by its full path. A test checks the list.
#
# Providers are published libraries, so Dependabot's default versioning
# strategy keeps a range when a new version fits it. It widens the range when a
# release falls outside it, for example zod 3 to zod 4.
- package-ecosystem: npm
directory: "/scripts/typecheck-sandbox-providers"
schedule:
interval: weekly
day: monday
time: "06:00"
open-pull-requests-limit: 10
labels:
- "dependencies"
ignore:
# Same reason as the "/" entry.
- dependency-name: "@types/node"
update-types:
- "version-update:semver-major"
# Daytona is the only provider with its own lockfile. The release build and the
# Daytona end-to-end job install it frozen, so the shared workspace above leaves
# Daytona out, and scripts/typecheck-test-programs.mjs installs Daytona from
# this lockfile (LOCKED_PROVIDERS). This entry is the only one that changes
# Daytona's package.json, and it changes this lockfile in the same pull request.
- package-ecosystem: npm
directory: "/packages/plugins/sandbox-providers/daytona"
schedule:
interval: weekly
day: monday
time: "06:00"
open-pull-requests-limit: 5
labels:
- "dependencies"
ignore:
# Same reason as the "/" entry.
- dependency-name: "@types/node"
update-types:
- "version-update:semver-major"
# No entry for packages/paperclip-runner/scripts/pi-distribution or
# kimi-distribution. Every direct dependency there is a pinned provider
# release, and the pins feed each provider's ACPX commandDigest. The Runner
# checks compare the Pi lockfile with PI_DISTRIBUTION_PINS, and the Kimi build
# compares the SHA-256 of its package-lock.json with a pin. So a Dependabot
# change to either lockfile fails CI (for example #18998), and a real upgrade
# also starts saved sessions fresh. Change these by hand (see
# rules/acpx-profile-identity.mdc). [email protected] needs zod ^3.25.0 as a
# direct dependency, so the Pi zod pin (3.25.76) cannot move to zod 4 until
# pi-acp allows it. GitHub security alerts still cover both lockfiles.
- package-ecosystem: github-actions
directory: "/"
schedule:
+22 -8
View File
@@ -1,16 +1,19 @@
#!/usr/bin/env node
/**
* check-lockfile-release-age.mjs
* Decides whether the lockfile bot may merge a refreshed pnpm-lock.yaml without review.
* Decides whether the lockfile bot may merge refreshed pnpm lockfiles without review.
* Every package version that the refresh adds must be published on npm for at least
* the minimum age. A version that is too new, or that cannot be verified, holds the
* PR for a human.
*
* Exports: parseLockfilePackages(text), addedPackageVersions(baseText, headText),
* addedPackageVersionsAcross(pairs),
* checkReleaseAge(entries, { fetchPublishTime, now, minAgeMs, concurrency })
*
* CLI: node check-lockfile-release-age.mjs --base <file> --head <file> --min-age-days <n>
* Writes `eligible=true|false` to GITHUB_OUTPUT and a Markdown report to --report.
* CLI: node check-lockfile-release-age.mjs --base <file> --head <file> [--base <file> --head <file> ...]
* --min-age-days <n>
* Pass one --base/--head pair per lockfile, in the same order. Writes `eligible=true|false`
* to GITHUB_OUTPUT and a Markdown report to --report.
*/
import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
@@ -65,6 +68,15 @@ export function addedPackageVersions(baseText, headText) {
.map(([key, resolution]) => ({ key, ...splitPackageKey(key), resolution }));
}
/** Added versions across several base/head lockfile pairs, each listed once. */
export function addedPackageVersionsAcross(pairs) {
const entries = new Map();
for (const [baseText, headText] of pairs) {
for (const entry of addedPackageVersions(baseText, headText)) entries.set(`${entry.key}\0${entry.resolution}`, entry);
}
return [...entries.values()].sort((a, b) => a.key.localeCompare(b.key));
}
export async function fetchNpmPublishTime(name, version, fetchImpl = fetch) {
const res = await fetchImpl(`https://registry.npmjs.org/${name.replace('/', '%2f')}`, {
headers: { Accept: 'application/json' },
@@ -135,19 +147,21 @@ export function formatReport(result, minAgeDays) {
async function main() {
const { values } = parseArgs({
options: {
base: { type: 'string' },
head: { type: 'string' },
base: { type: 'string', multiple: true },
head: { type: 'string', multiple: true },
'min-age-days': { type: 'string' },
report: { type: 'string' },
},
});
const minAgeDays = Number(values['min-age-days']);
if (!values.base || !values.head || !Number.isFinite(minAgeDays) || minAgeDays < 0) {
console.error('Usage: check-lockfile-release-age.mjs --base <file> --head <file> --min-age-days <n> [--report <file>]');
const bases = values.base ?? [];
const heads = values.head ?? [];
if (bases.length === 0 || bases.length !== heads.length || !Number.isFinite(minAgeDays) || minAgeDays < 0) {
console.error('Usage: check-lockfile-release-age.mjs --base <file> --head <file> [--base <file> --head <file> ...] --min-age-days <n> [--report <file>]');
process.exit(2);
}
const entries = addedPackageVersions(readFileSync(values.base, 'utf8'), readFileSync(values.head, 'utf8'));
const entries = addedPackageVersionsAcross(bases.map((base, index) => [readFileSync(base, 'utf8'), readFileSync(heads[index], 'utf8')]));
const result = await checkReleaseAge(entries, { minAgeMs: minAgeDays * DAY_MS });
const report = formatReport(result, minAgeDays);
console.log(report);
+14 -9
View File
@@ -1,24 +1,29 @@
#!/usr/bin/env node
/**
* check-pr-lockfile.mjs
* Checks that pnpm-lock.yaml was not manually edited.
* Advisory check that lock content was not edited by hand. `pr-trusted.yml` enforces the
* same rule with `lockfile-trust.mjs`, which also verifies Dependabot commit signatures.
* Export: checkLockfile(files, prAuthor, prBranch) → { passed, failures }
*/
import { fileURLToPath } from 'node:url';
import { DEPENDABOT_LOGIN, LOCKFILES, REFRESH_BRANCH, REFRESH_PR_AUTHOR, REPORT_LOCK_FILES } from './lockfile-trust.mjs';
export function checkLockfile(files, prAuthor, prBranch) {
const lockfileChanged = files.some(f => f.filename === 'pnpm-lock.yaml');
if (!lockfileChanged) return { passed: true, failures: [] };
const changed = files.map(f => f.filename);
const lockfilesChanged = changed.filter(f => LOCKFILES.includes(f));
const proofChanged = changed.filter(f => REPORT_LOCK_FILES.includes(f));
if (lockfilesChanged.length === 0 && proofChanged.length === 0) return { passed: true, failures: [] };
const isRefreshBot =
prAuthor === 'github-actions[bot]' && prBranch === 'chore/refresh-lockfile';
if (prAuthor === REFRESH_PR_AUTHOR && prBranch === REFRESH_BRANCH) return { passed: true, failures: [] };
if (prAuthor === DEPENDABOT_LOGIN && proofChanged.length === 0) return { passed: true, failures: [] };
const edited = [...lockfilesChanged, ...proofChanged].map(f => `\`${f}\``).join(', ');
return {
passed: isRefreshBot,
failures: isRefreshBot ? [] : [
'You have changes to `pnpm-lock.yaml` — `pr.yml` will hard-fail this PR with a confusing message about lockfile edits. ' +
passed: false,
failures: [
`You have changes to ${edited} — \`pr.yml\` will hard-fail this PR because only the lockfile refresh workflow and Dependabot may change lock content. ` +
'To fix: run `pnpm install` locally, exclude the lockfile from your commit, push again. ' +
'The lockfile is regenerated automatically by the refresh bot on a schedule.',
'The refresh bot regenerates every managed lockfile after your PR merges.',
],
};
}
+237
View File
@@ -0,0 +1,237 @@
#!/usr/bin/env node
/**
* lockfile-trust.mjs
*
* Decides who may change the dependency locks (LOCKFILES). Only trusted environments write them:
* - the lockfile refresh workflow (`chore/refresh-lockfile`, opened by github-actions[bot]),
* - Dependabot, when the exact bytes of each lockfile it changes come from a verified Dependabot commit.
*
* The reporting-lock proof (`tests/runner-e2e/report-lock.json` and `.patch`) can rewrite the
* lock that paid evaluation jobs install, so it counts as lock content too. Only the refresh
* workflow writes it. After a verified Dependabot lockfile merges, the refresh workflow renews
* the proof for it.
*
* Keep this file free of imports outside node:*. The PR policy job runs the copy from the current
* base-branch tip, so a pull request cannot rewrite the check that judges it.
*
* Usage:
* node .github/scripts/lockfile-trust.mjs pr # PR policy job
* node .github/scripts/lockfile-trust.mjs merged # refresh workflow on master
*/
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { appendFileSync, realpathSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// The root lockfile. The reporting-lock proof and the `merged` command follow this one only.
export const LOCKFILE = 'pnpm-lock.yaml';
// Sandbox providers stay out of the root workspace (see doc/DEVELOPING.md), so they have their own
// pnpm lockfiles. The refresh workflow regenerates these too, and its LOCKFILES env lists the same
// paths. Hand-managed pins, such as the Pi and Kimi distributions, are not listed.
export const PROVIDER_LOCKFILES = [
'scripts/typecheck-sandbox-providers/pnpm-lock.yaml',
'packages/plugins/sandbox-providers/daytona/pnpm-lock.yaml',
];
export const LOCKFILES = [LOCKFILE, ...PROVIDER_LOCKFILES];
export const REPORT_LOCK_FILES = ['tests/runner-e2e/report-lock.json', 'tests/runner-e2e/report-lock.patch'];
export const DEPENDABOT_LOGIN = 'dependabot[bot]';
export const REFRESH_BRANCH = 'chore/refresh-lockfile';
export const REFRESH_PR_AUTHOR = 'github-actions[bot]';
// GitHub commits and signs Dependabot commits as web-flow. A verified signature proves only who
// committed, and anyone can set the author field, so require GitHub as the committer as well.
export const GITHUB_COMMITTER_LOGIN = 'web-flow';
const RECREATE_HINT = 'Comment `@dependabot recreate` so Dependabot rebuilds the lockfile, instead of merging or editing it by hand.';
/**
* True for a commit that Dependabot authored and GitHub committed and signed.
* @param {{ login: string | null, committer: string | null, verified: boolean }} commit
*/
export function isDependabotCommit(commit) {
return commit.login === DEPENDABOT_LOGIN && commit.committer === GITHUB_COMMITTER_LOGIN && commit.verified === true;
}
/**
* Find a Dependabot commit (see isDependabotCommit) in the pull request that holds exactly the
* lockfile under review.
* @param {{ lockBlob: string | null,
* commits: Array<{ sha: string, login: string | null, committer: string | null, verified: boolean }>,
* lockBlobAt: (sha: string) => string | null }} input
*/
export function findDependabotLockCommit({ lockBlob, commits, lockBlobAt }) {
if (!lockBlob) return null;
for (const commit of [...commits].reverse()) {
if (!isDependabotCommit(commit)) continue;
if (lockBlobAt(commit.sha) === lockBlob) return commit.sha;
}
return null;
}
/**
* Classify the lock-content change in a pull request.
* `dependabotLockCommits` maps each changed lockfile to the verified Dependabot commit that holds
* its exact bytes, or to null.
* @returns {{ allowed: boolean, writer: 'none' | 'dependabot' | 'refresh-bot', reason: string }}
*/
export function classifyPullRequest({ changedPaths, author, headRepo, baseRepo, headRef, dependabotLockCommits = {} }) {
const changedLockfiles = LOCKFILES.filter((file) => changedPaths.includes(file));
const proofChanged = changedPaths.some((file) => REPORT_LOCK_FILES.includes(file));
if (changedLockfiles.length === 0 && !proofChanged) {
return { allowed: true, writer: 'none', reason: 'The pull request does not change lock content.' };
}
const sameRepo = Boolean(headRepo) && headRepo === baseRepo;
if (sameRepo && author === REFRESH_PR_AUTHOR && headRef === REFRESH_BRANCH) {
return { allowed: true, writer: 'refresh-bot', reason: 'The lockfile refresh workflow opened this pull request.' };
}
if (sameRepo && author === DEPENDABOT_LOGIN) {
if (proofChanged) {
return { allowed: false, writer: 'dependabot',
reason: `Dependabot pull requests must not change ${REPORT_LOCK_FILES.join(' or ')}. The lockfile refresh renews the reporting-lock proof after merge.` };
}
const unverified = changedLockfiles.filter((file) => !dependabotLockCommits[file]);
if (unverified.length > 0) {
return { allowed: false, writer: 'dependabot',
reason: `${unverified.join(', ')} does not match any verified Dependabot commit in this pull request. ${RECREATE_HINT}` };
}
return { allowed: true, writer: 'dependabot',
reason: changedLockfiles.map((file) => `${file} matches verified Dependabot commit ${dependabotLockCommits[file]}.`).join(' ') };
}
const files = [...changedLockfiles, ...REPORT_LOCK_FILES.filter((file) => changedPaths.includes(file))];
return { allowed: false, writer: 'none',
reason: `Do not commit ${files.join(', ')} in pull requests. CI owns lockfile updates.` };
}
/**
* True when GitHub created and signed the commit, as it does when it merges a pull request.
* @param {any} commit Response of GET /repos/{repo}/commits/{sha}.
*/
export function isGitHubSignedCommit(commit) {
return commit?.committer?.login === GITHUB_COMMITTER_LOGIN && commit?.commit?.verification?.verified === true;
}
/**
* Pick the merged Dependabot pull request whose squash or merge commit is `commitSha`.
* @param {Array<any>} pulls Response of GET /repos/{repo}/commits/{sha}/pulls.
*/
export function findMergedDependabotPull(pulls, commitSha, defaultBranch) {
return pulls.find((pull) => pull?.user?.login === DEPENDABOT_LOGIN && Boolean(pull.merged_at) &&
pull.merge_commit_sha === commitSha && pull?.base?.ref === defaultBranch) ?? null;
}
function run(command, args, options = {}) {
return execFileSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...options }).trim();
}
function tryRun(command, args) {
try { return run(command, args); } catch { return null; }
}
function ghJsonLines(endpoint, jq) {
const output = run('gh', ['api', '--paginate', endpoint, '--jq', jq]);
return output ? output.split('\n').map((line) => JSON.parse(line)) : [];
}
function pullCommits(repository, number) {
return ghJsonLines(`repos/${repository}/pulls/${number}/commits?per_page=100`,
'.[] | {sha, login: .author.login, committer: .committer.login, verified: .commit.verification.verified}');
}
function writeOutputs(values) {
const lines = Object.entries(values).map(([key, value]) => `${key}=${value}\n`).join('');
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, lines);
}
function required(name) {
const value = process.env[name];
if (!value) throw new Error(`${name} is required`);
return value;
}
function checkPullRequest() {
const repository = required('GITHUB_REPOSITORY');
const baseSha = required('BASE_SHA');
const headSha = required('HEAD_SHA');
const author = required('PR_AUTHOR');
// Diff against the merge base so recent base-branch commits are not counted as PR changes.
const changedPaths = run('git', ['diff', '--name-only', `${baseSha}...${headSha}`]).split('\n').filter(Boolean);
const dependabotLockCommits = {};
const changedLockfiles = LOCKFILES.filter((file) => changedPaths.includes(file));
if (author === DEPENDABOT_LOGIN && changedLockfiles.length > 0) {
const commits = pullCommits(repository, required('PR_NUMBER'));
for (const file of changedLockfiles) {
dependabotLockCommits[file] = findDependabotLockCommit({
lockBlob: tryRun('git', ['rev-parse', `${headSha}:${file}`]),
commits,
lockBlobAt: (sha) => tryRun('git', ['rev-parse', `${sha}:${file}`]),
});
}
}
const result = classifyPullRequest({
changedPaths, author, headRepo: process.env.HEAD_REPO ?? '', baseRepo: repository,
headRef: process.env.HEAD_REF ?? '', dependabotLockCommits,
});
console.log(result.reason);
writeOutputs({ writer: result.writer });
if (!result.allowed) {
console.log(`::error title=Untrusted lockfile change::${result.reason}`);
process.exitCode = 1;
}
}
/**
* On master: report the committed lockfile's SHA-256 when its last change is a GitHub-signed merge
* of a Dependabot pull request whose head lockfile bytes come from a verified Dependabot commit.
* When master's lockfile moved after Dependabot's commit, GitHub's merge combines the two trusted
* lockfiles, so the merged bytes can differ from Dependabot's. The refresh then records the
* resolution of that merged lock as the reviewed correction. Otherwise report nothing.
*/
function checkMerged() {
const repository = required('GITHUB_REPOSITORY');
const head = required('GITHUB_SHA');
const defaultBranch = process.env.DEFAULT_BRANCH || 'master';
const lockCommit = run('gh', ['api', `repos/${repository}/commits?sha=${head}&path=${LOCKFILE}&per_page=1`, '--jq', '.[0].sha // empty']);
const pull = lockCommit
? findMergedDependabotPull(JSON.parse(run('gh', ['api', `repos/${repository}/commits/${lockCommit}/pulls`])), lockCommit, defaultBranch)
: null;
if (pull && !isGitHubSignedCommit(JSON.parse(run('gh', ['api', `repos/${repository}/commits/${lockCommit}`])))) {
console.log(`::warning title=Unverified Dependabot merge::${lockCommit} is not a GitHub-signed merge of #${pull.number}. The reporting-lock proof is not renewed.`);
writeOutputs({ trusted_lock_sha256: '', dependabot_pr: String(pull.number) });
return;
}
if (!pull) {
console.log(`The last ${LOCKFILE} change on ${defaultBranch} is not a merged Dependabot pull request.`);
writeOutputs({ trusted_lock_sha256: '', dependabot_pr: '' });
return;
}
// The checkout is shallow, so read each candidate's lockfile blob hash from the API.
const lockBlobAt = (sha) => tryRun('gh', ['api', `repos/${repository}/git/trees/${sha}`,
'--jq', `.tree[] | select(.path == "${LOCKFILE}") | .sha`]) || null;
const verified = findDependabotLockCommit({
lockBlob: lockBlobAt(pull.head.sha),
commits: pullCommits(repository, pull.number),
lockBlobAt,
});
if (!verified) {
console.log(`::warning title=Unverified Dependabot lockfile::The ${LOCKFILE} at the head of #${pull.number} matches no verified Dependabot commit. The reporting-lock proof is not renewed.`);
writeOutputs({ trusted_lock_sha256: '', dependabot_pr: String(pull.number) });
return;
}
const lockSha256 = createHash('sha256').update(execFileSync('git', ['show', `HEAD:${LOCKFILE}`])).digest('hex');
console.log(`${LOCKFILE} on ${defaultBranch} is GitHub's merge of verified Dependabot commit ${verified} from #${pull.number}.`);
writeOutputs({ trusted_lock_sha256: lockSha256, dependabot_pr: String(pull.number) });
}
// Compare real paths: the runner's temporary directory can sit behind a symlink.
if (process.argv[1] && realpathSync(path.resolve(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url))) {
const mode = process.argv[2];
if (mode === 'pr') checkPullRequest();
else if (mode === 'merged') checkMerged();
else throw new Error('Usage: lockfile-trust.mjs pr|merged');
}
+177
View File
@@ -0,0 +1,177 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
const repository = "paperclipai/paperclip";
const shaPattern = /^[a-f0-9]{40}$/;
const minimumChecks = ["ci / verify", "ci / e2e"];
const gh = (args) => JSON.parse(execFileSync("gh", args, {
encoding: "utf8", maxBuffer: 16 * 1024 * 1024, timeout: 30_000,
// Never allow command diagnostics or a response to reach the Actions log.
stdio: ["ignore", "pipe", "pipe"],
}));
const api = (endpoint) => gh(["api", endpoint]);
const pages = (endpoint) => gh(["api", "--paginate", "--slurp", endpoint]).flat();
const query = `query($number:Int!, $endCursor:String) {
repository(owner:"paperclipai", name:"paperclip") {
pullRequest(number:$number) {
number url state isDraft headRefOid baseRefOid baseRefName changedFiles
statusCheckRollup { contexts(first:100, after:$endCursor) {
totalCount pageInfo { hasNextPage endCursor }
nodes {
__typename
... on CheckRun { name status conclusion checkSuite { databaseId app { databaseId }
workflowRun { databaseId } } }
... on StatusContext { context state }
}
} }
}
}
}`;
export function snapshot(number, request = gh) {
let candidate;
let cursor;
const checks = [];
do {
const args = ["api", "graphql", "-f", `query=${query}`, "-F", `number=${number}`];
if (cursor) args.push("-f", `endCursor=${cursor}`);
const result = request(args);
if (result.errors?.length) throw new Error("Incomplete check listing");
const pr = result.data.repository.pullRequest;
if (candidate && (pr.headRefOid !== candidate.headRefOid || pr.baseRefOid !== candidate.baseRefOid)) {
throw new Error("Candidate changed during check pagination");
}
candidate = pr;
const contexts = pr.statusCheckRollup.contexts;
checks.push(...contexts.nodes);
if (!contexts.pageInfo.hasNextPage) {
if (checks.length !== contexts.totalCount) throw new Error("Incomplete check listing");
break;
}
const next = contexts.pageInfo.endCursor;
if (!next || next === cursor || checks.length >= contexts.totalCount) throw new Error("Incomplete check listing");
cursor = next;
} while (checks.length < 10_000);
if (candidate.statusCheckRollup.contexts.pageInfo.hasNextPage) throw new Error("Check listing limit exceeded");
return { ...candidate, statusCheckRollup: checks };
}
export function green(pr, requiredNames = minimumChecks) {
if (pr.state !== "OPEN" || pr.isDraft || pr.baseRefName !== "master" ||
!shaPattern.test(pr.headRefOid) || !shaPattern.test(pr.baseRefOid)) return false;
const checks = pr.statusCheckRollup;
if (!Array.isArray(checks) || checks.length === 0) return false;
const relevant = checks;
const passed = (check) => check.__typename === "StatusContext"
? check.state === "SUCCESS"
: check.__typename === "CheckRun" && check.status === "COMPLETED" && check.conclusion === "SUCCESS";
// Optional skipped/neutral checks can mean no work or unavailable coverage.
// They permit assessment, not approval. Required checks must really succeed.
return relevant.every((check) => passed(check) || (check.__typename === "CheckRun" &&
check.status === "COMPLETED" && ["SKIPPED", "NEUTRAL"].includes(check.conclusion))) &&
[...new Set([...minimumChecks, ...requiredNames])].every((name) =>
relevant.some((check) => (check.name ?? check.context) === name && passed(check)));
}
export function trustedVerification(pr, { read = api, list = pages } = {}) {
// Names alone are forgeable in pull_request YAML. Require the real Actions
// suite for the unmodified PR caller and its master-owned reusable workflow.
const actionsChecks = pr.statusCheckRollup.filter((check) =>
check.__typename === "CheckRun" && check.status === "COMPLETED" && check.conclusion === "SUCCESS" &&
check.checkSuite?.app?.databaseId === 15368 &&
Number.isSafeInteger(check.checkSuite?.workflowRun?.databaseId));
const runIds = new Set(actionsChecks.filter((check) => minimumChecks.includes(check.name))
.map((check) => check.checkSuite.workflowRun.databaseId));
let trusted = false;
for (const id of runIds) {
const run = read(`/repos/${repository}/actions/runs/${id}`);
if (run.repository?.full_name !== repository || run.path !== ".github/workflows/pr.yml" ||
run.event !== "pull_request" || run.head_sha !== pr.headRefOid ||
run.status !== "completed" || run.conclusion !== "success") continue;
if (!minimumChecks.every((name) => actionsChecks.some((check) => check.name === name &&
check.checkSuite.workflowRun.databaseId === id && check.checkSuite.databaseId === run.check_suite_id))) continue;
const reusable = run.referenced_workflows?.find((entry) =>
entry.path === `${repository}/.github/workflows/pr-trusted.yml@master` &&
entry.ref === "refs/heads/master" && shaPattern.test(entry.sha));
if (reusable) { trusted = true; break; }
}
if (!trusted || !Number.isSafeInteger(pr.changedFiles) || pr.changedFiles < 0) return false;
const files = list(`/repos/${repository}/pulls/${pr.number}/files?per_page=100`);
// GitHub caps this API at 3,000 files. A partial listing cannot prove that
// the caller is unmodified. Do not execute or download any of these files.
return files.length === pr.changedFiles && files.every((file) =>
typeof file.filename === "string" && ![file.filename, file.previous_filename].includes(".github/workflows/pr.yml"));
}
export async function deliver(pr, { url, token, fetcher = fetch } = {}) {
if (!url || !token) return "unconfigured";
try {
const target = new URL(url);
if (target.protocol !== "https:" || target.username || target.password || target.search || target.hash ||
!/^\/api\/routine-triggers\/public\/[a-f0-9]+\/fire$/.test(target.pathname)) return "invalid_configuration";
const response = await fetcher(target, {
method: "POST", redirect: "error", signal: AbortSignal.timeout(15_000),
headers: {
"Content-Type": "application/json", Authorization: `Bearer ${token}`,
"Idempotency-Key": `pr-review:${repository}:${pr.number}:${pr.headRefOid}:${pr.baseRefOid}`,
},
// No PR title, body, comments, or executable instructions cross this boundary.
body: JSON.stringify({ variables: {
pr_url: `https://github.com/${repository}/pull/${pr.number}`,
pr_number: String(pr.number), head_sha: pr.headRefOid, base_sha: pr.baseRefOid,
} }),
});
// Do not read the response body: a proxy can echo the URL or credentials.
await response.body?.cancel();
return response.ok ? "delivered" : "not_delivered";
} catch {
// Fetch errors can contain the private URL. Keep the result fixed and quiet.
return "not_delivered";
}
}
export async function notify(event, { read = api, list = pages, view = snapshot, send = deliver } = {}) {
if (event.repository?.full_name !== repository) return [];
const run = event.workflow_run;
if (run && (run.status !== "completed" || !["pull_request", "pull_request_target"].includes(run.event))) return [];
const sha = run?.head_sha ?? event.check_run?.head_sha ?? event.sha;
if (!shaPattern.test(sha)) return [];
// Resolve forks through the commit API too: workflow_run.pull_requests can be empty.
const associated = run?.pull_requests?.length ? run.pull_requests
: list(`/repos/${repository}/commits/${sha}/pulls?per_page=100`);
const required = read(`/repos/${repository}/rules/branches/master`)
.filter((rule) => rule.type === "required_status_checks")
.flatMap((rule) => rule.parameters.required_status_checks.map((check) => check.context));
const outcomes = [];
for (const number of new Set(associated.map((pr) => pr.number))) {
if (!Number.isSafeInteger(number) || number <= 0) continue;
const pr = view(number);
// Target-context workflows use the base SHA. All other events must still
// describe this PR's current head; an old successful run cannot trigger it.
if ((run?.event !== "pull_request_target" && pr.headRefOid !== sha) || !green(pr, required)) continue;
const fresh = view(number);
if (fresh.headRefOid !== pr.headRefOid || fresh.baseRefOid !== pr.baseRefOid ||
!green(fresh, required) || !trustedVerification(fresh, { read, list })) continue;
outcomes.push(await send(fresh));
}
return outcomes;
}
async function main() {
try {
const url = process.env.PAPERCLIP_PR_REVIEW_WEBHOOK_URL;
const token = process.env.PAPERCLIP_PR_REVIEW_WEBHOOK_TOKEN;
if (!url || !token) {
console.log("PR review notification is not configured; skipped.");
return;
}
const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"));
const outcomes = await notify(event, { send: (pr) => deliver(pr, { url, token }) });
console.log(outcomes.length ? `PR review notification: ${outcomes.join(", ")}.` : "No current green PR to notify.");
} catch {
console.log("PR review notification was unavailable; verification results are unchanged.");
}
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) await main();
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env node
/**
* runner-loss-rerun.mjs
*
* Re-runs the failed jobs of a first PR CI attempt once, but only when every
* failed job failed because its runner was lost. Runs from
* .github/workflows/ci-runner-loss-rerun.yml on workflow_run. It reads GitHub
* API metadata only and never touches the PR's code or artifacts.
*
* Local dry run against a real run (no rerun is requested):
* GITHUB_TOKEN=$(gh auth token) node .github/scripts/runner-loss-rerun.mjs --run-id <id> --dry-run
*/
import { appendFileSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
export const REPOSITORY = "paperclipai/paperclip";
export const REPOSITORY_ID = 1170821064;
export const PR_WORKFLOW_PATH = ".github/workflows/pr.yml";
// The runner's own messages when an AWS instance disappears mid-job.
export const RUNNER_LOSS_PATTERN = /lost communication with the server|received a shutdown signal/;
// `verify` and `e2e` in pr-trusted.yml only assert their `needs` results. They
// fail whenever a dependency failed and re-run with it.
export const AGGREGATE_JOBS = new Set(["ci / verify", "ci / e2e"]);
// ci-routing.yml checks the attempt's triggering actor. A bot-triggered attempt
// would move the whole run to GitHub-hosted runners, so leave it to a human.
export const GATE_JOB_PREFIX = "ci / gate / ";
// A shutdown signal often reaches the check run only as this annotation; the
// signal itself is logged as an error line in the job log.
const CANCELLED_MESSAGE = "The operation was canceled.";
export function createGithubClient({ token, fetcher = fetch, apiUrl = "https://api.github.com" }) {
if (!token) throw new Error("GITHUB_TOKEN is required");
const origin = new URL(apiUrl).origin;
const request = async (target, { method = "GET" } = {}) => {
const url = new URL(target, apiUrl);
if (url.origin !== origin) throw new Error(`Refusing to call ${url.origin}`);
const response = await fetcher(url.href, {
method,
headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${token}`, "X-GitHub-Api-Version": "2022-11-28" },
signal: AbortSignal.timeout(60_000),
});
if (!response.ok) throw new Error(`GitHub API ${method} ${url.pathname} failed (HTTP ${response.status})`);
return response;
};
return {
json: async (target) => (await request(target)).json(),
// Follows Link rel="next" until the last page. `field` selects the array
// in wrapped responses such as { total_count, jobs }.
paginate: async (target, field) => {
const items = [];
let totalCount;
for (let next = target, pages = 0; next; pages += 1) {
if (pages >= 100) throw new Error(`Too many pages for ${target}`);
const response = await request(next);
const body = await response.json();
const page = field ? body[field] : body;
if (!Array.isArray(page)) throw new Error(`Unexpected page shape for ${target}`);
items.push(...page);
if (field && Number.isInteger(body.total_count)) totalCount = body.total_count;
next = /<([^>]+)>;\s*rel="next"/.exec(response.headers.get("link") ?? "")?.[1];
}
if (totalCount !== undefined && items.length !== totalCount) {
throw new Error(`Incomplete listing for ${target}: ${items.length} of ${totalCount}`);
}
return items;
},
// Job logs answer with a redirect to a signed download URL.
text: async (target) => (await request(target)).text(),
post: async (target) => { await request(target, { method: "POST" }); },
};
}
export function logShowsRunnerLoss(log) {
return log.split("\n").some((line) => line.includes("##[error]") && RUNNER_LOSS_PATTERN.test(line));
}
/** Classifies one failed job. Logs are read only when annotations are inconclusive. */
export async function classifyJob(job, { annotations, readLog }) {
const failures = annotations.filter((annotation) => annotation.annotation_level === "failure");
const isRunnerLoss = (annotation) => RUNNER_LOSS_PATTERN.test(annotation.message ?? "");
// A step can fail for a real reason before the runner is lost (for example a
// test failure, then a loss during an always() upload step). Any failure
// annotation other than runner loss or cancellation marks a real failure.
const real = failures.find((annotation) => !isRunnerLoss(annotation) && annotation.message !== CANCELLED_MESSAGE);
if (real) return { lost: false, evidence: firstLine(real.message) };
const match = failures.find(isRunnerLoss);
if (match) return { lost: true, evidence: firstLine(match.message) };
// Only cancellations are left: the shutdown signal, if any, is in the log.
if (failures.length > 0) {
if (logShowsRunnerLoss(await readLog(job))) {
return { lost: true, evidence: "job log: runner received a shutdown signal or lost communication" };
}
return { lost: false, evidence: `${CANCELLED_MESSAGE} (no runner-loss error in the job log)` };
}
return { lost: false, evidence: "no failure annotation" };
}
function firstLine(message) {
const line = String(message).split("\n")[0];
return line.length > 160 ? `${line.slice(0, 157)}...` : line;
}
export function eligibleRun(run) {
if (run.repository?.full_name !== REPOSITORY || run.repository?.id !== REPOSITORY_ID) return "run is not in the canonical repository";
if (run.path !== PR_WORKFLOW_PATH) return `workflow is ${run.path}, not ${PR_WORKFLOW_PATH}`;
if (run.event !== "pull_request") return `event is ${run.event}, not pull_request`;
if (run.status !== "completed" || run.conclusion !== "failure") return `run is ${run.status}/${run.conclusion}, not completed/failure`;
// One automatic re-run only. A higher attempt also means someone re-ran it.
if (run.run_attempt !== 1) return `run is on attempt ${run.run_attempt}; only attempt 1 is re-run automatically`;
return undefined;
}
/**
* Re-running an older run joins the PR's `cancel-in-progress` concurrency
* group and would cancel newer CI. Require the run to be the latest CI for the
* PR's current head.
*/
export async function staleReason(client, run) {
const pulls = await client.paginate(`/repos/${REPOSITORY}/commits/${run.head_sha}/pulls?per_page=100`);
const open = pulls.filter((pr) => pr.state === "open" && pr.base?.repo?.id === REPOSITORY_ID
&& pr.head?.repo?.id === run.head_repository?.id && pr.head?.sha === run.head_sha);
if (open.length === 0) return "no open pull request still has this run's head commit";
const runs = await client.paginate(
`/repos/${REPOSITORY}/actions/workflows/pr.yml/runs?event=pull_request&head_sha=${run.head_sha}&per_page=100`,
"workflow_runs",
);
const newer = runs.find((candidate) => candidate.id > run.id && candidate.conclusion !== "skipped");
if (newer) return `a newer PR run (${newer.id}) exists for this head commit`;
return undefined;
}
export async function decide(client, run) {
const decision = { runId: run.id, attempt: run.run_attempt, rerun: false, jobs: [] };
const ineligible = eligibleRun(run);
if (ineligible) return { ...decision, reason: ineligible };
const jobs = await client.paginate(`/repos/${REPOSITORY}/actions/runs/${run.id}/attempts/1/jobs?per_page=100`, "jobs");
const failed = jobs.filter((job) => job.conclusion === "failure" && !AGGREGATE_JOBS.has(job.name));
if (failed.length === 0) return { ...decision, reason: "no failed jobs other than the verify/e2e aggregates" };
for (const job of failed) {
if (job.name.startsWith(GATE_JOB_PREFIX)) {
decision.jobs.push({ id: job.id, name: job.name, lost: false, evidence: "routing gate failed; a bot re-run would lose AWS routing" });
continue;
}
const annotations = await client.paginate(`/repos/${REPOSITORY}/check-runs/${job.id}/annotations?per_page=100`);
const readLog = (entry) => client.text(`/repos/${REPOSITORY}/actions/jobs/${entry.id}/logs`);
decision.jobs.push({ id: job.id, name: job.name, ...(await classifyJob(job, { annotations, readLog })) });
}
const real = decision.jobs.filter((job) => !job.lost);
if (real.length) return { ...decision, reason: `${real.length} of ${failed.length} failed jobs did not lose their runner` };
const stale = await staleReason(client, run);
if (stale) return { ...decision, reason: stale };
return { ...decision, rerun: true, reason: `all ${failed.length} failed jobs lost their runner` };
}
export function renderSummary(decision, { dryRun = false } = {}) {
const action = decision.rerun ? (dryRun ? "Would re-run failed jobs (dry run)" : "Re-ran failed jobs") : "No automatic re-run";
const lines = [
"### CI runner-loss re-run", "",
`**${action}** for run ${decision.runId} attempt ${decision.attempt}: ${decision.reason}.`, "",
];
if (decision.jobs.length) {
lines.push("| Job | Runner lost | Evidence |", "| --- | --- | --- |");
for (const job of decision.jobs) {
lines.push(`| ${escapeCell(job.name)} | ${job.lost ? "yes" : "no"} | ${escapeCell(job.evidence)} |`);
}
lines.push("");
}
return `${lines.join("\n")}\n`;
}
const escapeCell = (value) => String(value).replace(/\|/g, "\\|").replace(/[\r\n]+/g, " ");
export async function main({ env = process.env, argv = process.argv.slice(2), fetcher = fetch, log = console.log } = {}) {
const dryRun = argv.includes("--dry-run");
const argRunId = argv[argv.indexOf("--run-id") + 1];
const event = env.GITHUB_EVENT_PATH ? JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, "utf8")) : undefined;
const runId = argv.includes("--run-id") ? argRunId : event?.workflow_run?.id;
if (!/^[1-9]\d*$/.test(String(runId))) throw new Error("Provide --run-id or a workflow_run event");
const client = createGithubClient({ token: env.GITHUB_TOKEN, fetcher });
// Read the run's current state rather than the event snapshot: its attempt
// number is above 1 if anyone re-ran it after this event was queued.
const run = await client.json(`/repos/${REPOSITORY}/actions/runs/${runId}`);
const decision = await decide(client, run);
if (decision.rerun && !dryRun) {
await client.post(`/repos/${REPOSITORY}/actions/runs/${run.id}/rerun-failed-jobs`);
}
const summary = renderSummary(decision, { dryRun });
log(summary);
if (env.GITHUB_STEP_SUMMARY) appendFileSync(env.GITHUB_STEP_SUMMARY, summary);
return decision;
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
}
@@ -1,7 +1,12 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import {
addedPackageVersions,
addedPackageVersionsAcross,
checkReleaseAge,
formatReport,
parseLockfilePackages,
@@ -66,6 +71,30 @@ test('lists only package versions the refresh adds', () => {
]);
});
test('lists the versions each lockfile adds, once', () => {
const pairs = [
[lockfile(['[email protected]']), lockfile(['[email protected]', '[email protected]', '[email protected]'])],
[lockfile([]), lockfile(['[email protected]', '[email protected]'])],
];
assert.deepEqual(addedPackageVersionsAcross(pairs).map(entry => entry.key), ['[email protected]', '[email protected]', '[email protected]']);
});
test('the CLI rejects unpaired lockfiles before any registry lookup', () => {
const root = mkdtempSync(path.join(tmpdir(), 'release-age-cli-'));
try {
const file = path.join(root, 'pnpm-lock.yaml');
writeFileSync(file, lockfile([]));
const result = spawnSync(process.execPath, [
path.resolve('.github/scripts/check-lockfile-release-age.mjs'),
'--base', file, '--head', file, '--base', file, '--min-age-days', '3',
], { encoding: 'utf8' });
assert.equal(result.status, 2, result.stderr);
assert.match(result.stderr, /Usage/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('lists an existing version whose resolution changed', () => {
const swapped = ['[email protected]', '{tarball: https://example.com/left-pad-1.3.0.tgz}'];
assert.deepEqual(addedPackageVersions(lockfile(['[email protected]']), lockfile([swapped])), [
@@ -31,3 +31,32 @@ test('fails when lockfile changed by bot on wrong branch', () => {
);
assert.equal(result.passed, false);
});
test('passes when Dependabot changes the lockfile', () => {
assert.equal(checkLockfile(makeFiles(['pnpm-lock.yaml', 'package.json']), 'dependabot[bot]', 'dependabot/npm_and_yarn/zod-4.6.6').passed, true);
});
test('fails when Dependabot changes the reporting-lock proof', () => {
const result = checkLockfile(makeFiles(['pnpm-lock.yaml', 'tests/runner-e2e/report-lock.json']), 'dependabot[bot]', 'dependabot/npm_and_yarn/zod-4.6.6');
assert.equal(result.passed, false);
assert.ok(result.failures[0].includes('report-lock.json'));
});
test('fails when a regular user changes the reporting-lock proof', () => {
const result = checkLockfile(makeFiles(['tests/runner-e2e/report-lock.patch']), 'someuser', 'fix/bug');
assert.equal(result.passed, false);
assert.ok(result.failures[0].includes('report-lock.patch'));
});
test('fails when a regular user changes a sandbox provider lockfile', () => {
for (const file of ['scripts/typecheck-sandbox-providers/pnpm-lock.yaml', 'packages/plugins/sandbox-providers/daytona/pnpm-lock.yaml']) {
const result = checkLockfile(makeFiles([file]), 'someuser', 'fix/bug');
assert.equal(result.passed, false, file);
assert.ok(result.failures[0].includes(file));
}
});
test('passes when Dependabot changes a sandbox provider lockfile', () => {
const files = makeFiles(['packages/plugins/sandbox-providers/daytona/package.json', 'packages/plugins/sandbox-providers/daytona/pnpm-lock.yaml']);
assert.equal(checkLockfile(files, 'dependabot[bot]', 'dependabot/npm_and_yarn/packages/plugins/sandbox-providers/daytona/zod-4.6.6').passed, true);
});
+28 -1
View File
@@ -33,7 +33,9 @@ test("a UI component retains browser and build coverage without unrelated server
assert.equal(selected(result, "build"), true);
assert.equal(selected(result, "e2e_shards"), true);
assert.equal(selected(result, "verify_serialized_server"), false);
assert.deepEqual([...selectedTasks(result)].filter((task) => /server|chat|runner/.test(task)), []);
// The Runner support suite imports UI modules and checks that catalog-listed
// UI files stay tracked, so UI source changes are its inputs too.
assert.deepEqual([...selectedTasks(result)].filter((task) => /server|chat|runner/.test(task)), ["ci:runner-e2e-support"]);
assert.equal(result.jobs.general_tests.selected, 2);
});
@@ -76,6 +78,31 @@ test("shared contracts, runtime prompts, protocol inputs and fixtures reach cons
}
});
test("sandbox provider tests run only for the providers, the plugin SDK, and what the SDK imports", () => {
for (const file of ["packages/plugins/sandbox-providers/daytona/src/plugin.ts",
"packages/plugins/sandbox-providers/cloudflare/bridge-template/src/index.ts",
"packages/plugins/sdk/src/index.ts", "packages/shared/src/types/issue.ts"]) {
assert.equal(selected(plan([file]), "sandbox_provider_tests"), true, file);
}
for (const file of ["server/src/index.ts", "ui/src/components/Sidebar.tsx", "cli/src/index.ts",
"packages/paperclip-runner/src/index.ts", "packages/plugins/plugin-workspace-diff/src/worker.ts"]) {
assert.equal(selected(plan([file]), "sandbox_provider_tests"), false, file);
}
// The provider lockfiles, the shared install workspace and the runner script are global inputs.
for (const file of ["scripts/typecheck-sandbox-providers/pnpm-lock.yaml", "scripts/test-sandbox-providers.mjs",
"packages/plugins/sandbox-providers/daytona/package.json"]) {
assert.equal(selected(plan([file]), "sandbox_provider_tests"), true, file);
}
// The SDK depends on shared and nothing else in the workspace. A new dependency must join the inputs.
const sdk = JSON.parse(readFileSync(new URL("packages/plugins/sdk/package.json", root), "utf8"));
const workspace = Object.entries({ ...sdk.dependencies, ...sdk.peerDependencies })
.filter(([, version]) => String(version).startsWith("workspace:")).map(([name]) => name);
assert.deepEqual(workspace, ["@paperclipai/shared"]);
const shared = JSON.parse(readFileSync(new URL("packages/shared/package.json", root), "utf8"));
assert.deepEqual(Object.values({ ...shared.dependencies, ...shared.peerDependencies })
.filter((version) => String(version).startsWith("workspace:")), []);
});
test("renames out of source and deletions cannot masquerade as release notes", () => {
const paths = comparisonPaths({ files: [
{ filename: "releases/v2026.1029.0.md", previous_filename: "packages/shared/src/constants.ts", status: "renamed" },
+2 -2
View File
@@ -330,10 +330,10 @@ test("workflow exporter runs trusted code and keeps keys out of the PR jobs", ()
assertNoPrWorkflowCredentials(pr);
assert.match(pr, /if: always\(\) && env.PAPERCLIP_CI_PROFILE == 'true'/);
assert.doesNotMatch(pr, /PAPERCLIP_CI_PROFILE:.*labels/);
assert.equal(pr.match(/PAPERCLIP_CI_PROFILE: \$\{\{ needs.gate.outputs.profile_ci \}\}/g).length, 9);
assert.equal(pr.match(/PAPERCLIP_CI_PROFILE: \$\{\{ needs.gate.outputs.profile_ci \}\}/g).length, 11);
// upload-artifact excludes files inside dot-directories by default. Keep
// this opt-in scoped to timing NDJSON, never the workspace or other secrets.
assert.equal(pr.match(/path: \.ci-profile\/\*\.ndjson\n {10}include-hidden-files: true/g).length, 9);
assert.equal(pr.match(/path: \.ci-profile\/\*\.ndjson\n {10}include-hidden-files: true/g).length, 11);
const routing = readFileSync(new URL("../../workflows/ci-routing.yml", import.meta.url), "utf8");
assert.match(routing, /PROFILE_CI: \$\{\{ steps.profile.outputs.profile_ci \}\}/);
assert.match(routing, /\/git\/ref\/heads\/master/);
+45 -2
View File
@@ -282,9 +282,9 @@ test("path scope skips full CI for a single-PR stack that only touches release n
}).full_ci, "false");
});
function routingPlacement(vars = {}, github = {}) {
function routingPlacement(vars = {}, github = {}, placementJobs = jobs) {
const source = "[34892728]";
const expression = jobs.match(/^ {4}runs-on: \$\{\{ (.+) \}\}$/m)?.[1];
const expression = placementJobs.match(/^ {4}runs-on: \$\{\{ (.+) \}\}$/m)?.[1];
assert.ok(expression, "routing expression is missing");
return vm.runInNewContext(expression, {
vars: {
@@ -330,3 +330,46 @@ for (const [name, vars, github, expected = "ubuntu-latest"] of placementCases) {
assert.equal(routingPlacement(vars, github), expected);
});
}
const catalogWorkflow = readFileSync(new URL("../../workflows/chat-catalog-testops.yml", import.meta.url), "utf8");
const catalogAuthorize = catalogWorkflow.split(" authorize:\n")[1].split("\n select:\n")[0];
const catalogContext = {
event_name: "workflow_dispatch",
workflow_ref: "paperclipai/paperclip/.github/workflows/chat-catalog-testops.yml@refs/heads/master",
actor: "dotta",
triggering_actor: "dotta",
run_attempt: "1",
};
const catalogPlacementCases = [
["approved first manual master attempt", {}, awsRunner],
["foreign repository", { repository: "other/paperclip" }],
["foreign repository ID", { repository_id: "999" }],
["untrusted branch", { ref: "refs/heads/other" }],
["tag", { ref: "refs/tags/master" }],
["pull request", { event_name: "pull_request" }],
["push", { event_name: "push" }],
["different workflow", { workflow_ref: "paperclipai/paperclip/.github/workflows/ci-routing.yml@refs/heads/master" }],
["branch workflow", { workflow_ref: "paperclipai/paperclip/.github/workflows/chat-catalog-testops.yml@refs/heads/other" }],
["unapproved actor", { actor_id: "999" }],
["partial actor ID", { actor_id: "4892728" }],
["different triggering actor", { triggering_actor: "other" }],
["missing triggering actor", { triggering_actor: "" }],
["rerun", { run_attempt: "2" }],
["missing attempt", { run_attempt: "" }],
];
for (const [name, overrides, expected = "ubuntu-latest"] of catalogPlacementCases) {
test(`catalog authorize placement before allocation: ${name}`, () => {
assert.equal(routingPlacement({}, { ...catalogContext, ...overrides }, catalogAuthorize), expected);
});
}
test("catalog placement retains downstream jobs and actual dispatch guard", () => {
assert.match(catalogWorkflow.split(" select:\n")[1].split("\n build:\n")[0], /^ {4}runs-on: ubuntu-latest$/m);
assert.match(catalogAuthorize, /ref: \$\{\{ github\.sha \}\}/);
assert.match(catalogAuthorize, /persist-credentials: false/);
assert.match(catalogAuthorize, /TESTOPS_ALLOWED_ACTOR_IDS: \$\{\{ vars\.RUNNER_E2E_ALLOWED_ACTOR_IDS \}\}/);
assert.match(catalogAuthorize, /run: node tests\/runner-e2e\/chat-completion-testops-cli\.mjs guard/);
assert.doesNotMatch(catalogAuthorize, /secrets\.|environment:/);
});
@@ -0,0 +1,976 @@
{
"source": "GET /repos/paperclipai/paperclip/actions/runs/42264785415/attempts/1 (+ jobs and annotations), trimmed",
"run": {
"id": 42264785415,
"name": "PR",
"head_branch": "fix/rc16-bootstrap-proof-after-connect",
"head_sha": "f418091616c6f7b0af1d4c3115fa47c500015a8a",
"path": ".github/workflows/pr.yml",
"run_number": 60214,
"event": "pull_request",
"status": "completed",
"conclusion": "failure",
"workflow_id": 249194268,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415",
"created_at": "2026-11-06T17:47:06Z",
"run_attempt": 1,
"run_started_at": "2026-11-06T17:47:06Z",
"actor": {
"login": "cryppadotta",
"id": 34892728,
"type": "User"
},
"triggering_actor": {
"login": "cryppadotta",
"id": 34892728,
"type": "User"
},
"repository": {
"id": 1170821064,
"full_name": "paperclipai/paperclip"
},
"head_repository": {
"id": 1170821064,
"full_name": "paperclipai/paperclip"
}
},
"jobs": [
{
"id": 128053744882,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128053744882",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:47:07Z",
"started_at": "2026-11-06T17:47:30Z",
"completed_at": "2026-11-06T17:48:06Z",
"name": "ci / gate / Select trusted runner",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128053744882",
"labels": [
"runs-on/fleet=paperclip-ci-routing-x64/env=public-ci"
],
"runner_name": "runs-on--i-0bd15c639391d3334--e87997a5-9",
"runner_group_name": "paperclip-ci-routing"
},
{
"id": 128054259560,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128054259560",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:48:07Z",
"started_at": "2026-11-06T17:48:32Z",
"completed_at": "2026-11-06T17:58:38Z",
"name": "ci / Typecheck + Release Registry",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128054259560",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0f9c9e372436e224a--ce576ff8-f",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128054259668,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128054259668",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:48:07Z",
"started_at": "2026-11-06T17:48:33Z",
"completed_at": "2026-11-06T17:50:19Z",
"name": "ci / policy",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128054259668",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-02004b0b62771f088--836450ae-0",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128054259676,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128054259676",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:48:07Z",
"started_at": "2026-11-06T17:48:30Z",
"completed_at": "2026-11-06T17:53:43Z",
"name": "ci / Verify Paperclip Runner (static checks)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128054259676",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0499d8944ad722cc8--d269ace8-1",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128054259678,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128054259678",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:48:07Z",
"started_at": "2026-11-06T17:48:34Z",
"completed_at": "2026-11-06T17:49:18Z",
"name": "ci / Docker context integrity",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128054259678",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0f415a1e7415eded5--1ead060a-1",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128054259685,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128054259685",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:48:07Z",
"started_at": "2026-11-06T17:48:33Z",
"completed_at": "2026-11-06T17:57:02Z",
"name": "ci / Build",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128054259685",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-04162f556ced67989--89ceb20a-2",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128054259696,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128054259696",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:48:07Z",
"started_at": "2026-11-06T17:48:32Z",
"completed_at": "2026-11-06T17:56:06Z",
"name": "ci / Canary Dry Run",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128054259696",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0e73a58cd878a2eba--ed01493c-d",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128054259836,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128054259836",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:48:07Z",
"started_at": "2026-11-06T17:48:33Z",
"completed_at": "2026-11-06T18:02:15Z",
"name": "ci / Verify Paperclip Runner (vitest package)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128054259836",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-03b78c874a221b14d--3f9580f1-c",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128054259837,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128054259837",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:48:07Z",
"started_at": "2026-11-06T17:48:34Z",
"completed_at": "2026-11-06T17:50:33Z",
"name": "ci / Select Vitest files",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128054259837",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0965b01d7ca22acc7--46a2c286-5",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128054259878,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128054259878",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:48:07Z",
"started_at": "2026-11-06T17:48:34Z",
"completed_at": "2026-11-06T17:55:01Z",
"name": "ci / Verify Paperclip Runner (vitest native server)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128054259878",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0a3c1c296733234fa--24120234-b",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128054260016,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128054260016",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:48:07Z",
"started_at": "2026-11-06T17:48:33Z",
"completed_at": "2026-11-06T17:55:03Z",
"name": "ci / e2e shard (2/8)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128054260016",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-05170a3c06ca46460--d09cbbe4-4",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128054260029,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128054260029",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:48:07Z",
"started_at": "2026-11-06T17:48:33Z",
"completed_at": "2026-11-06T17:56:53Z",
"name": "ci / e2e shard (3/8)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128054260029",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-03be127710fc5e3f3--1232d471-3",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128054260068,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128054260068",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:48:07Z",
"started_at": "2026-11-06T17:48:34Z",
"completed_at": "2026-11-06T17:57:28Z",
"name": "ci / e2e shard (4/8)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128054260068",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0e30b923f731a8617--7f3f286f-a",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128054260075,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128054260075",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:48:07Z",
"started_at": "2026-11-06T17:48:33Z",
"completed_at": "2026-11-06T17:55:25Z",
"name": "ci / e2e shard (6/8)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128054260075",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0afa242a4729d2f5e--1e1e7e9a-4",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128054260099,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128054260099",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:48:07Z",
"started_at": "2026-11-06T17:48:33Z",
"completed_at": "2026-11-06T17:54:46Z",
"name": "ci / e2e shard (8/8)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128054260099",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-08fc2533358d5e023--bc9c002a-0",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128054260141,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128054260141",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:48:07Z",
"started_at": "2026-11-06T17:48:34Z",
"completed_at": "2026-11-06T17:53:23Z",
"name": "ci / e2e shard (7/8)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128054260141",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0e1920f3a20679a6d--4eaf0fed-f",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128054260232,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128054260232",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:48:07Z",
"started_at": "2026-11-06T17:48:35Z",
"completed_at": "2026-11-06T17:54:31Z",
"name": "ci / e2e shard (5/8)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128054260232",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-01730060988e5694a--f4f43bb3-1",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128054260285,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128054260285",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:48:07Z",
"started_at": "2026-11-06T17:48:36Z",
"completed_at": "2026-11-06T17:53:55Z",
"name": "ci / e2e shard (1/8)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128054260285",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0398f5a4a13d3bb86--06283b1d-1",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128055537875,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128055537875",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:50:33Z",
"started_at": "2026-11-06T17:51:08Z",
"completed_at": "2026-11-06T17:54:45Z",
"name": "ci / Verify serialized server suites (6/9)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128055537875",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-03863cc26751ee81d--ed7003ac-e",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128055537897,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128055537897",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:50:33Z",
"started_at": "2026-11-06T17:51:09Z",
"completed_at": "2026-11-06T17:54:56Z",
"name": "ci / Verify serialized server suites (5/9)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128055537897",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-07c73636f0cb7025c--f79ac0af-8",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128055537952,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128055537952",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:50:33Z",
"started_at": "2026-11-06T17:50:57Z",
"completed_at": "2026-11-06T17:54:28Z",
"name": "ci / Verify serialized server suites (8/9)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128055537952",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-03b2eebb3d82945ba--deae2c56-b",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128055537956,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128055537956",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:50:33Z",
"started_at": "2026-11-06T17:51:11Z",
"completed_at": "2026-11-06T17:54:27Z",
"name": "ci / Verify serialized server suites (3/9)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128055537956",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0efadc3ad82b120d2--95e7dcf3-7",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128055537962,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128055537962",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:50:33Z",
"started_at": "2026-11-06T17:50:57Z",
"completed_at": "2026-11-06T17:57:23Z",
"name": "ci / Verify serialized server suites (2/9)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128055537962",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-07d521bfd6e73257a--19c15d8d-b",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128055538017,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128055538017",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:50:33Z",
"started_at": "2026-11-06T17:51:17Z",
"completed_at": "2026-11-06T17:54:58Z",
"name": "ci / Verify serialized server suites (7/9)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128055538017",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-01e662eefaa655648--55d2ece6-3",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128055538089,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128055538089",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:50:33Z",
"started_at": "2026-11-06T17:51:17Z",
"completed_at": "2026-11-06T17:56:27Z",
"name": "ci / Verify serialized server suites (1/9)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128055538089",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0ad89fde02e893c90--06ed32bb-a",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128055538125,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128055538125",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:50:33Z",
"started_at": "2026-11-06T17:51:17Z",
"completed_at": "2026-11-06T17:54:49Z",
"name": "ci / Verify serialized server suites (4/9)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128055538125",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-064cd806776d929f4--274b294f-f",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128055538149,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128055538149",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:50:33Z",
"started_at": "2026-11-06T17:51:09Z",
"completed_at": "2026-11-06T18:06:26Z",
"name": "ci / General tests (server (2/12))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128055538149",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0eea471a99f85b880--00c81037-b",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128055538165,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128055538165",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:50:33Z",
"started_at": "2026-11-06T17:51:08Z",
"completed_at": "2026-11-06T17:58:45Z",
"name": "ci / General tests (server (1/12))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128055538165",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-04800a2d79c810238--b9ce8e93-3",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128055538174,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128055538174",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:50:33Z",
"started_at": "2026-11-06T17:51:08Z",
"completed_at": "2026-11-06T17:59:43Z",
"name": "ci / General tests (server (6/12))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128055538174",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0efaf057cc5a98d48--edea4c47-2",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128055538185,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128055538185",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:50:33Z",
"started_at": "2026-11-06T17:51:17Z",
"completed_at": "2026-11-06T17:57:34Z",
"name": "ci / General tests (server (7/12))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128055538185",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0205811b68f4b3fd1--52920e1d-9",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128055538207,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128055538207",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:50:33Z",
"started_at": "2026-11-06T17:51:14Z",
"completed_at": "2026-11-06T18:00:06Z",
"name": "ci / General tests (server (5/12))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128055538207",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0a37eb2d9fde459d4--36952ebd-0",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128055538219,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128055538219",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:50:33Z",
"started_at": "2026-11-06T17:51:19Z",
"completed_at": "2026-11-06T17:59:30Z",
"name": "ci / General tests (server (3/12))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128055538219",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0188e96e97af2f59c--b8e855e1-4",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128055538250,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128055538250",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:50:33Z",
"started_at": "2026-11-06T17:51:13Z",
"completed_at": "2026-11-06T18:00:22Z",
"name": "ci / General tests (server (9/12))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128055538250",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-00c505418cc8ee70e--fb114f23-1",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128055538253,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128055538253",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:50:33Z",
"started_at": "2026-11-06T17:51:12Z",
"completed_at": "2026-11-06T17:56:10Z",
"name": "ci / General tests (workspaces-b)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128055538253",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-05fc778dbd976d597--c58725c9-c",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128055538265,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128055538265",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:50:33Z",
"started_at": "2026-11-06T17:51:17Z",
"completed_at": "2026-11-06T17:54:31Z",
"name": "ci / Verify serialized server suites (9/9)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128055538265",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-06a3bf8d19333dd51--c4258f53-2",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128055538302,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128055538302",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:50:33Z",
"started_at": "2026-11-06T17:51:17Z",
"completed_at": "2026-11-06T17:58:51Z",
"name": "ci / General tests (server (12/12))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128055538302",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-07605db9087df8358--8137b272-b",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128055538306,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128055538306",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:50:34Z",
"started_at": "2026-11-06T17:51:13Z",
"completed_at": "2026-11-06T17:59:36Z",
"name": "ci / General tests (server (11/12))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128055538306",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0d357b1cfb24c69f8--9cc138ac-0",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128055538334,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128055538334",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:50:34Z",
"started_at": "2026-11-06T17:51:14Z",
"completed_at": "2026-11-06T17:57:39Z",
"name": "ci / General tests (server (8/12))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128055538334",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-06bee8abdadd855f6--4777aea8-3",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128055538337,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128055538337",
"status": "completed",
"conclusion": "failure",
"created_at": "2026-11-06T17:50:34Z",
"started_at": "2026-11-06T17:51:17Z",
"completed_at": "2026-11-06T17:57:43Z",
"name": "ci / General tests (server (4/12))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128055538337",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-00861ea950b0c2df8--e7934d4c-7",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128055538364,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128055538364",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:50:34Z",
"started_at": "2026-11-06T17:51:16Z",
"completed_at": "2026-11-06T17:56:44Z",
"name": "ci / General tests (chat (2/3))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128055538364",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-04505acda283d309b--e4997141-b",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128055538368,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128055538368",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:50:34Z",
"started_at": "2026-11-06T17:51:23Z",
"completed_at": "2026-11-06T17:57:14Z",
"name": "ci / General tests (workspaces-a (1/2))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128055538368",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-01559d90b1c03dafc--39658d89-4",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128055538374,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128055538374",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:50:34Z",
"started_at": "2026-11-06T17:51:20Z",
"completed_at": "2026-11-06T17:57:56Z",
"name": "ci / General tests (server (10/12))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128055538374",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-05115335e959346eb--0b11d7ba-b",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128055538399,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128055538399",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:50:34Z",
"started_at": "2026-11-06T17:51:16Z",
"completed_at": "2026-11-06T17:57:35Z",
"name": "ci / General tests (chat (1/3))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128055538399",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-020c27d3a16ef9c2b--ea30bd71-2",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128055538415,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128055538415",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:50:34Z",
"started_at": "2026-11-06T17:51:18Z",
"completed_at": "2026-11-06T17:55:40Z",
"name": "ci / General tests (workspaces-a (2/2))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128055538415",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0c674a4b6f26936cf--ec849767-9",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128055538546,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128055538546",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:50:34Z",
"started_at": "2026-11-06T17:51:19Z",
"completed_at": "2026-11-06T17:59:30Z",
"name": "ci / General tests (chat (3/3))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128055538546",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-05c610ca30a34300b--725c544b-c",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128059102519,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128059102519",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T17:57:28Z",
"started_at": "2026-11-06T17:58:02Z",
"completed_at": "2026-11-06T17:58:07Z",
"name": "ci / e2e",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128059102519",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-02635cc9003444823--4f0acae5-e",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128063690619,
"run_id": 42264785415,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42264785415/job/128063690619",
"status": "completed",
"conclusion": "failure",
"created_at": "2026-11-06T18:06:26Z",
"started_at": "2026-11-06T18:07:00Z",
"completed_at": "2026-11-06T18:07:08Z",
"name": "ci / verify",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128063690619",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-007fb864dc5bb537d--f46a91cb-e",
"runner_group_name": "paperclip-public-pr"
}
],
"annotations": {
"128055538337": [
{
"path": ".github",
"start_line": 285371,
"end_line": 285371,
"annotation_level": "failure",
"title": "",
"message": "Process completed with exit code 1.",
"raw_details": ""
},
{
"path": "server/src/__tests__/connection-management.integration.test.ts",
"start_line": 1806,
"end_line": 1806,
"annotation_level": "failure",
"title": "[@paperclipai/server] src/__tests__/connection-management.integration.test.ts > connection management with production default agents > reads only the authorized Anthropic account identity through actual agent authentication",
"message": "AssertionError: expected 3 to be 2 // Object.is equality\n\n- Expected\n+ Received\n\n- 2\n+ 3\n\n ❯ src/__tests__/connection-management.integration.test.ts:1806:21\n\n",
"raw_details": ""
},
{
"path": ".github",
"start_line": 162423,
"end_line": 162423,
"annotation_level": "failure",
"title": "",
"message": "command (/home/runner/_work/paperclip/paperclip/) /home/runner/setup-pnpm/node_modules/.bin/bin/pnpm run test:run:general -- --group general-server-without-chat --shard-index 3 --shard-count 12 exited (1)",
"raw_details": ""
}
],
"128063690619": [
{
"path": ".github",
"start_line": 120,
"end_line": 120,
"annotation_level": "failure",
"title": "",
"message": "Process completed with exit code 1.",
"raw_details": ""
}
]
},
"logs": {},
"total_count": 47
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,979 @@
{
"source": "GET /repos/paperclipai/paperclip/actions/runs/42277969418/attempts/1 (+ jobs and annotations), trimmed",
"run": {
"id": 42277969418,
"name": "PR",
"head_branch": "fix/status-card-compile-inline-guidance",
"head_sha": "85a1ebd70ebb687bac36cd7d3aa035f08b1b76fe",
"path": ".github/workflows/pr.yml",
"run_number": 60340,
"event": "pull_request",
"status": "completed",
"conclusion": "failure",
"workflow_id": 249194268,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418",
"created_at": "2026-11-06T19:15:10Z",
"run_attempt": 1,
"run_started_at": "2026-11-06T19:15:10Z",
"actor": {
"login": "cryppadotta",
"id": 34892728,
"type": "User"
},
"triggering_actor": {
"login": "cryppadotta",
"id": 34892728,
"type": "User"
},
"repository": {
"id": 1170821064,
"full_name": "paperclipai/paperclip"
},
"head_repository": {
"id": 1170821064,
"full_name": "paperclipai/paperclip"
}
},
"jobs": [
{
"id": 128099176127,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128099176127",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:15:11Z",
"started_at": "2026-11-06T19:15:35Z",
"completed_at": "2026-11-06T19:15:51Z",
"name": "ci / gate / Select trusted runner",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128099176127",
"labels": [
"runs-on/fleet=paperclip-ci-routing-x64/env=public-ci"
],
"runner_name": "runs-on--i-05f6b9b2930a13c18--04e93093-7",
"runner_group_name": "paperclip-ci-routing"
},
{
"id": 128099518899,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128099518899",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:15:51Z",
"started_at": "2026-11-06T19:16:14Z",
"completed_at": "2026-11-06T19:18:48Z",
"name": "ci / Select Vitest files",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128099518899",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-022ce6a3fe4cb976a--9b74ec42-3",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128099518966,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128099518966",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:15:51Z",
"started_at": "2026-11-06T19:16:13Z",
"completed_at": "2026-11-06T19:17:52Z",
"name": "ci / policy",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128099518966",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-06dd55e2393795fc3--8bdf1065-e",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128099518969,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128099518969",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:15:51Z",
"started_at": "2026-11-06T19:16:14Z",
"completed_at": "2026-11-06T19:30:17Z",
"name": "ci / Verify Paperclip Runner (vitest package)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128099518969",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-09eb0ccea8ce6e3f1--bf3c948a-b",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128099518970,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128099518970",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:15:51Z",
"started_at": "2026-11-06T19:16:14Z",
"completed_at": "2026-11-06T19:26:13Z",
"name": "ci / Typecheck + Release Registry",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128099518970",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-06107c2f4ad4e0afa--e8a52bad-e",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128099519017,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128099519017",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:15:51Z",
"started_at": "2026-11-06T19:16:14Z",
"completed_at": "2026-11-06T19:21:53Z",
"name": "ci / Verify Paperclip Runner (static checks)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128099519017",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0ce2155a81766d11b--1c931e39-3",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128099519063,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128099519063",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:15:51Z",
"started_at": "2026-11-06T19:16:16Z",
"completed_at": "2026-11-06T19:17:03Z",
"name": "ci / Docker context integrity",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128099519063",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0e251a79a0c87658b--54fd3399-0",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128099519192,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128099519192",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:15:51Z",
"started_at": "2026-11-06T19:16:14Z",
"completed_at": "2026-11-06T19:24:53Z",
"name": "ci / Build",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128099519192",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-005e12d124d262094--566fec4c-1",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128099519255,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128099519255",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:15:51Z",
"started_at": "2026-11-06T19:16:14Z",
"completed_at": "2026-11-06T19:23:21Z",
"name": "ci / e2e shard (2/8)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128099519255",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0c236e72756ee5767--da386f7f-1",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128099519269,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128099519269",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:15:51Z",
"started_at": "2026-11-06T19:16:15Z",
"completed_at": "2026-11-06T19:26:36Z",
"name": "ci / e2e shard (6/8)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128099519269",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0460327d9a9ad85b9--4e8bc75b-2",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128099519294,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128099519294",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:15:51Z",
"started_at": "2026-11-06T19:16:22Z",
"completed_at": "2026-11-06T19:25:00Z",
"name": "ci / e2e shard (3/8)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128099519294",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0ff835844ee842210--66852b42-8",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128099519310,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128099519310",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:15:51Z",
"started_at": "2026-11-06T19:16:14Z",
"completed_at": "2026-11-06T19:22:48Z",
"name": "ci / e2e shard (4/8)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128099519310",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0b5b047d9eb22c701--5e90d2e6-0",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128099519324,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128099519324",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:15:51Z",
"started_at": "2026-11-06T19:16:18Z",
"completed_at": "2026-11-06T19:22:50Z",
"name": "ci / Verify Paperclip Runner (vitest native server)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128099519324",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0f33e5da352722dec--88ad2175-7",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128099519328,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128099519328",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:15:51Z",
"started_at": "2026-11-06T19:16:15Z",
"completed_at": "2026-11-06T19:23:26Z",
"name": "ci / e2e shard (5/8)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128099519328",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0b4a144abcf5b5ad0--13d99f06-4",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128099519373,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128099519373",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:15:51Z",
"started_at": "2026-11-06T19:16:19Z",
"completed_at": "2026-11-06T19:23:19Z",
"name": "ci / Canary Dry Run",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128099519373",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-026bbfb8e1f2b02f6--fcd31fd2-9",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128099519399,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128099519399",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:15:51Z",
"started_at": "2026-11-06T19:16:17Z",
"completed_at": "2026-11-06T19:22:50Z",
"name": "ci / e2e shard (8/8)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128099519399",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-06fecb3fefefd2b1e--48a7f824-3",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128099519420,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128099519420",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:15:51Z",
"started_at": "2026-11-06T19:16:19Z",
"completed_at": "2026-11-06T19:22:36Z",
"name": "ci / e2e shard (1/8)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128099519420",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0b1eec8b49f261f40--0ee7ea96-8",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128099519640,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128099519640",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:15:51Z",
"started_at": "2026-11-06T19:16:19Z",
"completed_at": "2026-11-06T19:22:41Z",
"name": "ci / e2e shard (7/8)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128099519640",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0c06a9265636bb3c1--29213d2a-c",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128099521660,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128099521660",
"status": "completed",
"conclusion": "skipped",
"created_at": "2026-11-06T19:15:51Z",
"started_at": "2026-11-06T19:15:51Z",
"completed_at": "2026-11-06T19:15:51Z",
"name": "ci / Sandbox provider tests",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128099521660",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": null,
"runner_group_name": null
},
{
"id": 128101015827,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128101015827",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:18:48Z",
"started_at": "2026-11-06T19:19:36Z",
"completed_at": "2026-11-06T19:23:28Z",
"name": "ci / Verify serialized server suites (3/9)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128101015827",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0f29d5a87a13a3137--0ae30aa5-9",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128101015873,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128101015873",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:18:48Z",
"started_at": "2026-11-06T19:19:37Z",
"completed_at": "2026-11-06T19:25:47Z",
"name": "ci / Verify serialized server suites (1/9)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128101015873",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0a92a42f0bc1f9c02--30c81b0a-1",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128101015886,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128101015886",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:18:48Z",
"started_at": "2026-11-06T19:19:57Z",
"completed_at": "2026-11-06T19:24:10Z",
"name": "ci / Verify serialized server suites (4/9)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128101015886",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-050c1bc7159c094e4--1d0ecbb0-5",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128101015895,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128101015895",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:18:48Z",
"started_at": "2026-11-06T19:19:39Z",
"completed_at": "2026-11-06T19:24:10Z",
"name": "ci / Verify serialized server suites (6/9)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128101015895",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0473b976117b1a23d--148e3608-1",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128101015916,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128101015916",
"status": "completed",
"conclusion": "cancelled",
"created_at": "2026-11-06T19:18:48Z",
"started_at": "2026-11-06T19:19:43Z",
"completed_at": "2026-11-06T19:24:43Z",
"name": "ci / Verify serialized server suites (2/9)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128101015916",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-06aa99d6dfd3eefa2--6d5659d3-7",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128101015954,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128101015954",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:18:48Z",
"started_at": "2026-11-06T19:19:41Z",
"completed_at": "2026-11-06T19:24:11Z",
"name": "ci / Verify serialized server suites (8/9)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128101015954",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-02e59035275e65dc1--f14d4574-a",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128101015978,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128101015978",
"status": "completed",
"conclusion": "cancelled",
"created_at": "2026-11-06T19:18:48Z",
"started_at": "2026-11-06T19:19:43Z",
"completed_at": "2026-11-06T19:24:43Z",
"name": "ci / Verify serialized server suites (5/9)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128101015978",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0fc8f7dbd4ebf8e25--306b0039-6",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128101016102,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128101016102",
"status": "completed",
"conclusion": "failure",
"created_at": "2026-11-06T19:18:48Z",
"started_at": "2026-11-06T19:19:43Z",
"completed_at": "2026-11-06T19:24:31Z",
"name": "ci / Verify serialized server suites (7/9)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128101016102",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0f30ba7b9604359f6--7247346a-7",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128101016153,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128101016153",
"status": "completed",
"conclusion": "cancelled",
"created_at": "2026-11-06T19:18:48Z",
"started_at": "2026-11-06T19:19:41Z",
"completed_at": "2026-11-06T19:24:43Z",
"name": "ci / General tests (server (1/12))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128101016153",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0a9149d343f722f73--6c14189b-2",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128101016167,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128101016167",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:18:48Z",
"started_at": "2026-11-06T19:19:36Z",
"completed_at": "2026-11-06T19:27:21Z",
"name": "ci / General tests (server (5/12))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128101016167",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0c26f035abc2181de--db86c3ca-7",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128101016193,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128101016193",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:18:48Z",
"started_at": "2026-11-06T19:19:37Z",
"completed_at": "2026-11-06T19:27:48Z",
"name": "ci / General tests (chat (1/3))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128101016193",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-09edcc5cc037cb21b--a7821027-c",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128101016200,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128101016200",
"status": "completed",
"conclusion": "cancelled",
"created_at": "2026-11-06T19:18:48Z",
"started_at": "2026-11-06T19:19:37Z",
"completed_at": "2026-11-06T19:24:42Z",
"name": "ci / General tests (server (8/12))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128101016200",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0d85343085d2a7e66--3919a1b5-e",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128101016203,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128101016203",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:18:48Z",
"started_at": "2026-11-06T19:19:41Z",
"completed_at": "2026-11-06T19:24:01Z",
"name": "ci / Verify serialized server suites (9/9)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128101016203",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0f2a0ad6f8136ed03--3f5b22b1-b",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128101016204,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128101016204",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:18:48Z",
"started_at": "2026-11-06T19:19:38Z",
"completed_at": "2026-11-06T19:25:44Z",
"name": "ci / General tests (workspaces-b)",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128101016204",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-022720c628aea9909--3489c3bc-e",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128101016209,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128101016209",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:18:48Z",
"started_at": "2026-11-06T19:19:37Z",
"completed_at": "2026-11-06T19:27:03Z",
"name": "ci / General tests (server (3/12))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128101016209",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-05cd168e2c805dfa1--2db2f96e-9",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128101016247,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128101016247",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:18:48Z",
"started_at": "2026-11-06T19:19:37Z",
"completed_at": "2026-11-06T19:24:39Z",
"name": "ci / General tests (workspaces-a (2/2))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128101016247",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0a76b068c81f7024a--8a17bf0c-7",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128101016250,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128101016250",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:18:48Z",
"started_at": "2026-11-06T19:19:39Z",
"completed_at": "2026-11-06T19:26:41Z",
"name": "ci / General tests (server (12/12))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128101016250",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-05867958d44053ee3--8a8e3069-9",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128101016265,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128101016265",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:18:48Z",
"started_at": "2026-11-06T19:19:39Z",
"completed_at": "2026-11-06T19:26:58Z",
"name": "ci / General tests (server (4/12))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128101016265",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0bf649f272db17bac--41ee1099-5",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128101016277,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128101016277",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:18:48Z",
"started_at": "2026-11-06T19:19:39Z",
"completed_at": "2026-11-06T19:34:40Z",
"name": "ci / General tests (server (2/12))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128101016277",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-057aa4559787e702b--99da1fc5-1",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128101016291,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128101016291",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:18:48Z",
"started_at": "2026-11-06T19:19:39Z",
"completed_at": "2026-11-06T19:27:31Z",
"name": "ci / General tests (server (6/12))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128101016291",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0204b721d120e8305--4a8cbdcf-5",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128101016304,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128101016304",
"status": "completed",
"conclusion": "cancelled",
"created_at": "2026-11-06T19:18:48Z",
"started_at": "2026-11-06T19:19:42Z",
"completed_at": "2026-11-06T19:24:43Z",
"name": "ci / General tests (workspaces-a (1/2))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128101016304",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-083ececdc0bee80c6--049cc912-b",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128101016340,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128101016340",
"status": "completed",
"conclusion": "cancelled",
"created_at": "2026-11-06T19:18:48Z",
"started_at": "2026-11-06T19:19:41Z",
"completed_at": "2026-11-06T19:24:43Z",
"name": "ci / General tests (server (7/12))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128101016340",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0a46ba5536afd6715--10ddddf7-c",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128101016380,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128101016380",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:18:48Z",
"started_at": "2026-11-06T19:19:39Z",
"completed_at": "2026-11-06T19:27:21Z",
"name": "ci / General tests (server (11/12))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128101016380",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0f1df9c61064c6c00--bfe5ea08-c",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128101016402,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128101016402",
"status": "completed",
"conclusion": "cancelled",
"created_at": "2026-11-06T19:18:48Z",
"started_at": "2026-11-06T19:19:43Z",
"completed_at": "2026-11-06T19:24:42Z",
"name": "ci / General tests (server (9/12))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128101016402",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0cd6219458ec3772c--7a914eac-5",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128101016432,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128101016432",
"status": "completed",
"conclusion": "cancelled",
"created_at": "2026-11-06T19:18:48Z",
"started_at": "2026-11-06T19:19:39Z",
"completed_at": "2026-11-06T19:24:43Z",
"name": "ci / General tests (chat (3/3))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128101016432",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0fdc6b32e8998c1ba--0724a4a5-2",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128101016453,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128101016453",
"status": "completed",
"conclusion": "cancelled",
"created_at": "2026-11-06T19:18:48Z",
"started_at": "2026-11-06T19:19:44Z",
"completed_at": "2026-11-06T19:24:43Z",
"name": "ci / General tests (server (10/12))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128101016453",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-05195ce82810fc9ab--39894cef-6",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128101016461,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128101016461",
"status": "completed",
"conclusion": "cancelled",
"created_at": "2026-11-06T19:18:48Z",
"started_at": "2026-11-06T19:19:43Z",
"completed_at": "2026-11-06T19:24:42Z",
"name": "ci / General tests (chat (2/3))",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128101016461",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-0722bda62fe122e78--24305ada-1",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128104989524,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128104989524",
"status": "completed",
"conclusion": "success",
"created_at": "2026-11-06T19:26:36Z",
"started_at": "2026-11-06T19:27:42Z",
"completed_at": "2026-11-06T19:27:47Z",
"name": "ci / e2e",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128104989524",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-03d74c0aaf2a6f9a8--5077b150-0",
"runner_group_name": "paperclip-public-pr"
},
{
"id": 128109082787,
"run_id": 42277969418,
"workflow_name": "PR",
"run_attempt": 1,
"html_url": "https://github.com/paperclipai/paperclip/actions/runs/42277969418/job/128109082787",
"status": "completed",
"conclusion": "failure",
"created_at": "2026-11-06T19:34:41Z",
"started_at": "2026-11-06T19:35:37Z",
"completed_at": "2026-11-06T19:35:43Z",
"name": "ci / verify",
"check_run_url": "https://api.github.com/repos/paperclipai/paperclip/check-runs/128109082787",
"labels": [
"runs-on/fleet=paperclip-public-pr-x64/env=public-ci"
],
"runner_name": "runs-on--i-05ba3f428ff571fab--c14c92a3-1",
"runner_group_name": "paperclip-public-pr"
}
],
"annotations": {
"128101016102": [
{
"path": ".github",
"start_line": 2,
"end_line": 2,
"annotation_level": "failure",
"title": "",
"message": "The operation was canceled.",
"raw_details": ""
}
],
"128109082787": [
{
"path": ".github",
"start_line": 124,
"end_line": 124,
"annotation_level": "failure",
"title": "",
"message": "Process completed with exit code 1.",
"raw_details": ""
}
]
},
"logs": {
"128101016102": "2026-11-06T19:24:29.1450769Z ##[error]The runner has received a shutdown signal. This can happen when the runner service is stopped, or a manually started runner is canceled.\n2026-11-06T19:24:29.1898916Z ##[error]The operation was canceled.\n2026-11-06T19:24:29.1977091Z Cleaning up orphan processes\n"
},
"total_count": 48
}
@@ -1,6 +1,10 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { execFileSync, spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { LOCKFILES } from "../lockfile-trust.mjs";
const workflow = readFileSync(new URL("../../workflows/refresh-lockfile.yml", import.meta.url), "utf8");
const stepNames = [...workflow.matchAll(/^ - name: (.+)$/gm)].map((match) => match[1]);
@@ -43,17 +47,18 @@ test("a held refresh keeps its versions through the next master run's artifact",
assert.match(reuse, /gh run download "\$run_id" --name held-pnpm-lock/);
assert.match(
reuse,
/if \[ "\$\(sha256sum < pnpm-lock\.yaml\)" != "\$\(cat "\$held\/source-lock\.sha256"\)" \]; then[\s\S]*?exit 0\n fi\n cp "\$held\/pnpm-lock\.yaml" pnpm-lock\.yaml/,
"a held lock must only be reused while master still has the lock it was resolved from",
/if ! sha256sum --check --status "\$held\/source-locks\.sha256"; then[\s\S]*?exit 0\n fi\n for lock in \$LOCKFILES; do\n cp "\$held\/\$lock" "\$lock"/,
"held locks must only be reused while master still has every lock they were resolved from",
);
assert.match(reuse, /if \[ ! -f "\$held\/source-locks\.sha256" \]; then[\s\S]*?exit 0/, "an older held artifact must not be reused");
assert.doesNotMatch(reuse, /chore\/refresh-lockfile/, "the PR branch is writable by others and must not seed resolution");
for (const name of ["Stage held lockfile", "Save held lockfile for the next refresh"]) {
assert.equal(condition(name), "steps.release-age.outputs.eligible == 'false'");
}
const stage = step("Stage held lockfile");
assert.match(stage, /cp pnpm-lock\.yaml "\$RUNNER_TEMP\/held-upload\/pnpm-lock\.yaml"/);
assert.match(stage, /sha256sum < "\$RUNNER_TEMP\/base-pnpm-lock\.yaml" > "\$RUNNER_TEMP\/held-upload\/source-lock\.sha256"/);
assert.match(stage, /for lock in \$LOCKFILES; do[\s\S]*?cp "\$lock" "\$held\/\$lock"/);
assert.match(stage, /\(cd "\$RUNNER_TEMP\/base-locks" && sha256sum \$LOCKFILES\) > "\$held\/source-locks\.sha256"/);
const save = step("Save held lockfile for the next refresh");
assert.match(save, /name: held-pnpm-lock/);
assert.match(save, /path: \$\{\{ runner\.temp \}\}\/held-upload\//);
@@ -66,3 +71,100 @@ test("the bot merges only the commit this job pushed", () => {
assert.match(merge, /GH_TOKEN: \$\{\{ steps\.bot-token\.outputs\.token \}\}/);
assert.match(merge, /gh pr merge "\$PR_URL" .*--match-head-commit "\$HEAD_SHA"/);
});
test("a refresh that renews only the reporting-lock proof still opens a pull request", () => {
const upsert = step("Create or update pull request");
const check = upsert.match(/^ if (git diff --quiet -- [^;]+); then$/m)?.[1];
assert.ok(check, "the PR step must check for changes with git diff --quiet");
const added = upsert.match(/^ git add (.+)$/m)?.[1].split(" ");
assert.deepEqual(check.replace("git diff --quiet -- ", "").split(" "), added, "the change check must cover every file the PR step commits");
const root = mkdtempSync(path.join(tmpdir(), "refresh-proof-only-"));
try {
const git = (...args) => execFileSync("git", args, { cwd: root });
execFileSync("mkdir", ["-p", path.join(root, "tests/runner-e2e")]);
const files = { "pnpm-lock.yaml": "lock\n", "tests/runner-e2e/report-lock.json": "{}\n", "tests/runner-e2e/report-lock.patch": "old\n" };
for (const [file, contents] of Object.entries(files)) writeFileSync(path.join(root, file), contents);
git("init", "-q");
git("add", ".");
git("-c", "user.name=Fixture", "-c", "[email protected]", "commit", "-qm", "fixture");
const unchanged = () => spawnSync("bash", ["-c", check], { cwd: root }).status === 0;
assert.equal(unchanged(), true);
writeFileSync(path.join(root, "tests/runner-e2e/report-lock.json"), '{"outputSha256":"new"}\n');
writeFileSync(path.join(root, "tests/runner-e2e/report-lock.patch"), "");
assert.equal(unchanged(), false, "a proof-only change must not exit as unchanged");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("the workflow owns the same lockfiles that the trust policy guards", () => {
const env = workflow.match(/^ LOCKFILES: (.+)$/m)?.[1];
assert.ok(env, "the refresh job must define LOCKFILES");
assert.deepEqual(env.trim().split(/\s+/), LOCKFILES);
assert.match(step("Refresh sandbox provider lockfiles"),
/pnpm --dir scripts\/typecheck-sandbox-providers install --resolution-only --ignore-scripts --no-frozen-lockfile/);
assert.match(step("Refresh sandbox provider lockfiles"),
/pnpm --dir packages\/plugins\/sandbox-providers\/daytona install --ignore-workspace --resolution-only --ignore-scripts --no-frozen-lockfile/);
assert.ok(stepNames.indexOf("Refresh sandbox provider lockfiles") < stepNames.indexOf("Fail on unexpected file changes"));
assert.match(step("Check release age of added package versions"), /for lock in \$LOCKFILES; do[\s\S]*?pairs\+=\(--base "\$base\/\$lock" --head "\$lock"\)/);
});
// Run a workflow step's script with bash, as GitHub would.
const runStep = (name, cwd, env) => {
const lines = step(name).split("\n");
const start = lines.findIndex((line) => line === " run: |") + 1;
const script = lines.slice(start).map((line) => line.slice(10)).join("\n");
return spawnSync("bash", ["-e", "-c", script], { cwd, encoding: "utf8", env: { ...process.env, LOCKFILES: LOCKFILES.join(" "), ...env } });
};
test("unexpected-change check admits every managed lockfile and nothing else", () => {
const root = mkdtempSync(path.join(tmpdir(), "refresh-unexpected-"));
try {
const git = (...args) => execFileSync("git", args, { cwd: root });
for (const file of [...LOCKFILES, "notes.md"]) {
mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
writeFileSync(path.join(root, file), "old\n");
}
git("init", "-q");
git("add", ".");
git("-c", "user.name=Fixture", "-c", "[email protected]", "commit", "-qm", "fixture");
for (const file of LOCKFILES) writeFileSync(path.join(root, file), "new\n");
const allowed = runStep("Fail on unexpected file changes", root, {});
assert.equal(allowed.status, 0, allowed.stdout + allowed.stderr);
writeFileSync(path.join(root, "notes.md"), "new\n");
const blocked = runStep("Fail on unexpected file changes", root, {});
assert.equal(blocked.status, 1, blocked.stdout + blocked.stderr);
assert.match(blocked.stdout, /notes\.md/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("a held refresh round-trips every managed lockfile", () => {
const root = mkdtempSync(path.join(tmpdir(), "refresh-held-"));
const temp = mkdtempSync(path.join(tmpdir(), "refresh-held-temp-"));
try {
for (const file of LOCKFILES) {
for (const dir of [root, path.join(temp, "base-locks")]) {
mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
writeFileSync(path.join(dir, file), `master ${file}\n`);
}
writeFileSync(path.join(root, file), `held ${file}\n`);
}
const staged = runStep("Stage held lockfile", root, { RUNNER_TEMP: temp });
assert.equal(staged.status, 0, staged.stderr);
const held = path.join(temp, "held-upload");
for (const file of LOCKFILES) assert.equal(readFileSync(path.join(held, file), "utf8"), `held ${file}\n`);
// The next run checks the hashes against master's lockfiles, then copies the held ones in.
for (const file of LOCKFILES) writeFileSync(path.join(root, file), `master ${file}\n`);
const check = `sha256sum --check --status "${held}/source-locks.sha256"`;
assert.equal(spawnSync("bash", ["-c", check], { cwd: root }).status, 0, "unchanged master locks must match the held source hashes");
writeFileSync(path.join(root, LOCKFILES[1]), "changed on master\n");
assert.notEqual(spawnSync("bash", ["-c", check], { cwd: root }).status, 0, "a changed provider lock must invalidate the held refresh");
} finally {
rmSync(root, { recursive: true, force: true });
rmSync(temp, { recursive: true, force: true });
}
});
@@ -0,0 +1,376 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import {
classifyPullRequest,
findDependabotLockCommit,
findMergedDependabotPull,
isDependabotCommit,
isGitHubSignedCommit,
LOCKFILES,
PROVIDER_LOCKFILES,
} from '../lockfile-trust.mjs';
const repo = 'paperclipai/paperclip';
const base = { author: 'someuser', headRepo: repo, baseRepo: repo, headRef: 'fix/bug', dependabotLockCommits: {} };
test('allows a pull request that does not change lock content', () => {
assert.deepEqual(
{ ...classifyPullRequest({ ...base, changedPaths: ['server/src/index.ts'] }), reason: undefined },
{ allowed: true, writer: 'none', reason: undefined },
);
});
test('blocks a contributor lockfile change', () => {
const result = classifyPullRequest({ ...base, changedPaths: ['pnpm-lock.yaml'] });
assert.equal(result.allowed, false);
assert.match(result.reason, /pnpm-lock\.yaml/);
});
test('blocks a contributor change to the reporting-lock proof', () => {
const result = classifyPullRequest({ ...base, changedPaths: ['tests/runner-e2e/report-lock.patch'] });
assert.equal(result.allowed, false);
assert.match(result.reason, /report-lock\.patch/);
});
test('blocks a contributor branch named like the refresh branch', () => {
for (const headRepo of [repo, 'someone/paperclip']) {
const result = classifyPullRequest({ ...base, headRepo, headRef: 'chore/refresh-lockfile', changedPaths: ['pnpm-lock.yaml'] });
assert.equal(result.allowed, false, headRepo);
}
});
test('blocks the refresh author and branch from a fork', () => {
const result = classifyPullRequest({ ...base, author: 'github-actions[bot]', headRepo: 'someone/paperclip',
headRef: 'chore/refresh-lockfile', changedPaths: ['pnpm-lock.yaml'] });
assert.equal(result.allowed, false);
});
test('allows the refresh workflow to change the lockfile and its proof', () => {
const result = classifyPullRequest({ ...base, author: 'github-actions[bot]', headRef: 'chore/refresh-lockfile',
changedPaths: ['pnpm-lock.yaml', 'tests/runner-e2e/report-lock.json', 'tests/runner-e2e/report-lock.patch'] });
assert.equal(result.allowed, true);
assert.equal(result.writer, 'refresh-bot');
});
test('allows a Dependabot lockfile that matches a verified Dependabot commit', () => {
const result = classifyPullRequest({ ...base, author: 'dependabot[bot]', headRef: 'dependabot/npm_and_yarn/zod-4.6.6',
changedPaths: ['package.json', 'pnpm-lock.yaml'], dependabotLockCommits: { 'pnpm-lock.yaml': 'abc123' } });
assert.equal(result.allowed, true);
assert.equal(result.writer, 'dependabot');
});
test('blocks a Dependabot lockfile that no verified Dependabot commit produced', () => {
const result = classifyPullRequest({ ...base, author: 'dependabot[bot]', changedPaths: ['pnpm-lock.yaml'] });
assert.equal(result.allowed, false);
assert.match(result.reason, /@dependabot recreate/);
});
test('blocks a Dependabot pull request that changes the reporting-lock proof', () => {
const result = classifyPullRequest({ ...base, author: 'dependabot[bot]', dependabotLockCommits: { 'pnpm-lock.yaml': 'abc123' },
changedPaths: ['pnpm-lock.yaml', 'tests/runner-e2e/report-lock.json'] });
assert.equal(result.allowed, false);
});
test('blocks a Dependabot author from a fork', () => {
const result = classifyPullRequest({ ...base, author: 'dependabot[bot]', headRepo: 'someone/paperclip',
dependabotLockCommits: { 'pnpm-lock.yaml': 'abc123' }, changedPaths: ['pnpm-lock.yaml'] });
assert.equal(result.allowed, false);
});
test('guards the sandbox provider lockfiles as well as the root lockfile', () => {
assert.deepEqual(LOCKFILES, ['pnpm-lock.yaml', ...PROVIDER_LOCKFILES]);
for (const file of PROVIDER_LOCKFILES) {
const result = classifyPullRequest({ ...base, changedPaths: [file] });
assert.equal(result.allowed, false, file);
assert.ok(result.reason.includes(file), result.reason);
}
});
test('allows the refresh workflow to change the provider lockfiles', () => {
const result = classifyPullRequest({ ...base, author: 'github-actions[bot]', headRef: 'chore/refresh-lockfile',
changedPaths: LOCKFILES });
assert.equal(result.allowed, true);
assert.equal(result.writer, 'refresh-bot');
});
test('allows a Dependabot provider lockfile that matches a verified Dependabot commit', () => {
const [file] = PROVIDER_LOCKFILES;
const result = classifyPullRequest({ ...base, author: 'dependabot[bot]',
changedPaths: ['packages/plugins/sandbox-providers/e2b/package.json', file], dependabotLockCommits: { [file]: 'abc123' } });
assert.equal(result.allowed, true);
assert.equal(result.writer, 'dependabot');
});
test('blocks a Dependabot pull request when any changed lockfile is unverified', () => {
const [file] = PROVIDER_LOCKFILES;
const result = classifyPullRequest({ ...base, author: 'dependabot[bot]',
changedPaths: ['pnpm-lock.yaml', file], dependabotLockCommits: { 'pnpm-lock.yaml': 'abc123', [file]: null } });
assert.equal(result.allowed, false);
assert.ok(result.reason.startsWith(`${file} does not match`), result.reason);
});
test('finds only verified Dependabot commits that hold the exact lockfile', () => {
const blobs = { a: 'lock-1', b: 'lock-2', c: 'lock-2', d: 'lock-2' };
const lockBlobAt = (sha) => blobs[sha] ?? null;
const commits = [
{ sha: 'a', login: 'dependabot[bot]', committer: 'web-flow', verified: true },
{ sha: 'b', login: 'dependabot[bot]', committer: 'web-flow', verified: false },
{ sha: 'c', login: 'someuser', committer: 'web-flow', verified: true },
];
assert.equal(findDependabotLockCommit({ lockBlob: 'lock-2', commits, lockBlobAt }), null);
assert.equal(findDependabotLockCommit({ lockBlob: 'lock-1', commits, lockBlobAt }), 'a');
assert.equal(findDependabotLockCommit({ lockBlob: 'lock-2', lockBlobAt,
commits: [...commits, { sha: 'd', login: 'dependabot[bot]', committer: 'web-flow', verified: true }] }), 'd');
assert.equal(findDependabotLockCommit({ lockBlob: null, commits, lockBlobAt }), null);
});
test('requires a Dependabot author with GitHub as the verified committer', () => {
assert.equal(isDependabotCommit({ login: 'dependabot[bot]', committer: 'web-flow', verified: true }), true);
// A collaborator can sign with their own key and set Dependabot as the author.
assert.equal(isDependabotCommit({ login: 'dependabot[bot]', committer: 'someuser', verified: true }), false);
assert.equal(isDependabotCommit({ login: 'dependabot[bot]', committer: null, verified: true }), false);
assert.equal(isDependabotCommit({ login: 'dependabot[bot]', committer: 'web-flow', verified: false }), false);
assert.equal(isDependabotCommit({ login: 'someuser', committer: 'web-flow', verified: true }), false);
});
test('ignores a lockfile commit with a spoofed Dependabot author', () => {
const lockBlobAt = () => 'lock-1';
const spoofed = [{ sha: 'x', login: 'dependabot[bot]', committer: 'someuser', verified: true }];
assert.equal(findDependabotLockCommit({ lockBlob: 'lock-1', commits: spoofed, lockBlobAt }), null);
});
test('finds the merged Dependabot pull request for a master commit', () => {
const pull = (overrides) => ({ number: 1, user: { login: 'dependabot[bot]' }, merged_at: '2026-11-05T00:00:00Z',
merge_commit_sha: 'm', base: { ref: 'master' }, ...overrides });
assert.equal(findMergedDependabotPull([pull({})], 'm', 'master')?.number, 1);
assert.equal(findMergedDependabotPull([pull({ merged_at: null })], 'm', 'master'), null);
assert.equal(findMergedDependabotPull([pull({ user: { login: 'someuser' } })], 'm', 'master'), null);
assert.equal(findMergedDependabotPull([pull({ merge_commit_sha: 'other' })], 'm', 'master'), null);
assert.equal(findMergedDependabotPull([pull({ base: { ref: 'release' } })], 'm', 'master'), null);
});
test('trusts only merge commits that GitHub created and signed', () => {
assert.equal(isGitHubSignedCommit({ committer: { login: 'web-flow' }, commit: { verification: { committer: 'web-flow', verified: true } } }), true);
assert.equal(isGitHubSignedCommit({ committer: { login: 'web-flow' }, commit: { verification: { committer: 'web-flow', verified: false } } }), false);
assert.equal(isGitHubSignedCommit({ committer: { login: 'someuser' }, commit: { verification: { committer: 'web-flow', verified: true } } }), false);
assert.equal(isGitHubSignedCommit(null), false);
});
// Run the `pr` command against a real git history, with a stub `gh` that lists the PR commits.
function runPrCheck(build) {
const root = mkdtempSync(path.join(tmpdir(), 'lockfile-trust-'));
try {
const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim();
const commit = (author, files) => {
for (const [file, contents] of Object.entries(files)) {
mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
writeFileSync(path.join(root, file), contents);
}
git('add', '.');
git('-c', `user.name=${author}`, '-c', `user.email=${author}@example.invalid`, 'commit', '-qm', author);
return git('rev-parse', 'HEAD');
};
git('init', '-q');
const baseSha = commit('base', { 'pnpm-lock.yaml': 'lock: base\n', 'package.json': '{}\n' });
const { author, commits } = build(commit);
const bin = path.join(root, '.bin');
execFileSync('mkdir', [bin]);
writeFileSync(path.join(bin, 'gh'), '#!/bin/sh\nprintf "%s\\n" "$GH_STUB_OUTPUT"\n');
chmodSync(path.join(bin, 'gh'), 0o755);
const output = path.join(root, '.output');
writeFileSync(output, '');
const result = spawnSync(process.execPath, [path.resolve('.github/scripts/lockfile-trust.mjs'), 'pr'], {
cwd: root, encoding: 'utf8',
env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, GITHUB_OUTPUT: output, GITHUB_REPOSITORY: repo,
PR_NUMBER: '7', BASE_SHA: baseSha, HEAD_SHA: git('rev-parse', 'HEAD'), PR_AUTHOR: author, HEAD_REPO: repo,
HEAD_REF: 'dependabot/npm_and_yarn/zod', GH_STUB_OUTPUT: commits.map((entry) => JSON.stringify(entry)).join('\n') },
});
return { status: result.status, stdout: result.stdout, output: readFileSync(output, 'utf8') };
} finally {
rmSync(root, { recursive: true, force: true });
}
}
test('pr command admits a Dependabot lockfile after a later non-lockfile commit', () => {
const result = runPrCheck((commit) => {
const sha = commit('dependabot', { 'pnpm-lock.yaml': 'lock: dependabot\n', 'package.json': '{"zod":"4.6.6"}\n' });
commit('maintainer', { 'notes.md': 'fix\n' });
return { author: 'dependabot[bot]', commits: [{ sha, login: 'dependabot[bot]', committer: 'web-flow', verified: true }] };
});
assert.equal(result.status, 0, result.stdout);
assert.match(result.output, /^writer=dependabot$/m);
});
test('pr command blocks a lockfile edited after the Dependabot commit', () => {
const result = runPrCheck((commit) => {
const sha = commit('dependabot', { 'pnpm-lock.yaml': 'lock: dependabot\n' });
const edit = commit('maintainer', { 'pnpm-lock.yaml': 'lock: edited\n' });
return { author: 'dependabot[bot]', commits: [
{ sha, login: 'dependabot[bot]', committer: 'web-flow', verified: true },
{ sha: edit, login: 'maintainer', committer: 'web-flow', verified: true },
] };
});
assert.equal(result.status, 1);
assert.match(result.stdout, /Untrusted lockfile change/);
});
test('pr command blocks a lockfile commit whose Dependabot author another committer signed', () => {
const result = runPrCheck((commit) => {
const sha = commit('maintainer', { 'pnpm-lock.yaml': 'lock: spoofed\n' });
return { author: 'dependabot[bot]', commits: [{ sha, login: 'dependabot[bot]', committer: 'maintainer', verified: true }] };
});
assert.equal(result.status, 1);
assert.match(result.stdout, /Untrusted lockfile change/);
});
test('pr command checks each lockfile a Dependabot pull request changes', () => {
const [file] = PROVIDER_LOCKFILES;
const admitted = runPrCheck((commit) => {
const sha = commit('dependabot', { [file]: 'lock: dependabot\n', 'package.json': '{"zod":"4.6.6"}\n' });
return { author: 'dependabot[bot]', commits: [{ sha, login: 'dependabot[bot]', committer: 'web-flow', verified: true }] };
});
assert.equal(admitted.status, 0, admitted.stdout);
assert.match(admitted.output, /^writer=dependabot$/m);
const edited = runPrCheck((commit) => {
const sha = commit('dependabot', { 'pnpm-lock.yaml': 'lock: dependabot\n', [file]: 'lock: dependabot\n' });
const edit = commit('maintainer', { [file]: 'lock: edited\n' });
return { author: 'dependabot[bot]', commits: [
{ sha, login: 'dependabot[bot]', committer: 'web-flow', verified: true },
{ sha: edit, login: 'maintainer', committer: 'web-flow', verified: true },
] };
});
assert.equal(edited.status, 1, edited.stdout);
assert.ok(edited.stdout.includes(`${file} does not match`), edited.stdout);
});
test('pr command blocks a contributor change to a provider lockfile', () => {
const result = runPrCheck((commit) => {
commit('someuser', { [PROVIDER_LOCKFILES[1]]: 'lock: contributor\n' });
return { author: 'someuser', commits: [] };
});
assert.equal(result.status, 1);
assert.match(result.output, /^writer=none$/m);
});
test('pr command blocks a contributor lockfile change', () => {
const result = runPrCheck((commit) => {
commit('someuser', { 'pnpm-lock.yaml': 'lock: contributor\n' });
return { author: 'someuser', commits: [] };
});
assert.equal(result.status, 1);
assert.match(result.output, /^writer=none$/m);
});
test('workflows run the trust policy from trusted code and pass the verified hash to the proof refresh', () => {
const pr = readFileSync('.github/workflows/pr-trusted.yml', 'utf8');
assert.match(pr, /git show "\$trusted_base:\.github\/scripts\/lockfile-trust\.mjs" > "\$policy"/);
assert.match(pr, /BASE_SHA="\$trusted_base" node "\$policy" pr/);
assert.match(pr, /PAPERCLIP_CI_PR_AUTHOR: \$\{\{ github\.event\.pull_request\.user\.login \}\}/);
assert.doesNotMatch(pr, /github\.head_ref != 'chore\/refresh-lockfile'/);
const refresh = readFileSync('.github/workflows/refresh-lockfile.yml', 'utf8');
assert.match(refresh, /node \.github\/scripts\/lockfile-trust\.mjs merged/);
assert.match(refresh, /--refresh --trusted-lock-sha256 "\$TRUSTED_LOCK_SHA256"/);
assert.ok(refresh.indexOf('lockfile-trust.mjs merged') < refresh.indexOf('--trusted-lock-sha256'));
});
// Run the policy job's step script, as GitHub would, against a merge commit.
// RUNNER_TEMP is a symlink, so the policy must still recognize itself as the entry point.
function runPolicyStep({ staleBase, githubSha, headSha, author, headRef, root }) {
const workflow = readFileSync('.github/workflows/pr-trusted.yml', 'utf8');
const step = workflow.split(' - name: Block untrusted lockfile edits\n')[1];
const lines = step.split('\n');
const start = lines.findIndex((line) => line === ' run: |') + 1;
const end = lines.findIndex((line, index) => index >= start && line.trim() !== '' && !line.startsWith(' '));
const script = lines.slice(start, end).map((line) => line.slice(10)).join('\n');
const output = path.join(root, '.output');
writeFileSync(output, '');
const runnerTemp = path.join(mkdtempSync(path.join(tmpdir(), 'policy-runner-')), 'temp');
symlinkSync(mkdtempSync(path.join(tmpdir(), 'policy-runner-real-')), runnerTemp);
const result = spawnSync('bash', ['-e', '-c', script], {
cwd: root, encoding: 'utf8',
env: { ...process.env, RUNNER_TEMP: runnerTemp, GITHUB_OUTPUT: output,
GITHUB_REPOSITORY: repo, GITHUB_SHA: githubSha, BASE_SHA: staleBase, HEAD_SHA: headSha, PR_NUMBER: '1',
PR_AUTHOR: author, HEAD_REPO: repo, HEAD_REF: headRef },
});
return { status: result.status, stdout: result.stdout + result.stderr, output: readFileSync(output, 'utf8') };
}
test('the policy step loads the policy from the merge commit when the recorded base is stale', () => {
const root = mkdtempSync(path.join(tmpdir(), 'lockfile-policy-step-'));
try {
const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim();
const commit = (message) => {
git('add', '.');
git('-c', 'user.name=Fixture', '-c', '[email protected]', 'commit', '-qm', message);
return git('rev-parse', 'HEAD');
};
git('init', '-q', '-b', 'master');
writeFileSync(path.join(root, 'pnpm-lock.yaml'), 'lock: base\n');
const staleBase = commit('base before the policy');
// A contributor branch named like the refresh branch, from the stale base. The earlier rule exempts it.
git('checkout', '-q', '-b', 'chore/refresh-lockfile');
writeFileSync(path.join(root, 'pnpm-lock.yaml'), 'lock: contributor\n');
const head = commit('contributor lockfile');
git('checkout', '-q', 'master');
execFileSync('mkdir', ['-p', path.join(root, '.github/scripts')]);
writeFileSync(path.join(root, '.github/scripts/lockfile-trust.mjs'), readFileSync('.github/scripts/lockfile-trust.mjs'));
commit('add the policy');
git('-c', 'user.name=GitHub', '-c', '[email protected]', 'merge', '-q', '--no-ff', '-X', 'theirs', '-m', 'merge', head);
const merge = git('rev-parse', 'HEAD');
const blocked = runPolicyStep({ root, staleBase, githubSha: merge, headSha: head, author: 'someuser', headRef: 'chore/refresh-lockfile' });
assert.equal(blocked.status, 1, blocked.stdout);
assert.match(blocked.stdout, /Untrusted lockfile change/);
// A contributor pull request that does not touch lock content passes through the loaded policy.
git('checkout', '-q', '-b', 'docs', staleBase);
writeFileSync(path.join(root, 'notes.md'), 'docs\n');
const docs = commit('docs only');
git('checkout', '-q', 'master');
git('-c', 'user.name=GitHub', '-c', '[email protected]', 'merge', '-q', '--no-ff', '-m', 'merge docs', docs);
const allowed = runPolicyStep({ root, staleBase, githubSha: git('rev-parse', 'HEAD'), headSha: docs, author: 'someuser', headRef: 'docs' });
assert.equal(allowed.status, 0, allowed.stdout);
assert.match(allowed.stdout, /does not change lock content/);
assert.match(allowed.output, /^writer=none$/m);
// Without GitHub's merge commit, the step keeps the recorded base, which predates the policy.
const fallback = runPolicyStep({ root, staleBase, githubSha: head, headSha: head, author: 'someuser', headRef: 'chore/refresh-lockfile' });
assert.equal(fallback.status, 0, fallback.stdout);
assert.match(fallback.output, /^writer=none$/m);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('the policy step fails when the loaded policy exits without reporting a writer', () => {
const root = mkdtempSync(path.join(tmpdir(), 'lockfile-policy-silent-'));
try {
const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim();
const commit = (message) => {
git('add', '.');
git('-c', 'user.name=Fixture', '-c', '[email protected]', 'commit', '-qm', message);
return git('rev-parse', 'HEAD');
};
git('init', '-q', '-b', 'master');
execFileSync('mkdir', ['-p', path.join(root, '.github/scripts')]);
writeFileSync(path.join(root, '.github/scripts/lockfile-trust.mjs'), 'process.exit(0);\n');
const baseSha = commit('a policy that skips its checks');
git('checkout', '-q', '-b', 'feature');
writeFileSync(path.join(root, 'pnpm-lock.yaml'), 'lock: contributor\n');
const head = commit('contributor lockfile');
git('checkout', '-q', 'master');
git('-c', 'user.name=GitHub', '-c', '[email protected]', 'merge', '-q', '--no-ff', '-m', 'merge', head);
const result = runPolicyStep({ root, staleBase: baseSha, githubSha: git('rev-parse', 'HEAD'), headSha: head, author: 'someuser', headRef: 'feature' });
assert.equal(result.status, 1, result.stdout);
assert.match(result.stdout, /did not report a lockfile writer/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
@@ -0,0 +1,315 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import path from "node:path";
import { green, deliver, notify, snapshot, trustedVerification } from "../notify-pr-merge-review.mjs";
const head = "a".repeat(40);
const base = "b".repeat(40);
const check = (name, conclusion = "SUCCESS", extra = {}) => ({
__typename: "CheckRun", name, status: "COMPLETED", conclusion, workflowName: "PR",
checkSuite: { databaseId: 8, app: { databaseId: 15368 }, workflowRun: { databaseId: 7 } },
...extra,
});
const candidate = (extra = {}) => ({
number: 42, state: "OPEN", isDraft: false, headRefOid: head, baseRefOid: base,
baseRefName: "master", changedFiles: 1, statusCheckRollup: [check("ci / verify"), check("ci / e2e")], ...extra,
});
const event = (extra = {}) => ({ repository: { full_name: "paperclipai/paperclip" }, sha: head, ...extra });
const trustedRun = (extra = {}) => ({
id: 7, repository: { full_name: "paperclipai/paperclip" }, path: ".github/workflows/pr.yml",
event: "pull_request", head_sha: head, status: "completed", conclusion: "success", check_suite_id: 8,
referenced_workflows: [{ path: "paperclipai/paperclip/.github/workflows/pr-trusted.yml@master",
ref: "refs/heads/master", sha: base }], ...extra,
});
const files = [{ filename: "README.md" }];
const read = (endpoint) => endpoint.includes("/rules/") ? [] : trustedRun();
const list = (endpoint) => endpoint.includes("/files?") ? files : [{ number: 42 }];
const deps = (extra = {}) => ({
read, list, view: () => candidate(),
send: async () => "delivered", ...extra,
});
test("reads every check page and fails closed on incomplete or changing listings", () => {
const page = (nodes, hasNextPage, extra = {}) => ({ data: { repository: { pullRequest: candidate({
statusCheckRollup: { contexts: { totalCount: 2, nodes,
pageInfo: { hasNextPage, endCursor: "next" } } }, ...extra,
}) } } });
let calls = 0;
const pr = snapshot(42, (args) => {
if (++calls === 1) return page([check("ci / verify")], true);
assert.ok(args.includes("endCursor=next"));
return page([check("ci / e2e")], false);
});
assert.equal(green(pr), true);
const own = check("notify", "", { status: "IN_PROGRESS", workflowName: undefined,
checkSuite: { workflowRun: { workflow: { name: "PR merge review notification" } } } });
const withOwn = snapshot(42, () => ({ data: { repository: { pullRequest: candidate({
statusCheckRollup: { contexts: { totalCount: 3, nodes: [...candidate().statusCheckRollup, own],
pageInfo: { hasNextPage: false } } },
}) } } }));
assert.equal(green(withOwn), false);
assert.throws(() => snapshot(42, () => page([check("ci / verify")], false)), /Incomplete/);
calls = 0;
assert.throws(() => snapshot(42, () => ++calls === 1 ? page([check("ci / verify")], true)
: page([check("ci / e2e")], false, { headRefOid: "d".repeat(40) })), /Candidate changed/);
assert.throws(() => snapshot(42, () => ({ errors: [{ message: "unavailable" }] })), /Incomplete/);
});
test("requires real success from both aggregate checks and additional repository rules", () => {
assert.equal(green(candidate()), true);
for (const state of ["FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "NEUTRAL", "SKIPPED", ""]) {
assert.equal(green(candidate({ statusCheckRollup: [check("ci / verify", state), check("ci / e2e")] })), false, state);
}
assert.equal(green(candidate({ statusCheckRollup: [check("ci / verify")] })), false);
assert.equal(green(candidate(), ["extra required"]), false);
assert.equal(green(candidate({ statusCheckRollup: [...candidate().statusCheckRollup, check("extra required")] }), ["extra required"]), true);
assert.equal(green(candidate({ statusCheckRollup: [] })), false);
});
test("waits for pending checks and statuses, permits skipped optional routing lanes", () => {
for (const extra of [check("tests", "", { status: "IN_PROGRESS" }), check("tests", "FAILURE"),
{ __typename: "StatusContext", context: "security", state: "PENDING" },
{ __typename: "StatusContext", context: "security", state: "ERROR" }]) {
assert.equal(green(candidate({ statusCheckRollup: [...candidate().statusCheckRollup, extra] })), false);
}
assert.equal(green(candidate({ statusCheckRollup: [...candidate().statusCheckRollup,
check("not selected", "SKIPPED"), { __typename: "StatusContext", context: "security", state: "SUCCESS" }] })), true);
assert.equal(green(candidate({ statusCheckRollup: [...candidate().statusCheckRollup,
check("optional scanner coverage", "NEUTRAL")] })), true);
});
test("workflow names cannot hide pending or failing checks", () => {
const extras = ["PR merge review notification", "CI profile"].map((workflowName) =>
check("notify", "", { status: "IN_PROGRESS", workflowName }));
assert.equal(green(candidate({ statusCheckRollup: [...candidate().statusCheckRollup, ...extras] })), false);
assert.equal(green(candidate({ statusCheckRollup: [...candidate().statusCheckRollup,
check("another", "", { status: "QUEUED", workflowName: "Other verification" })] })), false);
});
test("rejects closed, draft, wrong-base, and malformed candidates", () => {
for (const extra of [{ state: "MERGED" }, { isDraft: true }, { baseRefName: "release" },
{ headRefOid: "invalid" }, { baseRefOid: "invalid" }]) assert.equal(green(candidate(extra)), false);
});
test("resolves fork PRs from the head commit when workflow payload has no PRs", async () => {
const seen = [];
const outcomes = await notify(event({ workflow_run: {
status: "completed", event: "pull_request", head_sha: head, pull_requests: [],
} }), deps({ list: (endpoint) => { seen.push(endpoint); return list(endpoint); } }));
assert.deepEqual(outcomes, ["delivered"]);
assert.match(seen[0], new RegExp(`/commits/${head}/pulls\\?per_page=100$`));
});
test("reads additional required rules and holds until their check succeeds", async () => {
const outcomes = await notify(event(), deps({ read: () => [{ type: "required_status_checks",
parameters: { required_status_checks: [{ context: "new gate" }] } }] }));
assert.deepEqual(outcomes, []);
});
test("forged successful check names from a PR-controlled workflow cannot notify", async () => {
const input = deps({ read: (endpoint) => endpoint.includes("/rules/") ? [] : {
id: 7, path: ".github/workflows/attacker.yml", event: "pull_request",
head_sha: head, status: "completed", conclusion: "success",
} });
assert.deepEqual(await notify(event(), input), []);
});
test("requires genuine suite, caller, revision, and master reusable workflow provenance", () => {
assert.equal(trustedVerification(candidate(), deps()), true);
for (const changes of [
{ path: ".github/workflows/attacker.yml" }, { event: "pull_request_target" },
{ head_sha: "c".repeat(40) }, { status: "in_progress" }, { conclusion: "failure" },
{ repository: { full_name: "attacker/repo" } }, { check_suite_id: 99 },
{ referenced_workflows: [] },
{ referenced_workflows: [{ path: "paperclipai/paperclip/.github/workflows/pr-trusted.yml@master",
ref: "refs/pull/42/merge", sha: base }] },
]) {
assert.equal(trustedVerification(candidate(), deps({ read: () => trustedRun(changes) })), false);
}
const wrongSuite = check("ci / verify", "SUCCESS", {
checkSuite: { databaseId: 99, app: { databaseId: 1 }, workflowRun: { databaseId: 7 } },
});
assert.equal(trustedVerification(candidate({
statusCheckRollup: [wrongSuite, check("ci / e2e")],
}), deps()), false);
});
test("modified or renamed caller and truncated file listings cannot notify", async () => {
for (const fileList of [[], [{ filename: ".github/workflows/pr.yml" }],
[{ filename: "moved.yml", previous_filename: ".github/workflows/pr.yml" }]]) {
const dependencies = deps({ list: (endpoint) => endpoint.includes("/files?")
? fileList : [{ number: 42 }] });
assert.deepEqual(await notify(event(), dependencies), []);
}
assert.equal(trustedVerification(candidate({ changedFiles: undefined }), deps()), false);
});
test("rejects stale head events and a head, base, or check change during refresh", async () => {
assert.deepEqual(await notify(event({ sha: "c".repeat(40) }), deps()), []);
for (const extra of [{ headRefOid: "c".repeat(40) }, { baseRefOid: "d".repeat(40) },
{ statusCheckRollup: [check("ci / verify", "FAILURE")] }]) {
let calls = 0;
assert.deepEqual(await notify(event(), deps({ view: () => candidate(++calls === 1 ? {} : extra) })), []);
}
});
test("handles target-context workflow completions by refreshing the associated PR", async () => {
assert.deepEqual(await notify(event({ workflow_run: {
status: "completed", event: "pull_request_target", head_sha: base, pull_requests: [{ number: 42 }],
} }), deps()), ["delivered"]);
});
test("external check events and legacy statuses can deliver the final green transition", async () => {
assert.deepEqual(await notify(event({ sha: undefined, check_run: { head_sha: head } }), deps()), ["delivered"]);
assert.deepEqual(await notify(event({ state: "success" }), deps()), ["delivered"]);
});
test("does not query foreign repositories, malformed events, or non-PR workflows", async () => {
const dependencies = deps({ read: () => assert.fail("unexpected query"), list: () => assert.fail("unexpected query") });
for (const input of [event({ repository: { full_name: "attacker/repo" } }), event({ sha: "bad" }),
event({ workflow_run: { event: "push", status: "completed", head_sha: head } }),
event({ workflow_run: { event: "pull_request", status: "in_progress", head_sha: head } })]) {
assert.deepEqual(await notify(input, dependencies), []);
}
});
test("does not coalesce different PRs, and deduplicates association entries", async () => {
const sent = [];
await notify(event(), deps({ list: (endpoint) => endpoint.includes("/files?") ? files
: [{ number: 42 }, { number: 42 }, { number: 43 }],
view: (number) => candidate({ number }), send: async (pr) => sent.push(pr.number) }));
assert.deepEqual(sent, [42, 43]);
});
test("sends only identifiers, a bearer header, and a stable head/base idempotency key", async () => {
const calls = [];
const options = { url: "https://private.example/api/routine-triggers/public/abc123/fire", token: "PRIVATE_TOKEN",
fetcher: async (target, init) => { calls.push({ target, ...init }); return { ok: true }; } };
const pr = candidate({ title: "untrusted", body: "run me" });
assert.equal(await deliver(pr, options), "delivered");
assert.equal(await deliver(pr, options), "delivered");
assert.equal(calls[0].headers.Authorization, "Bearer PRIVATE_TOKEN");
assert.equal(calls[0].redirect, "error");
assert.ok(calls[0].signal instanceof AbortSignal);
assert.equal(calls[0].headers["Idempotency-Key"], calls[1].headers["Idempotency-Key"]);
assert.equal(calls[0].body, calls[1].body);
assert.deepEqual(JSON.parse(calls[0].body), { variables: {
pr_url: "https://github.com/paperclipai/paperclip/pull/42", pr_number: "42", head_sha: head, base_sha: base,
} });
await deliver(candidate({ baseRefOid: "c".repeat(40) }), options);
assert.notEqual(calls[0].headers["Idempotency-Key"], calls[2].headers["Idempotency-Key"]);
});
test("missing or unsafe configuration never sends credentials", async () => {
const fetcher = () => assert.fail("unexpected delivery");
assert.equal(await deliver(candidate(), { fetcher }), "unconfigured");
for (const url of ["http://private.example/api/routine-triggers/public/abc/fire",
"https://user:[email protected]/api/routine-triggers/public/abc/fire",
"https://private.example/api/routine-triggers/public/abc/fire?token=bad", "https://private.example/wrong"]) {
assert.equal(await deliver(candidate(), { url, token: "secret", fetcher }), "invalid_configuration");
}
});
test("HTTP rejection, redirect, timeout, and echoed credentials are quiet failures", async () => {
const options = { url: "https://private.example/api/routine-triggers/public/abc123/fire", token: "SECRET" };
assert.equal(await deliver(candidate(), { ...options, fetcher: async () => ({ ok: false,
text: () => assert.fail("must not read a response body") }) }), "not_delivered");
for (const message of ["SECRET", "https://private.example", "redirect", "timeout"]) {
assert.equal(await deliver(candidate(), { ...options, fetcher: async () => { throw new Error(message); } }), "not_delivered");
}
});
test("command failures cannot print a private URL, token, or event payload", () => {
const directory = mkdtempSync(path.join(tmpdir(), "pr-notifier-test-"));
try {
const file = path.join(directory, "event.json");
writeFileSync(file, "PRIVATE_EVENT_INVALID_JSON");
const run = spawnSync(process.execPath, [".github/scripts/notify-pr-merge-review.mjs"], {
encoding: "utf8", env: { ...process.env, GITHUB_EVENT_PATH: file,
PAPERCLIP_PR_REVIEW_WEBHOOK_URL: "PRIVATE_URL_SENTINEL", PAPERCLIP_PR_REVIEW_WEBHOOK_TOKEN: "PRIVATE_TOKEN_SENTINEL" },
});
assert.equal(run.status, 0);
assert.doesNotMatch(run.stdout + run.stderr, /PRIVATE_/);
assert.match(run.stdout, /verification results are unchanged/);
} finally { rmSync(directory, { recursive: true, force: true }); }
});
test("workflow uses read permissions, a protected environment, trusted checkout, and optional delivery", () => {
const workflow = readFileSync(".github/workflows/pr-merge-review.yml", "utf8");
assert.match(workflow, /environment: pr-merge-review/);
assert.match(workflow, /ref: \$\{\{ github.sha \}\}/);
assert.match(workflow, /persist-credentials: false/);
assert.match(workflow, /continue-on-error: true/);
for (const permission of ["actions", "checks", "contents", "pull-requests", "statuses"]) {
assert.ok(workflow.includes(` ${permission}: read`), `Missing ${permission}: read`);
}
assert.doesNotMatch(workflow, /: write|download-artifact|restore@|pull_request_target:/);
for (const name of ["PAPERCLIP_PR_REVIEW_WEBHOOK_URL", "PAPERCLIP_PR_REVIEW_WEBHOOK_TOKEN"]) {
assert.ok(workflow.includes(`secrets.${name}`));
assert.ok(!workflow.includes(`vars.${name}`));
}
});
// Evaluates the small GitHub expression subset used by the notifier job condition.
// Unknown functions or syntax fail the test instead of being silently approximated.
function jobCondition(expression) {
const source = expression.trim().replace(/\s+/g, " ");
const allowed = /^(?:\s|\(|\)|&&|\|\||==|!=|,|'[^']*'|\d+|github(?:\.[a-z_]+)+|contains|fromJSON)+$/;
assert.match(source, allowed, "Unsupported syntax in the notifier job condition");
const javascript = source.replace(/github((?:\.[a-z_]+)+)/g, (_, path) => `github${path.replaceAll(".", "?.")}`)
.replace(/==/g, "===").replace(/!===/g, "!==");
const evaluate = new Function("github", "contains", "fromJSON", `return (${javascript});`);
return (github) => evaluate(github, (list, value) => list.includes(value), (text) => JSON.parse(text));
}
test("workflow skips non-final events before runner allocation and collapses bursts per PR head", () => {
const workflow = readFileSync(".github/workflows/pr-merge-review.yml", "utf8");
const triggers = workflow.slice(workflow.indexOf("\non:"), workflow.indexOf("\npermissions:"));
// Exactly these trigger kinds; check_run and status cannot be filtered at the trigger level.
assert.deepEqual([...triggers.matchAll(/^ {2}([a-z_]+):/gm)].map((match) => match[1]),
["workflow_run", "check_run", "status"]);
assert.match(triggers, /check_run:\n {4}types: \[completed\]/);
const job = workflow.slice(workflow.indexOf("\n notify:"));
const condition = job.slice(job.indexOf(" if: >-"), job.indexOf("\n # ", job.indexOf(" if: >-")));
// GitHub Actions check runs arrive through workflow_run; only other providers schedule a job.
assert.match(condition, /github\.event_name == 'check_run' &&\s+github\.event\.check_run\.app\.id != 15368/);
assert.match(condition, /fromJSON\('\["success","skipped","neutral"\]'\), github\.event\.check_run\.conclusion/);
assert.match(condition, /fromJSON\('\["success","skipped","neutral"\]'\), github\.event\.workflow_run\.conclusion/);
assert.match(condition, /github\.event_name == 'status' && github\.event\.state == 'success'/);
assert.match(condition, /github\.event\.workflow_run\.event == 'pull_request'/);
assert.match(condition, /github\.repository == 'paperclipai\/paperclip'/);
// Evaluate the condition for real events so an operator change cannot reopen the flood.
const schedules = jobCondition(condition.replace(/^ {4}if: >-/, ""));
const repository = { full_name: "paperclipai/paperclip" };
const run = (event, conclusion) => ({ event_name: "workflow_run", repository: "paperclipai/paperclip",
event: { repository, workflow_run: { event, conclusion, head_sha: head } } });
const checkRun = (appId, conclusion) => ({ event_name: "check_run", repository: "paperclipai/paperclip",
event: { repository, check_run: { app: { id: appId }, conclusion, head_sha: head } } });
const status = (state) => ({ event_name: "status", repository: "paperclipai/paperclip",
event: { repository, state, sha: head } });
for (const accepted of [run("pull_request", "success"), run("pull_request_target", "neutral"),
run("pull_request", "skipped"), checkRun(867647, "success"), checkRun(156372, "neutral"), status("success")]) {
assert.equal(schedules(accepted), true, JSON.stringify(accepted));
}
for (const rejected of [run("pull_request", "failure"), run("pull_request", "cancelled"), run("push", "success"),
run("schedule", "success"), checkRun(15368, "success"), checkRun(867647, "failure"),
checkRun(867647, "action_required"), status("pending"), status("failure"), status("error"),
{ ...status("success"), repository: "someone/fork" }]) {
assert.equal(schedules(rejected), false, JSON.stringify(rejected));
}
// Job-level concurrency: a skipped event must not cancel a scheduled notifier.
assert.doesNotMatch(workflow.slice(0, workflow.indexOf("\njobs:")), /concurrency:/);
assert.match(job, /\n {4}concurrency:\n {6}group: pr-merge-review-\$\{\{ github\.event\.workflow_run\.head_sha \|\| github\.event\.check_run\.head_sha \|\| github\.event\.sha \}\}\n {6}cancel-in-progress: true\n/);
});
test("API probe uses the same read permissions without private webhook access", () => {
const probe = readFileSync(".github/workflows/pr-review-api-access.yml", "utf8");
for (const permission of ["actions", "checks", "contents", "pull-requests", "statuses"]) {
assert.ok(probe.includes(` ${permission}: read`));
}
assert.match(probe, /snapshot\(Number\(process.env.PR_NUMBER\)\)/);
assert.doesNotMatch(probe, /\$\{\{ secrets\.|environment:|: write|deliver\(/);
});
@@ -0,0 +1,317 @@
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import {
classifyJob, createGithubClient, decide, logShowsRunnerLoss, main, renderSummary,
} from "../runner-loss-rerun.mjs";
// Trimmed attempt-1 snapshots of real PR runs: jobs, check-run annotations for
// each failed job, and the error lines of job logs whose only annotation is
// "The operation was canceled.".
const fixture = (runId) => JSON.parse(readFileSync(
new URL(`./fixtures/runner-loss-rerun/run-${runId}.json`, import.meta.url), "utf8",
));
const MIXED = 42271918832; // six lost runners, one shutdown in the log, one real test timeout
const REAL = 42264785415; // a real assertion failure
const ALL_LOST = 42272083862; // one lost runner
const LOG_ONLY = 42277969418; // shutdown signal visible only in the job log
const workflow = readFileSync(new URL("../../workflows/ci-runner-loss-rerun.yml", import.meta.url), "utf8");
const json = (body, headers = {}) => new Response(JSON.stringify(body), {
status: 200, headers: { "content-type": "application/json", ...headers },
});
/**
* Serves a fixture as the GitHub REST API. By default the run is the latest
* CI for an open PR's current head.
*/
function fakeGithub(data, {
jobsPageSize = 100, pulls, prRuns, mutateRun = (run) => run,
} = {}) {
const calls = [];
const run = mutateRun(structuredClone(data.run));
const openPr = {
number: 1, state: "open",
base: { repo: { id: data.run.repository.id } },
head: { sha: data.run.head_sha, repo: { id: data.run.head_repository.id } },
};
const fetcher = async (href, init) => {
const url = new URL(href);
calls.push({ method: init.method, path: url.pathname, query: url.search, auth: init.headers.Authorization });
const base = `/repos/paperclipai/paperclip`;
const route = url.pathname.slice(base.length);
if (init.method === "POST" && route === `/actions/runs/${run.id}/rerun-failed-jobs`) {
return new Response(null, { status: 201 });
}
if (route === `/actions/runs/${run.id}`) return json(run);
if (route === `/actions/runs/${run.id}/attempts/1/jobs`) {
const page = Number(url.searchParams.get("page") ?? 1);
const jobs = data.jobs.slice((page - 1) * jobsPageSize, page * jobsPageSize);
const last = page * jobsPageSize >= data.jobs.length;
const next = new URL(url);
next.searchParams.set("page", String(page + 1));
return json({ total_count: data.total_count, jobs }, last ? {} : { link: `<${next.href}>; rel="next"` });
}
let match = route.match(/^\/check-runs\/(\d+)\/annotations$/);
if (match) return json(data.annotations[match[1]] ?? []);
match = route.match(/^\/actions\/jobs\/(\d+)\/logs$/);
if (match) return new Response(data.logs[match[1]] ?? "", { status: 200 });
if (route === `/commits/${run.head_sha}/pulls`) return json(pulls ?? [openPr]);
if (route === "/actions/workflows/pr.yml/runs") {
const runs = prRuns ?? [{ id: run.id, conclusion: "failure" }];
return json({ total_count: runs.length, workflow_runs: runs });
}
return new Response("not found", { status: 404 });
};
return { calls, fetcher, run, client: createGithubClient({ token: "test-token", fetcher }) };
}
const posted = (calls) => calls.filter((call) => call.method === "POST");
async function runMain(data, options = {}, eventRun = {}) {
const github = fakeGithub(data, options);
const directory = mkdtempSync(path.join(tmpdir(), "runner-loss-rerun-"));
try {
const eventPath = path.join(directory, "event.json");
const summaryPath = path.join(directory, "summary.md");
writeFileSync(eventPath, JSON.stringify({ workflow_run: { id: data.run.id, run_attempt: 1, ...eventRun } }));
writeFileSync(summaryPath, "");
const decision = await main({
env: { GITHUB_TOKEN: "test-token", GITHUB_EVENT_PATH: eventPath, GITHUB_STEP_SUMMARY: summaryPath },
argv: [], fetcher: github.fetcher, log: () => {},
});
return { decision, calls: github.calls, summary: readFileSync(summaryPath, "utf8") };
} finally {
rmSync(directory, { recursive: true, force: true });
}
}
test("all failed jobs lost their runner: re-runs failed jobs once", async () => {
const { decision, calls, summary } = await runMain(fixture(ALL_LOST));
assert.equal(decision.rerun, true);
assert.deepEqual(decision.jobs.map((job) => [job.name, job.lost]), [["ci / General tests (server (3/12))", true]]);
assert.deepEqual(posted(calls).map((call) => call.path),
[`/repos/paperclipai/paperclip/actions/runs/${ALL_LOST}/rerun-failed-jobs`]);
assert.ok(calls.every((call) => call.auth === "Bearer test-token"));
// `ci / verify` failed too, but only because its dependency failed.
assert.equal(calls.some((call) => call.path.endsWith("/check-runs/128088995071/annotations")), false);
assert.match(summary, /\*\*Re-ran failed jobs\*\*/);
assert.match(summary, /\| ci \/ General tests \(server \(3\/12\)\) \| yes \| The self-hosted runner lost communication/);
});
test("a shutdown signal found only in the job log counts as runner loss", async () => {
const data = fixture(LOG_ONLY);
assert.deepEqual(data.annotations["128101016102"].map((annotation) => annotation.message), ["The operation was canceled."]);
const { decision, calls } = await runMain(data);
assert.equal(decision.rerun, true);
assert.match(decision.jobs[0].evidence, /job log/);
assert.ok(calls.some((call) => call.path.endsWith("/actions/jobs/128101016102/logs")));
assert.equal(posted(calls).length, 1);
});
test("mixed runner loss and a real test failure: no re-run", async () => {
const { decision, calls, summary } = await runMain(fixture(MIXED));
assert.equal(decision.rerun, false);
assert.equal(decision.jobs.length, 8);
assert.deepEqual(decision.jobs.filter((job) => !job.lost).map((job) => job.name), ["ci / General tests (workspaces-a (1/2))"]);
assert.equal(decision.jobs.find((job) => job.name === "ci / Verify Paperclip Runner (vitest package)").lost, true);
assert.equal(posted(calls).length, 0);
assert.match(decision.reason, /1 of 8 failed jobs did not lose their runner/);
assert.match(summary, /\*\*No automatic re-run\*\*/);
});
test("real test failures: no re-run", async () => {
const { decision, calls } = await runMain(fixture(REAL));
assert.equal(decision.rerun, false);
assert.deepEqual(decision.jobs.map((job) => [job.name, job.lost]), [["ci / General tests (server (4/12))", false]]);
assert.equal(posted(calls).length, 0);
});
test("only the verify and e2e aggregates failed: no re-run", async () => {
const data = fixture(ALL_LOST);
data.jobs = data.jobs.map((job) => job.name.startsWith("ci / General tests") ? { ...job, conclusion: "success" } : job);
const { decision, calls } = await runMain(data);
assert.equal(decision.rerun, false);
assert.match(decision.reason, /no failed jobs other than the verify\/e2e aggregates/);
assert.equal(calls.some((call) => call.path.includes("/check-runs/")), false);
assert.equal(posted(calls).length, 0);
});
test("attempt 2 or later is never re-run automatically", async () => {
// The event was for attempt 1, but someone re-ran the run before this job started.
const { decision, calls } = await runMain(fixture(ALL_LOST), { mutateRun: (run) => ({ ...run, run_attempt: 2 }) });
assert.equal(decision.rerun, false);
assert.match(decision.reason, /attempt 2/);
assert.deepEqual(calls.map((call) => call.path), [`/repos/paperclipai/paperclip/actions/runs/${ALL_LOST}`]);
});
test("a failed routing gate is left for a human re-run", async () => {
const data = fixture(ALL_LOST);
const gate = data.jobs.find((job) => job.name === "ci / gate / Select trusted runner");
gate.conclusion = "failure";
data.annotations[gate.id] = [{ annotation_level: "failure", message: "The self-hosted runner lost communication with the server." }];
const { decision, calls } = await runMain(data);
assert.equal(decision.rerun, false);
assert.match(decision.jobs.find((job) => job.id === gate.id).evidence, /routing gate/);
assert.equal(posted(calls).length, 0);
});
test("runs that are no longer the PR's latest CI are not re-run", async () => {
const data = fixture(ALL_LOST);
const moved = await decide(fakeGithub(data, {
pulls: [{ state: "open", base: { repo: { id: 1170821064 } }, head: { sha: "0".repeat(40), repo: { id: 1170821064 } } }],
}).client, data.run);
assert.equal(moved.rerun, false);
assert.match(moved.reason, /no open pull request/);
const merged = await decide(fakeGithub(data, {
pulls: [{ state: "closed", base: { repo: { id: 1170821064 } }, head: { sha: data.run.head_sha, repo: { id: 1170821064 } } }],
}).client, data.run);
assert.equal(merged.rerun, false);
// A later full-ci label run on the same head shares the PR concurrency group;
// re-running the older run would cancel it.
const newer = await decide(fakeGithub(data, {
prRuns: [{ id: data.run.id + 1, conclusion: null }, { id: data.run.id, conclusion: "failure" }],
}).client, data.run);
assert.equal(newer.rerun, false);
assert.match(newer.reason, /newer PR run/);
// A later label event that did not request CI skips its only job; it is not CI.
const skipped = await decide(fakeGithub(data, {
prRuns: [{ id: data.run.id + 1, conclusion: "skipped" }, { id: data.run.id, conclusion: "failure" }],
}).client, data.run);
assert.equal(skipped.rerun, true);
});
test("ineligible runs are rejected before any job is read", async () => {
const data = fixture(ALL_LOST);
for (const [change, reason] of [
[{ event: "push" }, /event is push/],
[{ path: ".github/workflows/other.yml" }, /workflow is/],
[{ conclusion: "success" }, /completed\/success/],
[{ repository: { id: 1, full_name: "someone/paperclip" } }, /canonical repository/],
]) {
const github = fakeGithub(data);
const decision = await decide(github.client, { ...data.run, ...change });
assert.equal(decision.rerun, false);
assert.match(decision.reason, reason);
assert.equal(github.calls.length, 0);
}
});
test("job listing follows Link pagination across pages", async () => {
const data = fixture(MIXED);
const github = fakeGithub(data, { jobsPageSize: 20 });
const decision = await decide(github.client, data.run);
const pages = github.calls.filter((call) => call.path.endsWith("/attempts/1/jobs"));
assert.deepEqual(pages.map((call) => new URLSearchParams(call.query).get("page")), [null, "2", "3"]);
// Failed jobs on pages 2 and 3 are all classified.
assert.equal(decision.jobs.length, 8);
assert.equal(decision.rerun, false);
});
test("an incomplete job listing fails instead of deciding on partial data", async () => {
const data = fixture(ALL_LOST);
data.total_count += 1;
const github = fakeGithub(data);
await assert.rejects(decide(github.client, data.run), /Incomplete listing/);
assert.equal(posted(github.calls).length, 0);
});
test("the client refuses to follow pagination to another host", async () => {
const client = createGithubClient({
token: "test-token",
fetcher: async () => json([], { link: '<https://example.com/steal?page=2>; rel="next"' }),
});
await assert.rejects(client.paginate("/repos/paperclipai/paperclip/commits/x/pulls"), /Refusing to call https:\/\/example.com/);
});
test("classification is conservative about cancelled jobs", async () => {
const cancelled = [{ annotation_level: "failure", message: "The operation was canceled." }];
const noSignal = await classifyJob({ id: 1 }, { annotations: cancelled, readLog: async () => "##[error]The operation was canceled.\n" });
assert.equal(noSignal.lost, false);
// A job timeout also cancels the step, but adds its own failure annotation.
const timedOut = await classifyJob({ id: 1 }, {
annotations: [{ annotation_level: "failure", message: "The job has exceeded the maximum execution time of 30m0s" }, ...cancelled],
readLog: async () => { throw new Error("log must not be read"); },
});
assert.equal(timedOut.lost, false);
// Notices and warnings are not failure evidence.
const warning = await classifyJob({ id: 1 }, {
annotations: [{ annotation_level: "warning", message: "The self-hosted runner lost communication with the server." }],
readLog: async () => "",
});
assert.equal(warning.lost, false);
// Test output that merely prints the phrase is not a runner error line.
assert.equal(logShowsRunnerLoss("2026-11-06T00:00:00Z expected 'received a shutdown signal'\n"), false);
assert.equal(logShowsRunnerLoss(fixture(MIXED).logs["128078674151"]), true);
});
test("a real failure before the runner was lost is not counted as runner loss", async () => {
const lostMessage = "The self-hosted runner lost communication with the server. Verify the machine is running.";
const noLog = async () => { throw new Error("log must not be read"); };
// A test fails, then the runner is lost during an always() upload step.
const testThenLost = await classifyJob({ id: 1 }, {
annotations: [
{ annotation_level: "failure", message: "Error: Test timed out in 15000ms." },
{ annotation_level: "failure", message: "Process completed with exit code 1." },
{ annotation_level: "failure", message: lostMessage },
],
readLog: noLog,
});
assert.equal(testThenLost.lost, false);
assert.match(testThenLost.evidence, /Test timed out/);
// The order of the annotations does not matter.
const lostThenExit = await classifyJob({ id: 1 }, {
annotations: [
{ annotation_level: "failure", message: lostMessage },
{ annotation_level: "failure", message: "Process completed with exit code 1." },
],
readLog: noLog,
});
assert.equal(lostThenExit.lost, false);
// Runner loss with a cancellation only is still runner loss.
const lostAndCancelled = await classifyJob({ id: 1 }, {
annotations: [
{ annotation_level: "failure", message: lostMessage },
{ annotation_level: "failure", message: "The operation was canceled." },
],
readLog: noLog,
});
assert.equal(lostAndCancelled.lost, true);
});
test("summary cells cannot break the table", () => {
const summary = renderSummary({
runId: 1, attempt: 1, rerun: false, reason: "test",
jobs: [{ name: "ci / a|b", lost: false, evidence: "line one\nline | two" }],
});
assert.match(summary, /\| ci \/ a\\\|b \| no \| line one line \\\| two \|/);
});
test("workflow acts only on failed first attempts of the canonical PR workflow", () => {
const condition = workflow.match(/^ {4}if: >-\n((?: {6}.+\n)+)/m)?.[1].replace(/\s+/g, " ").trim();
assert.equal(condition, [
"github.repository == 'paperclipai/paperclip' &&",
"github.repository_id == '1170821064' &&",
"github.event.workflow_run.event == 'pull_request' &&",
"github.event.workflow_run.path == '.github/workflows/pr.yml' &&",
"github.event.workflow_run.conclusion == 'failure' &&",
"github.event.workflow_run.run_attempt == 1",
].join(" "));
assert.match(workflow, /^on:\n {2}workflow_run:\n {4}workflows: \[PR\]\n {4}types: \[completed\]$/m);
assert.match(workflow, /^ {2}group: ci-runner-loss-rerun-\$\{\{ github\.event\.workflow_run\.id \}\}$/m);
const permissions = workflow.match(/^permissions:\n((?: {2}.+\n)+)/m)[1]
.split("\n").filter((line) => /^ {2}[a-z-]+:/.test(line)).map((line) => line.trim());
assert.deepEqual(permissions, ["actions: write", "checks: read", "contents: read", "pull-requests: read"]);
});
test("workflow runs only trusted default-branch code", () => {
assert.match(workflow, /ref: \$\{\{ github\.sha \}\}\n {10}persist-credentials: false\n {10}sparse-checkout: \.github\/scripts\n/);
assert.doesNotMatch(workflow, /head_sha|head_branch|download-artifact|secrets\.|pull_request_target/);
assert.equal([...workflow.matchAll(/uses: /g)].length, 1);
assert.match(workflow, /runs-on: ubuntu-latest/);
});
+1 -1
View File
@@ -33,7 +33,7 @@ env:
jobs:
authorize:
runs-on: ubuntu-latest
runs-on: ${{ github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.workflow_ref == 'paperclipai/paperclip/.github/workflows/chat-catalog-testops.yml@refs/heads/master' && github.ref == 'refs/heads/master' && github.event_name == 'workflow_dispatch' && github.actor_id == '34892728' && github.run_attempt == '1' && github.triggering_actor == github.actor && 'runs-on/fleet=paperclip-public-pr-x64/env=public-ci' || 'ubuntu-latest' }}
timeout-minutes: 5
outputs:
operation: ${{ steps.guard.outputs.operation }}
@@ -0,0 +1,52 @@
name: CI runner-loss rerun
# Self-hosted AWS runners are sometimes lost mid-job. When every failed job of
# a first PR CI attempt lost its runner, re-run the failed jobs once. This is
# what a maintainer's "Re-run failed jobs" click does; github-actions[bot]
# becomes attempt 2's triggering actor. See "Trusted PR Workflow" in
# doc/DEVELOPING.md.
on:
workflow_run:
workflows: [PR]
types: [completed]
permissions:
# rerun-failed-jobs, and reading jobs and job logs.
actions: write
# Check-run annotations carry the runner-loss message.
checks: read
contents: read
# Confirms the run still belongs to an open PR's current head.
pull-requests: read
concurrency:
group: ci-runner-loss-rerun-${{ github.event.workflow_run.id }}
cancel-in-progress: false
jobs:
rerun:
# Attempt 1 only: this allows one automatic re-run and cannot loop.
if: >-
github.repository == 'paperclipai/paperclip' &&
github.repository_id == '1170821064' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.path == '.github/workflows/pr.yml' &&
github.event.workflow_run.conclusion == 'failure' &&
github.event.workflow_run.run_attempt == 1
# GitHub-hosted on purpose: this job must not depend on the fleet it repairs.
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
# workflow_run is privileged. Never check out its head SHA or run code
# from downloaded artifacts. This checkout is the trusted default branch.
- name: Checkout trusted re-run script
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.sha }}
persist-credentials: false
sparse-checkout: .github/scripts
- name: Re-run failed jobs if every failure was runner loss
env:
GITHUB_TOKEN: ${{ github.token }}
run: node .github/scripts/runner-loss-rerun.mjs
+12 -6
View File
@@ -36,34 +36,40 @@ jobs:
run: |
count="$(gh pr list --repo paperclipai/paperclip --state open --search 'in:title "refresh measured test durations"' --limit 100 --json headRefName --jq '[.[] | select(.headRefName | startswith("codex/refresh-ci-durations-"))] | length')"
echo "count=$count" >> "$GITHUB_OUTPUT"
- name: Refresh from successful protected-master runs
- name: Refresh from successful protected-master and merged-PR runs
if: steps.pending.outputs.count == '0'
env:
GH_TOKEN: ${{ github.token }}
run: node scripts/ci/refresh-test-durations.mjs
run: node scripts/ci/refresh-test-durations.mjs "--pr-body=$RUNNER_TEMP/ci-duration-pr.md"
- name: Verify timing data and complete shard coverage
if: steps.pending.outputs.count == '0'
run: node --test scripts/ci/test-selection-timings.test.mjs scripts/__tests__/run-vitest-stable-shard.test.mjs
run: >-
node --test scripts/ci/test-selection-timings.test.mjs scripts/ci/test-selection-pr-timings.test.mjs
scripts/__tests__/run-vitest-stable-shard.test.mjs scripts/__tests__/e2e-shard.test.mjs
- name: Open a timing-only pull request
if: steps.pending.outputs.count == '0'
env:
GH_TOKEN: ${{ github.token }}
run: |
manifests="scripts/general-server-shard-durations.json scripts/serialized-shard-durations.json scripts/e2e-shard-durations.json packages/paperclip-runner/scripts/vitest-lane-durations.json"
# The Runner manifest can be new; record intent so the diff sees it.
for file in $manifests; do [ ! -e "$file" ] || git add -N -- "$file"; done
if git diff --quiet; then exit 0; fi
# The collector must not cause unrelated code to enter the bot PR.
git diff --name-only -z | node --input-type=module -e '
import {readFileSync} from "node:fs";
import assert from "node:assert/strict";
const allowed = new Set(["scripts/general-server-shard-durations.json", "scripts/serialized-shard-durations.json"]);
const allowed = new Set(["scripts/general-server-shard-durations.json", "scripts/serialized-shard-durations.json",
"scripts/e2e-shard-durations.json", "packages/paperclip-runner/scripts/vitest-lane-durations.json"]);
for (const file of readFileSync(0,"utf8").split("\0").filter(Boolean)) assert.ok(allowed.has(file), file);
'
branch="codex/refresh-ci-durations-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
git switch -c "$branch"
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add scripts/general-server-shard-durations.json scripts/serialized-shard-durations.json
for file in $manifests; do [ ! -e "$file" ] || git add -- "$file"; done
git commit -m 'chore(ci): refresh measured test durations' -m 'Co-Authored-By: Paperclip <[email protected]>'
gh auth setup-git
git push origin "$branch"
gh pr create --repo paperclipai/paperclip --base master --head "$branch" \
--title 'chore(ci): refresh measured test durations' --body-file .github/ci-duration-pr.md
--title 'chore(ci): refresh measured test durations' --body-file "$RUNNER_TEMP/ci-duration-pr.md"
+76
View File
@@ -0,0 +1,76 @@
name: PR merge review notification
on:
workflow_run:
workflows:
- PR
- Runner Windows
- Sentry SDK contract
- Docker Runner check
- commitperclip PR Review
- Storybook Visual
- Storybook Deploy
- CI work benchmark
- Incremental CI qualification
- Backup checkpoint qualification
- PR review API access
types: [completed]
# External check providers and legacy commit statuses can finish after CI.
# GitHub has no trigger-level filter for these events, so the job condition
# below skips them before a runner is allocated. GitHub Actions check runs
# are covered by the workflow_run list above.
check_run:
types: [completed]
status:
permissions:
actions: read
checks: read
contents: read
pull-requests: read
statuses: read
jobs:
notify:
# A skipped job does not take a runner. Only events that can complete a
# green PR state schedule the job: successful, skipped, or neutral PR
# workflow runs, the same results from non-Actions check providers (GitHub
# Actions app 15368 checks arrive through workflow_run), and successful
# commit statuses. One PR push previously queued about 60 runner jobs.
if: >-
github.repository == 'paperclipai/paperclip' &&
((github.event_name == 'workflow_run' &&
(github.event.workflow_run.event == 'pull_request' ||
github.event.workflow_run.event == 'pull_request_target') &&
contains(fromJSON('["success","skipped","neutral"]'), github.event.workflow_run.conclusion)) ||
(github.event_name == 'check_run' &&
github.event.check_run.app.id != 15368 &&
contains(fromJSON('["success","skipped","neutral"]'), github.event.check_run.conclusion)) ||
(github.event_name == 'status' && github.event.state == 'success'))
# Collapse bursts for one PR head to the latest event. The notifier reads
# the current PR state, so the newest event subsumes older ones. Job-level
# concurrency keeps skipped events from cancelling a scheduled notifier.
concurrency:
group: pr-merge-review-${{ github.event.workflow_run.head_sha || github.event.check_run.head_sha || github.event.sha }}
cancel-in-progress: true
runs-on: ubuntu-latest
timeout-minutes: 5
# Restrict this environment to master. Repository secrets would also be
# available to workflows changed by an in-repository PR.
environment: pr-merge-review
continue-on-error: true
steps:
# Only the trusted default-branch commit. Never use the PR SHA, caches,
# artifacts, dependencies, or executable files from a pull request.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.sha }}
persist-credentials: false
sparse-checkout: .github/scripts
- name: Notify the advisory review routine
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
PAPERCLIP_PR_REVIEW_WEBHOOK_URL: ${{ secrets.PAPERCLIP_PR_REVIEW_WEBHOOK_URL }}
PAPERCLIP_PR_REVIEW_WEBHOOK_TOKEN: ${{ secrets.PAPERCLIP_PR_REVIEW_WEBHOOK_TOKEN }}
run: node .github/scripts/notify-pr-merge-review.mjs
@@ -0,0 +1,43 @@
name: PR review API access
on:
pull_request:
paths:
- .github/scripts/notify-pr-merge-review.mjs
- .github/scripts/tests/notify-pr-merge-review.test.mjs
- .github/workflows/pr-merge-review.yml
- .github/workflows/pr-review-api-access.yml
permissions:
actions: read
checks: read
contents: read
pull-requests: read
statuses: read
jobs:
probe:
if: github.repository == 'paperclipai/paperclip'
runs-on: ubuntu-latest
timeout-minutes: 5
# This job has no environment or private webhook secrets. It proves that
# the notifier's status query works with the actual read-only Actions token.
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
sparse-checkout: .github/scripts
- name: Read the PR status rollup with the Actions token
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
node --input-type=module <<'JS'
import assert from 'node:assert/strict';
import { snapshot } from './.github/scripts/notify-pr-merge-review.mjs';
const pr = snapshot(Number(process.env.PR_NUMBER));
assert.equal(pr.headRefOid, process.env.PR_HEAD_SHA);
assert.ok(pr.statusCheckRollup.length > 0);
console.log('Actions token can read the complete PR status rollup.');
JS
+205 -14
View File
@@ -15,6 +15,9 @@ permissions:
# the Honeycomb credential, so fork PRs cannot execute with it.
env:
PAPERCLIP_CI_PROFILE_DIR: ${{ github.workspace }}/.ci-profile
# Empty keeps the PR default of one Vitest retry (scripts/ci/vitest-retry.mjs).
# Set the CI_VITEST_RETRY repository variable to 0 to switch retries off.
PAPERCLIP_VITEST_RETRY: ${{ vars.CI_VITEST_RETRY }}
concurrency:
group: pr-${{ github.event.pull_request.number }}
@@ -58,8 +61,21 @@ jobs:
else
echo 'enabled=false' >> "$GITHUB_OUTPUT"
fi
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86
# Same trusted bootstrap as every other pnpm setup in this workflow:
# Node immediately first, and no npm audit, fund or update-notifier calls.
- name: Setup Node.js for pnpm bootstrap
if: steps.eligible.outputs.enabled == 'true'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
package-manager-cache: false
- name: Setup pnpm
if: steps.eligible.outputs.enabled == 'true'
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
env:
NPM_CONFIG_AUDIT: "false"
NPM_CONFIG_FUND: "false"
NPM_CONFIG_UPDATE_NOTIFIER: "false"
with:
version: 9.15.4
- name: Restore dependencies for graph analysis
@@ -131,14 +147,45 @@ jobs:
echo 'CI_NODE_TEST=--test' >> "$GITHUB_ENV"
fi
- name: Block manual lockfile edits
if: >-
github.head_ref != 'chore/refresh-lockfile' &&
github.event.pull_request.user.login != 'dependabot[bot]'
# Only trusted environments write lock content: the lockfile refresh
# workflow, and Dependabot when the lockfile bytes come from a verified
# Dependabot commit. Run the policy from the current base-branch tip so a
# pull request cannot rewrite the check that judges it.
- name: Block untrusted lockfile edits
id: lockfile_writer
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
HEAD_REF: ${{ github.head_ref }}
run: |
# Diff the PR branch against its merge base so recent base-branch commits
# do not masquerade as changes made by the PR itself.
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}")"
# GitHub does not move pull_request.base.sha forward on every push, so
# it can name an old base-branch commit. The checkout is GitHub's merge
# commit: its first parent is the current base-branch tip, and its
# second parent is the PR head. Load the policy from that tip.
trusted_base="$BASE_SHA"
if [ "$(git rev-parse --verify --quiet "$GITHUB_SHA^2")" = "$HEAD_SHA" ]; then
trusted_base="$(git rev-parse "$GITHUB_SHA^1")"
fi
policy="$RUNNER_TEMP/lockfile-trust.mjs"
if git show "$trusted_base:.github/scripts/lockfile-trust.mjs" > "$policy" 2>/dev/null; then
BASE_SHA="$trusted_base" node "$policy" pr
# Fail closed if the policy did not run.
if ! grep -q '^writer=' "$GITHUB_OUTPUT"; then
echo "::error title=Lockfile policy::lockfile-trust.mjs did not report a lockfile writer."
exit 1
fi
exit 0
fi
# The base predates lockfile-trust.mjs: keep the earlier rule.
echo "writer=none" >> "$GITHUB_OUTPUT"
if [ "$HEAD_REF" = 'chore/refresh-lockfile' ] || [ "$PR_AUTHOR" = 'dependabot[bot]' ]; then
exit 0
fi
changed="$(git diff --name-only "$trusted_base...$HEAD_SHA")"
if printf '%s\n' "$changed" | grep -qx 'pnpm-lock.yaml'; then
echo "Do not commit pnpm-lock.yaml in pull requests. CI owns lockfile updates."
exit 1
@@ -186,9 +233,8 @@ jobs:
- name: Test feature module boundary check
run: node "$CI_NODE_TEST" ./scripts/check-module-boundaries.test.mjs
# Report-only for now (always exits 0); --enforce turns findings into failures.
- name: Report raw query-error rendering
run: node scripts/check-query-error-rendering.mjs --summary
- name: Reject raw query-error rendering
run: node scripts/check-query-error-rendering.mjs --enforce --summary
- name: Test query-error rendering check
run: node --test ./scripts/check-query-error-rendering.test.mjs
@@ -407,6 +453,9 @@ jobs:
packages/paperclip-runner/runner/target/release/deps/paperclip_runnerd-*
packages/paperclip-runner/runner/target/release/paperclip-runnerd
packages/paperclip-runner/runner/target/release/paperclip-runnerd.d
packages/paperclip-runner/runner/target/release/.fingerprint/paperclip-process-table-*
packages/paperclip-runner/runner/target/release/deps/libpaperclip_process_table-*
packages/paperclip-runner/runner/target/release/deps/paperclip_process_table-*.d
key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/acpx-profiles.json', 'packages/paperclip-runner/antigravity-distributions.json', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }}
- name: Mark the restored Runner release crate current
@@ -416,6 +465,9 @@ jobs:
- name: Typecheck workspaces whose build scripts skip TypeScript
run: pnpm run typecheck:build-gaps
- name: Typecheck test files that no build reads
run: node scripts/typecheck-test-programs.mjs --low-memory
- name: Typecheck the server in low-memory layers
env:
PAPERCLIP_TYPECHECK_LOW_MEMORY: "1"
@@ -456,6 +508,9 @@ jobs:
PAPERCLIP_CI_SELECTION_HASH: ${{ matrix.selection && fromJSON(needs.test_selection.outputs.plan).testSelection.hash || '' }}
PAPERCLIP_CI_PROFILE: ${{ needs.gate.outputs.profile_ci }}
PAPERCLIP_CI_PROFILE_JOB: General tests (${{ matrix.group_label }})
# The reporting-lock policy test skips a Dependabot lockfile, which the
# policy job verifies and the lockfile refresh admits after merge.
PAPERCLIP_CI_PR_AUTHOR: ${{ github.event.pull_request.user.login }}
steps:
- name: Checkout repository
@@ -612,10 +667,137 @@ jobs:
if-no-files-found: warn
retention-days: 14
# Sandbox providers are outside the root workspace, so no other lane runs their
# tests. The `//#ci:sandbox-providers` inputs in turbo.json select this job: the
# providers, the plugin SDK, and the shared package the SDK depends on.
sandbox_provider_tests:
name: Sandbox provider tests
needs: [gate]
if: ${{ needs.gate.outputs.full_ci == 'true' && (needs.gate.outputs.plan == '' || fromJSON(needs.gate.outputs.plan).jobs.sandbox_provider_tests.run) }}
runs-on: ${{ needs.gate.outputs.runner }}
timeout-minutes: 15
env:
PAPERCLIP_CI_PROFILE: ${{ needs.gate.outputs.profile_ci }}
PAPERCLIP_CI_PROFILE_JOB: Sandbox provider tests
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Initialize CI profile
continue-on-error: true
if: env.PAPERCLIP_CI_PROFILE == 'true'
run: node scripts/ci-profile/initialize.mjs
- name: Setup Node.js for pnpm bootstrap
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
package-manager-cache: false
- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
env:
NPM_CONFIG_AUDIT: "false"
NPM_CONFIG_FUND: "false"
NPM_CONFIG_UPDATE_NOTIFIER: "false"
with:
version: 9.15.4
# Share the checked-in lockfile key with master. PR merge refs must not
# save full copies of the store or evict the post-merge build caches.
- name: Restore materialized dependencies and shared builds
uses: ./.github/actions/setup-ci-dependencies
# The script installs the providers from their own lockfiles, links the
# in-repo plugin SDK, and runs every provider suite. Daytona's capacity
# tests kill a process at a file-size limit inside the exported
# directory. On a runner that allows core dumps, its core file joins the
# export and changes the transferred file count, so turn core dumps off.
- name: Run sandbox provider tests
run: |
ulimit -c 0
node scripts/test-sandbox-providers.mjs
- name: Upload CI profile timings
continue-on-error: true
if: always() && env.PAPERCLIP_CI_PROFILE == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ci-profile-${{ github.run_attempt }}-${{ github.job }}-${{ strategy.job-index || 0 }}
path: .ci-profile/*.ndjson
include-hidden-files: true
if-no-files-found: warn
retention-days: 14
verify_runner_e2e_support:
name: Runner support suite
needs: [gate]
if: ${{ needs.gate.outputs.full_ci == 'true' && (needs.gate.outputs.plan == '' || fromJSON(needs.gate.outputs.plan).jobs.verify_runner_e2e_support.run) }}
runs-on: ${{ needs.gate.outputs.runner }}
timeout-minutes: 20
env:
PAPERCLIP_CI_PROFILE: ${{ needs.gate.outputs.profile_ci }}
PAPERCLIP_CI_PROFILE_JOB: Runner support suite
# The paid Runner workflows depend on this suite: workflow security
# policy, evidence oracles, cost receipts and controller recovery. It
# used to run only in a manual Docker Runner check, so master changes
# broke it unseen (#19527, #19529). It needs no provider credentials.
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Initialize CI profile
continue-on-error: true
if: env.PAPERCLIP_CI_PROFILE == 'true'
run: node scripts/ci-profile/initialize.mjs
- name: Setup Node.js for pnpm bootstrap
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
package-manager-cache: false
- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
env:
NPM_CONFIG_AUDIT: "false"
NPM_CONFIG_FUND: "false"
NPM_CONFIG_UPDATE_NOTIFIER: "false"
with:
version: 9.15.4
# Share the checked-in lockfile key with master. PR merge refs must not
# save full copies of the store or evict the post-merge build caches.
- name: Restore materialized dependencies and shared builds
uses: ./.github/actions/setup-ci-dependencies
# Builds the plugin SDK first, then runs Vitest and the node:test files.
- name: Run the Runner support suite
run: pnpm test:e2e:runner:unit
- name: Upload CI profile timings
continue-on-error: true
if: always() && env.PAPERCLIP_CI_PROFILE == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ci-profile-${{ github.run_attempt }}-${{ github.job }}-${{ strategy.job-index || 0 }}
path: .ci-profile/*.ndjson
include-hidden-files: true
if-no-files-found: warn
retention-days: 14
verify:
name: verify
if: ${{ always() }}
needs: [gate, test_selection, policy, typecheck_release_registry, general_tests, verify_paperclip_runner, build, docker_context_integrity, verify_serialized_server, canary_dry_run]
needs: [gate, test_selection, policy, typecheck_release_registry, general_tests, verify_paperclip_runner, build, docker_context_integrity, sandbox_provider_tests, verify_serialized_server, canary_dry_run, verify_runner_e2e_support]
runs-on: ${{ needs.gate.outputs.runner }}
timeout-minutes: 5
steps:
@@ -634,7 +816,7 @@ jobs:
const plan = process.env.CI_PLAN ? JSON.parse(process.env.CI_PLAN) : null;
if (plan) assert.equal(plan.version, 1, 'Supported task plan required');
if (plan?.testSelection) assert.equal(results.test_selection.result, 'success', 'Published selection must succeed');
for (const job of ["typecheck_release_registry","general_tests","verify_paperclip_runner","build","docker_context_integrity","verify_serialized_server","canary_dry_run"]) {
for (const job of ["typecheck_release_registry","general_tests","verify_paperclip_runner","build","docker_context_integrity","sandbox_provider_tests","verify_serialized_server","canary_dry_run","verify_runner_e2e_support"]) {
if (plan) assert.equal(typeof plan.jobs[job]?.run, 'boolean', 'Missing suite decision: ' + job);
const selected = process.env.FULL_CI === 'true' && (!plan || plan.jobs[job].run);
assert.equal(results[job].result, selected ? 'success' : 'skipped', job);
@@ -649,7 +831,7 @@ jobs:
timeout-minutes: 20
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.gate.outputs.plan != '' && toJSON(fromJSON(needs.gate.outputs.plan).jobs.verify_paperclip_runner.matrix) || '{"include":[{"lane_label":"static checks","command":"check:static","task":"ci:runner-static"},{"lane_label":"rust","command":"check:runner","release_crate":true,"task":"ci:runner-rust"},{"lane_label":"vitest package","command":"test:typescript:vitest --shard=1/2","task":"ci:runner-vitest"},{"lane_label":"vitest native server","command":"test:typescript:vitest --shard=2/2","release_crate":true,"task":"ci:runner-vitest"}]}') }}
matrix: ${{ fromJSON(needs.gate.outputs.plan != '' && toJSON(fromJSON(needs.gate.outputs.plan).jobs.verify_paperclip_runner.matrix) || '{"include":[{"lane_label":"static checks","command":"check:static","task":"ci:runner-static"},{"lane_label":"rust","command":"check:runner","release_crate":true,"task":"ci:runner-rust"},{"lane_label":"vitest package (1/3)","command":"test:typescript:vitest --package-lane=1/3","task":"ci:runner-vitest"},{"lane_label":"vitest package (2/3)","command":"test:typescript:vitest --package-lane=2/3","task":"ci:runner-vitest"},{"lane_label":"vitest package (3/3)","command":"test:typescript:vitest --package-lane=3/3","task":"ci:runner-vitest"},{"lane_label":"vitest native server","command":"test:typescript:vitest --native-server","release_crate":true,"task":"ci:runner-vitest"}]}') }}
env:
PAPERCLIP_CI_PROFILE: ${{ needs.gate.outputs.profile_ci }}
@@ -828,6 +1010,9 @@ jobs:
packages/paperclip-runner/runner/target/release/deps/paperclip_runnerd-*
packages/paperclip-runner/runner/target/release/paperclip-runnerd
packages/paperclip-runner/runner/target/release/paperclip-runnerd.d
packages/paperclip-runner/runner/target/release/.fingerprint/paperclip-process-table-*
packages/paperclip-runner/runner/target/release/deps/libpaperclip_process_table-*
packages/paperclip-runner/runner/target/release/deps/paperclip_process_table-*.d
key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/acpx-profiles.json', 'packages/paperclip-runner/antigravity-distributions.json', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }}
- name: Mark the restored Runner release crate current
@@ -1072,6 +1257,9 @@ jobs:
packages/paperclip-runner/runner/target/release/deps/paperclip_runnerd-*
packages/paperclip-runner/runner/target/release/paperclip-runnerd
packages/paperclip-runner/runner/target/release/paperclip-runnerd.d
packages/paperclip-runner/runner/target/release/.fingerprint/paperclip-process-table-*
packages/paperclip-runner/runner/target/release/deps/libpaperclip_process_table-*
packages/paperclip-runner/runner/target/release/deps/paperclip_process_table-*.d
key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/acpx-profiles.json', 'packages/paperclip-runner/antigravity-distributions.json', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }}
- name: Mark the restored Runner release crate current
@@ -1413,6 +1601,9 @@ jobs:
packages/paperclip-runner/runner/target/release/deps/paperclip_runnerd-*
packages/paperclip-runner/runner/target/release/paperclip-runnerd
packages/paperclip-runner/runner/target/release/paperclip-runnerd.d
packages/paperclip-runner/runner/target/release/.fingerprint/paperclip-process-table-*
packages/paperclip-runner/runner/target/release/deps/libpaperclip_process_table-*
packages/paperclip-runner/runner/target/release/deps/paperclip_process_table-*.d
key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/acpx-profiles.json', 'packages/paperclip-runner/antigravity-distributions.json', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }}
- name: Mark the restored Runner release crate current
+76 -22
View File
@@ -24,6 +24,12 @@ jobs:
actions: read
contents: write
pull-requests: write
env:
# Every lockfile this workflow owns: the root one, then the sandbox
# providers' own lockfiles. Keep it equal to LOCKFILES in
# .github/scripts/lockfile-trust.mjs, which blocks contributor edits to
# the same files. A test checks that the two lists match.
LOCKFILES: pnpm-lock.yaml scripts/typecheck-sandbox-providers/pnpm-lock.yaml packages/plugins/sandbox-providers/daytona/pnpm-lock.yaml
steps:
- name: Checkout repository
@@ -43,9 +49,9 @@ jobs:
# claim the shared cache key with an empty archive before full installs.
package-manager-cache: false
# Start from the lockfile the previous run held, so resolution keeps those
# Start from the lockfiles the previous run held, so resolution keeps those
# versions while their manifests are unchanged and they can age past the
# gate. Read it from that run's artifact, not the PR branch: anyone with
# gate. Read them from that run's artifact, not the PR branch: anyone with
# write access can push to chore/refresh-lockfile.
- name: Reuse versions from a held refresh
env:
@@ -61,21 +67,51 @@ jobs:
echo "The previous refresh held nothing. Resolving from master."
exit 0
fi
# Reuse the held lock only while master still has the lock it was
# resolved from. A lock change on master, such as a maintainer's
# correction, supersedes it.
if [ "$(sha256sum < pnpm-lock.yaml)" != "$(cat "$held/source-lock.sha256")" ]; then
echo "The lockfile on master changed after run $run_id held its refresh. Resolving from master."
# An artifact saved before the provider lockfiles has no
# source-locks.sha256. Resolve from master instead.
if [ ! -f "$held/source-locks.sha256" ]; then
echo "Run $run_id held its refresh in an older format. Resolving from master."
exit 0
fi
cp "$held/pnpm-lock.yaml" pnpm-lock.yaml
echo "Resolving from the lockfile held by run $run_id."
# Reuse the held locks only while master still has every lock they
# were resolved from. A lock change on master, such as a maintainer's
# correction, supersedes them.
if ! sha256sum --check --status "$held/source-locks.sha256"; then
echo "A lockfile on master changed after run $run_id held its refresh. Resolving from master."
exit 0
fi
for lock in $LOCKFILES; do
cp "$held/$lock" "$lock"
done
echo "Resolving from the lockfiles held by run $run_id."
- name: Refresh pnpm lockfile
run: pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile
# Sandbox providers stay out of the root workspace. The shared typecheck
# workspace has its own pnpm-workspace.yaml, and Daytona installs alone.
- name: Refresh sandbox provider lockfiles
run: |
pnpm --dir scripts/typecheck-sandbox-providers install --resolution-only --ignore-scripts --no-frozen-lockfile
pnpm --dir packages/plugins/sandbox-providers/daytona install --ignore-workspace --resolution-only --ignore-scripts --no-frozen-lockfile
# A verified Dependabot lockfile merged to master is trusted lock content.
# Report its hash so the proof refresh can admit it.
- name: Identify a verified Dependabot lockfile on master
id: trusted_lock
env:
GH_TOKEN: ${{ github.token }}
run: node .github/scripts/lockfile-trust.mjs merged
- name: Refresh reviewed eval report dependency proof
run: node tests/runner-e2e/prepare-report-lock.mjs --refresh
env:
TRUSTED_LOCK_SHA256: ${{ steps.trusted_lock.outputs.trusted_lock_sha256 }}
run: |
if [ -n "$TRUSTED_LOCK_SHA256" ]; then
node tests/runner-e2e/prepare-report-lock.mjs --refresh --trusted-lock-sha256 "$TRUSTED_LOCK_SHA256"
else
node tests/runner-e2e/prepare-report-lock.mjs --refresh
fi
- name: Fail on unexpected file changes
run: |
@@ -84,7 +120,9 @@ jobs:
echo "Lockfile is already up to date."
exit 0
fi
unexpected="$(printf '%s\n' "$changed" | awk '$2 != "pnpm-lock.yaml" && $2 != "tests/runner-e2e/report-lock.json" && $2 != "tests/runner-e2e/report-lock.patch" { print }')"
unexpected="$(printf '%s\n' "$changed" | awk -v allowed="$LOCKFILES tests/runner-e2e/report-lock.json tests/runner-e2e/report-lock.patch" '
BEGIN { n = split(allowed, files, " "); for (i = 1; i <= n; i++) ok[files[i]] = 1 }
!($2 in ok) { print }')"
if [ -n "$unexpected" ]; then
echo "Unexpected files changed during lockfile refresh:"
echo "$changed"
@@ -97,8 +135,10 @@ jobs:
GH_TOKEN: ${{ github.token }}
REPO_OWNER: ${{ github.repository_owner }}
run: |
if git diff --quiet -- pnpm-lock.yaml; then
echo "Lockfile unchanged, nothing to do."
# A verified Dependabot lockfile that already resolves changes only
# the reporting-lock proof. Commit that proof too.
if git diff --quiet -- $LOCKFILES tests/runner-e2e/report-lock.json tests/runner-e2e/report-lock.patch; then
echo "Lockfiles and reporting-lock proof unchanged, nothing to do."
echo "pr_url=" >> "$GITHUB_OUTPUT"
echo "head_sha=" >> "$GITHUB_OUTPUT"
exit 0
@@ -109,8 +149,8 @@ jobs:
git config user.email "[email protected]"
git checkout -B "$BRANCH"
git add pnpm-lock.yaml tests/runner-e2e/report-lock.json tests/runner-e2e/report-lock.patch
git commit -m "chore(lockfile): refresh pnpm-lock.yaml"
git add $LOCKFILES tests/runner-e2e/report-lock.json tests/runner-e2e/report-lock.patch
git commit -m "chore(lockfile): refresh pnpm lockfiles"
git push --force origin "$BRANCH"
# Only reuse an open PR from this repository owner, not a fork with the same branch name.
@@ -147,6 +187,7 @@ jobs:
## What Changed
- Refresh the dependency lock from the manifests on master.
- Refresh the sandbox provider lockfiles from their manifests on master.
- Include the matching offline report correction and hashes.
## Verification
@@ -171,7 +212,7 @@ jobs:
PR_BODY
pr_url="$(gh pr create \
--head "$BRANCH" \
--title "chore(lockfile): refresh pnpm-lock.yaml" \
--title "chore(lockfile): refresh pnpm lockfiles" \
--body-file "$pr_body_file")"
echo "Created new PR: $pr_url"
else
@@ -187,10 +228,18 @@ jobs:
id: release-age
if: steps.upsert-pr.outputs.pr_url != ''
run: |
git show "$GITHUB_SHA:pnpm-lock.yaml" > "$RUNNER_TEMP/base-pnpm-lock.yaml"
# Keep each base lockfile at its repository path. "Stage held
# lockfile" hashes them from there.
base="$RUNNER_TEMP/base-locks"
pairs=()
for lock in $LOCKFILES; do
mkdir -p "$base/$(dirname "$lock")"
# A lockfile that master does not have yet has no base, so all of its entries count as added.
git show "$GITHUB_SHA:$lock" > "$base/$lock" 2>/dev/null || : > "$base/$lock"
pairs+=(--base "$base/$lock" --head "$lock")
done
node .github/scripts/check-lockfile-release-age.mjs \
--base "$RUNNER_TEMP/base-pnpm-lock.yaml" \
--head pnpm-lock.yaml \
"${pairs[@]}" \
--min-age-days 3 \
--report "$RUNNER_TEMP/release-age-report.md"
cat "$RUNNER_TEMP/release-age-report.md" >> "$GITHUB_STEP_SUMMARY"
@@ -219,9 +268,14 @@ jobs:
- name: Stage held lockfile
if: steps.release-age.outputs.eligible == 'false'
run: |
mkdir -p "$RUNNER_TEMP/held-upload"
cp pnpm-lock.yaml "$RUNNER_TEMP/held-upload/pnpm-lock.yaml"
sha256sum < "$RUNNER_TEMP/base-pnpm-lock.yaml" > "$RUNNER_TEMP/held-upload/source-lock.sha256"
held="$RUNNER_TEMP/held-upload"
for lock in $LOCKFILES; do
mkdir -p "$held/$(dirname "$lock")"
cp "$lock" "$held/$lock"
done
# Hash the master lockfiles this refresh started from, by repository
# path, so the next run can check them with sha256sum --check.
(cd "$RUNNER_TEMP/base-locks" && sha256sum $LOCKFILES) > "$held/source-locks.sha256"
- name: Save held lockfile for the next refresh
if: steps.release-age.outputs.eligible == 'false'
+7 -1
View File
@@ -450,7 +450,7 @@ jobs:
- name: Clean debug test variants before publishing workspace binaries
if: ${{ matrix.lane == 'rust' && github.repository == 'paperclipai/paperclip' && github.event_name == 'push' && github.ref == 'refs/heads/master' && inputs.ref == github.sha && steps.runner_debug_crate.outputs.cache-hit != 'true' }}
working-directory: packages/paperclip-runner
run: cargo clean --profile dev --manifest-path runner/Cargo.toml -p paperclip-runner-core
run: cargo clean --profile dev --manifest-path runner/Cargo.toml -p paperclip-runner-core -p paperclip-process-table
- name: Warm debug dependencies for the shared Runner cache
# Protocol tests need debug binaries. Populate their dependencies in
@@ -497,6 +497,9 @@ jobs:
packages/paperclip-runner/runner/target/release/deps/paperclip_runnerd-*
packages/paperclip-runner/runner/target/release/paperclip-runnerd
packages/paperclip-runner/runner/target/release/paperclip-runnerd.d
packages/paperclip-runner/runner/target/release/.fingerprint/paperclip-process-table-*
packages/paperclip-runner/runner/target/release/deps/libpaperclip_process_table-*
packages/paperclip-runner/runner/target/release/deps/paperclip_process_table-*.d
key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/acpx-profiles.json', 'packages/paperclip-runner/antigravity-distributions.json', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }}
- name: Build the Runner release crate for its cache
@@ -518,6 +521,9 @@ jobs:
packages/paperclip-runner/runner/target/release/deps/paperclip_runnerd-*
packages/paperclip-runner/runner/target/release/paperclip-runnerd
packages/paperclip-runner/runner/target/release/paperclip-runnerd.d
packages/paperclip-runner/runner/target/release/.fingerprint/paperclip-process-table-*
packages/paperclip-runner/runner/target/release/deps/libpaperclip_process_table-*
packages/paperclip-runner/runner/target/release/deps/paperclip_process_table-*.d
key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/acpx-profiles.json', 'packages/paperclip-runner/antigravity-distributions.json', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }}
build:
@@ -0,0 +1,103 @@
name: Runner E2E TestOps registration
run-name: Runner E2E TestOps registration / ${{ inputs.plan }} / ${{ inputs.operation }}
# One-shot, idempotent registration of a sibling runner E2E TestOps plan. It
# never runs a provider cell. `inspect` only reads; `register` creates missing
# cases and the plan and assigns the plan to the standing plan's GitHub job.
on:
workflow_dispatch:
inputs:
plan:
description: "Sibling TestOps plan to register"
type: choice
options: [reliability]
default: reliability
operation:
description: "inspect reads TestOps only; register creates any missing case, the plan and its job assignment"
type: choice
options: [inspect, register]
default: inspect
permissions:
contents: read
concurrency:
group: runner-e2e-testops-registration
cancel-in-progress: false
jobs:
authorize:
name: Authorize registration
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- name: Require default branch and allowlisted numeric actor IDs
env:
GH_TOKEN: ${{ github.token }}
REF: ${{ github.ref }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
ACTOR: ${{ github.actor }}
ACTOR_ID: ${{ github.actor_id }}
TRIGGERING_ACTOR: ${{ github.triggering_actor }}
ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }}
run: |
set -euo pipefail
if [ "$REF" != "refs/heads/$DEFAULT_BRANCH" ]; then
echo "TestOps registration may run only from the default branch." >&2
exit 1
fi
if ! jq -e 'type == "array" and length > 0 and all(.[]; type == "number" and . > 0 and floor == .)' <<< "${ALLOWED_ACTOR_IDS:-}" >/dev/null; then
echo "RUNNER_E2E_ALLOWED_ACTOR_IDS must be a non-empty JSON array of numeric GitHub user IDs." >&2
exit 1
fi
for attempt in 1 2 3; do
if triggering_actor_id="$(timeout 30s gh api "users/$TRIGGERING_ACTOR" --jq .id)"; then
break
fi
if [ "$attempt" = 3 ]; then exit 1; fi
sleep "$((attempt * 2))"
done
if [ "$triggering_actor_id" != "$ACTOR_ID" ] && [ "$TRIGGERING_ACTOR" = "$ACTOR" ]; then
echo "GitHub actor identity contexts disagree; refusing registration." >&2
exit 1
fi
for candidate in "$triggering_actor_id" "$ACTOR_ID"; do
if ! jq -e --argjson candidate "$candidate" 'index($candidate) != null' <<< "$ALLOWED_ACTOR_IDS" >/dev/null; then
echo "The initiating GitHub account is not authorized to register TestOps plans." >&2
exit 1
fi
done
register:
name: Register the Reliability plan in TestOps
needs: authorize
runs-on: ubuntu-latest
timeout-minutes: 15
environment: runner-e2e-testops-reporting
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- name: Inspect or register the plan, its cases and its job assignment
env:
ALLURE_TOKEN: ${{ secrets.ALLURE_TOKEN }}
ALLURE_ENDPOINT: https://paperclip.testops.cloud
ALLURE_PROJECT_ID: '1'
REGISTRATION_PLAN: ${{ inputs.plan }}
REGISTRATION_OPERATION: ${{ inputs.operation }}
run: node tests/runner-e2e/reliability-testops-registration.mjs testops-registration/receipt.json
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: reliability-testops-registration-${{ github.run_id }}-${{ github.run_attempt }}
path: testops-registration/receipt.json
if-no-files-found: ignore
retention-days: 90
+107 -38
View File
@@ -49,9 +49,14 @@ on:
type: string
required: false
testops_case:
description: "Product regression TestOps bootstrap: all, case name or case ID; leave empty for normal campaigns"
description: "TestOps plan bootstrap: all, case name or case ID; leave empty for normal campaigns"
type: string
required: false
testops_plan:
description: "TestOps plan for a testops_case bootstrap; a TestOps launch names its own plan"
type: choice
options: [product-regression, reliability]
default: product-regression
ALLURE_JOB_RUN_ID:
description: "Allure TestOps service input; its requested selection takes precedence"
type: string
@@ -60,6 +65,10 @@ on:
description: "Allure TestOps service input"
type: string
required: false
image_only:
description: "Only build, sign and verify the Daytona image for the selected cells; skip every campaign job and paid gate"
type: boolean
default: false
permissions:
contents: read
@@ -139,8 +148,14 @@ jobs:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
EVENT_NAME: ${{ github.event_name }}
EVAL_CADENCE: ${{ inputs.cadence || 'manual' }}
IMAGE_ONLY: ${{ inputs.image_only == true }}
TESTOPS_REQUESTED: ${{ inputs.ALLURE_JOB_RUN_ID != '' || inputs.testops_case != '' }}
run: |
set -euo pipefail
if [ "$IMAGE_ONLY" = true ] && [ "$TESTOPS_REQUESTED" = true ]; then
echo "image_only builds publish no results; it cannot bind a TestOps selection." >&2
exit 1
fi
if [ -z "$TARGET_BRANCH" ] || [[ "$TARGET_BRANCH" == refs/* ]]; then
echo "target_branch must name a branch in this repository without a refs/ prefix." >&2
exit 1
@@ -297,7 +312,7 @@ jobs:
if-no-files-found: error
testops_selection:
name: Bind Product regression TestOps selection
name: Bind TestOps plan selection
needs: authorize
if: github.event_name == 'workflow_dispatch' && (inputs.ALLURE_JOB_RUN_ID != '' || inputs.testops_case != '')
runs-on: ubuntu-latest
@@ -338,10 +353,12 @@ jobs:
SELECT_ENVIRONMENT: ${{ inputs.environment }}
SELECT_CASE: ${{ inputs.case }}
TESTOPS_CASE: ${{ inputs.testops_case }}
TESTOPS_PLAN: ${{ inputs.testops_plan || 'product-regression' }}
ALLURE_JOB_RUN_ID: ${{ inputs.ALLURE_JOB_RUN_ID }}
ALLURE_USERNAME: ${{ inputs.ALLURE_USERNAME }}
ALLURE_LAUNCH_NAME: Product regression · ${{ github.run_number }}
ALLURE_LAUNCH_TAGS: product-regression,github-actions
# Names only a bootstrap launch; a TestOps launch already has its plan's name.
ALLURE_LAUNCH_NAME: ${{ inputs.testops_plan == 'reliability' && 'Reliability' || 'Product regression' }} · ${{ github.run_number }}
ALLURE_LAUNCH_TAGS: ${{ inputs.testops_plan == 'reliability' && 'reliability' || 'product-regression' }},github-actions
run: node tests/runner-e2e/product-regression-testops-cli.mjs select product-testops/selection.json
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
@@ -516,9 +533,9 @@ jobs:
{
echo "matrix=$(jq -c '{include: .include}' <<< "$catalog_json")"
echo "needs_daytona=$(jq -r '.needsDaytona' <<< "$catalog_json")"
echo "needs_runner_typescript=$(jq -r '[.include[] | select((.profileId == "runner-opencode") or (.profileId | startswith("runner-acpx-")) or (.suiteId == "openrouter-model-breadth"))] | length > 0' <<< "$catalog_json")"
echo "needs_runner_typescript=$(jq -r '[.include[] | select((.profileId | startswith("runner-opencode")) or (.profileId | startswith("runner-acpx-")) or (.suiteId == "openrouter-model-breadth"))] | length > 0' <<< "$catalog_json")"
echo "needs_native_binaries=$(jq -r '[.include[] | select((.profileId | startswith("runner-")) or (.suiteId == "openrouter-model-breadth"))] | length > 0' <<< "$catalog_json")"
echo "needs_remote_provider_pack=$(jq -r '[.include[] | select((.environmentId == "daytona") and ((.profileId == "runner-opencode") or (.profileId | startswith("runner-acpx-"))))] | length > 0' <<< "$catalog_json")"
echo "needs_remote_provider_pack=$(jq -r '[.include[] | select((.environmentId == "daytona") and ((.profileId | startswith("runner-opencode")) or (.profileId | startswith("runner-acpx-"))))] | length > 0' <<< "$catalog_json")"
echo "execution_ids=$(jq -c '.executionIds' <<< "$catalog_json")"
} >> "$GITHUB_OUTPUT"
if ! [[ "$MAX_PARALLEL_LIMIT" =~ ^[1-9][0-9]*$ ]] || [ "$MAX_PARALLEL_LIMIT" -gt 100 ]; then
@@ -541,6 +558,10 @@ jobs:
daytona_image:
name: Publish verified Daytona image
needs: [authorize, target_lock, catalog]
# testops_selection is skipped outside TestOps campaigns. GitHub's implicit
# success() also checks skipped ancestors, so jobs below catalog state
# their real dependency results explicitly.
if: ${{ !cancelled() && needs.catalog.result == 'success' }}
# Keep image builds on the same authorized fleet as campaign execution.
runs-on: ${{ needs.authorize.outputs.test_runner }}
timeout-minutes: 45
@@ -626,9 +647,26 @@ jobs:
[[ "$IMAGE_CONTENT_ID" =~ ^[0-9a-f]{64}$ ]]
[[ "$TARGET_LOCK_SHA256" =~ ^[0-9a-f]{64}$ ]]
identity="^https://github.com/${GITHUB_REPOSITORY}/.github/workflows/runner-full-stack-e2e.yml@"
verify_signed() {
cosign verify \
--certificate-identity-regexp "$identity" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
"$IMAGE_TAG@$1" >/dev/null
}
# Reuse an existing content tag only when its digest carries this
# workflow's signature. A run interrupted between push and sign
# leaves an unsigned tag; rebuilding, pushing and signing replaces it
# instead of failing every later campaign with "no signatures found".
digest=""
if docker buildx imagetools inspect "$IMAGE_TAG" >/dev/null 2>&1; then
digest="$(docker buildx imagetools inspect "$IMAGE_TAG" --format '{{json .Manifest.Digest}}' | tr -d '"')"
else
existing="$(docker buildx imagetools inspect "$IMAGE_TAG" --format '{{json .Manifest.Digest}}' | tr -d '"')"
if verify_signed "$existing" 2>/dev/null; then
digest="$existing"
else
echo "::warning title=Daytona image::Existing $IMAGE_TAG ($existing) has no verifiable workflow signature; rebuilding and signing it"
fi
fi
if [ -z "$digest" ]; then
cache_args=(
--cache-from "type=registry,ref=${IMAGE_CACHE}"
)
@@ -687,7 +725,9 @@ jobs:
build_runner_artifacts:
name: Build reusable runner campaign artifacts
needs: [authorize, target_lock, catalog]
if: github.event_name != 'schedule' || (vars.RUNNER_FULL_STACK_E2E_NIGHTLY_ENABLED == 'true' && vars.RUNNER_FULL_STACK_E2E_OPERATIONS_ENABLED == 'true')
if: >-
!cancelled() && needs.catalog.result == 'success' && inputs.image_only != true &&
(github.event_name != 'schedule' || (vars.RUNNER_FULL_STACK_E2E_NIGHTLY_ENABLED == 'true' && vars.RUNNER_FULL_STACK_E2E_OPERATIONS_ENABLED == 'true'))
# Compile native binaries on the same reviewed image used to execute them,
# avoiding libc/architecture drift between GitHub-hosted and AWS lanes.
runs-on: ${{ needs.authorize.outputs.test_runner }}
@@ -815,7 +855,10 @@ jobs:
name: Build reusable remote provider pack
needs:
[authorize, target_lock, catalog, daytona_image, build_runner_artifacts]
if: github.event_name != 'schedule' || (vars.RUNNER_FULL_STACK_E2E_NIGHTLY_ENABLED == 'true' && vars.RUNNER_FULL_STACK_E2E_OPERATIONS_ENABLED == 'true')
if: >-
!cancelled() && needs.catalog.result == 'success' && needs.daytona_image.result == 'success' &&
needs.build_runner_artifacts.result == 'success' && inputs.image_only != true &&
(github.event_name != 'schedule' || (vars.RUNNER_FULL_STACK_E2E_NIGHTLY_ENABLED == 'true' && vars.RUNNER_FULL_STACK_E2E_OPERATIONS_ENABLED == 'true'))
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
@@ -951,7 +994,11 @@ jobs:
test:
name: ${{ matrix.executionId }}
if: needs.catalog.outputs.operations_cadence == '' && needs.authorize.outputs.evidence_retention == 'actions'
if: >-
!cancelled() && needs.catalog.result == 'success' && needs.daytona_image.result == 'success' &&
needs.build_runner_artifacts.result == 'success' && needs.build_remote_provider_pack.result == 'success' &&
needs.catalog.outputs.operations_cadence == '' && needs.authorize.outputs.evidence_retention == 'actions' &&
inputs.image_only != true
needs:
[
authorize,
@@ -1071,7 +1118,8 @@ jobs:
working-directory: .manual-private-trusted
env:
MANUAL_PRIVATE_IDS: ${{ needs.authorize.outputs.manual_private_ids }}
MANUAL_PRIVATE_APPROVALS_JSON: ${{ vars.RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON }}
PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: v1
MANUAL_DISPATCH_ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }}
TARGET_SHA: ${{ needs.authorize.outputs.target_sha }}
TARGET_ROOT: ${{ github.workspace }}
SELECTED_EXECUTION_ID: ${{ matrix.executionId }}
@@ -1100,7 +1148,8 @@ jobs:
working-directory: .manual-private-trusted
env:
MANUAL_PRIVATE_IDS: ${{ needs.authorize.outputs.manual_private_ids }}
MANUAL_PRIVATE_APPROVALS_JSON: ${{ vars.RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON }}
PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: v1
MANUAL_DISPATCH_ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }}
TARGET_SHA: ${{ needs.authorize.outputs.target_sha }}
TARGET_ROOT: ${{ github.workspace }}
SELECTED_EXECUTION_ID: ${{ matrix.executionId }}
@@ -1150,7 +1199,7 @@ jobs:
test -e "$daytona_root/node_modules/@daytonaio/sdk"
- name: Materialize verified pinned OpenCode executable
if: matrix.environmentId == 'local' && (matrix.profileId == 'legacy-opencode' || matrix.profileId == 'runner-opencode' || matrix.suiteId == 'openrouter-model-breadth')
if: matrix.environmentId == 'local' && (matrix.profileId == 'legacy-opencode' || startsWith(matrix.profileId, 'runner-opencode') || matrix.suiteId == 'openrouter-model-breadth')
run: node packages/paperclip-runner/scripts/materialize-opencode-binary.mjs
- name: Install checksum-verified Grok executable
@@ -1165,7 +1214,7 @@ jobs:
path: runner-e2e-build
- name: Download immutable remote provider pack
if: matrix.environmentId == 'daytona' && (matrix.profileId == 'runner-opencode' || startsWith(matrix.profileId, 'runner-acpx-'))
if: matrix.environmentId == 'daytona' && (startsWith(matrix.profileId, 'runner-opencode') || startsWith(matrix.profileId, 'runner-acpx-'))
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ${{ needs.build_remote_provider_pack.outputs.provider_pack_artifact_name }}
@@ -1174,7 +1223,7 @@ jobs:
- name: Verify and restore campaign outputs
if: startsWith(matrix.profileId, 'runner-') || matrix.suiteId == 'openrouter-model-breadth'
env:
NEEDS_RUNNER_TYPESCRIPT: ${{ matrix.profileId == 'runner-opencode' || startsWith(matrix.profileId, 'runner-acpx-') || matrix.suiteId == 'openrouter-model-breadth' }}
NEEDS_RUNNER_TYPESCRIPT: ${{ startsWith(matrix.profileId, 'runner-opencode') || startsWith(matrix.profileId, 'runner-acpx-') || matrix.suiteId == 'openrouter-model-breadth' }}
NEEDS_NATIVE_BINARY: ${{ startsWith(matrix.profileId, 'runner-') || matrix.suiteId == 'openrouter-model-breadth' }}
run: |
set -euo pipefail
@@ -1194,7 +1243,7 @@ jobs:
fi
- name: Verify and restore remote provider pack
if: matrix.environmentId == 'daytona' && (matrix.profileId == 'runner-opencode' || startsWith(matrix.profileId, 'runner-acpx-'))
if: matrix.environmentId == 'daytona' && (startsWith(matrix.profileId, 'runner-opencode') || startsWith(matrix.profileId, 'runner-acpx-'))
env:
IMAGE_SOURCE_REVISION: ${{ needs.daytona_image.outputs.source_revision }}
run: |
@@ -1214,7 +1263,7 @@ jobs:
packages/paperclip-runner/provider-pack/provider-pack.json >/dev/null
- name: Qualify local provider Node interpreter
if: matrix.environmentId == 'local' && (matrix.profileId == 'runner-opencode' || startsWith(matrix.profileId, 'runner-acpx-') || matrix.suiteId == 'openrouter-model-breadth')
if: matrix.environmentId == 'local' && (startsWith(matrix.profileId, 'runner-opencode') || startsWith(matrix.profileId, 'runner-acpx-') || matrix.suiteId == 'openrouter-model-breadth')
run: |
node <<'NODE'
const fs = require("node:fs");
@@ -1307,7 +1356,7 @@ jobs:
NODE
- name: Prepare pinned Python artifact oracle image
if: (matrix.suiteId == 'everyday-workflows' || matrix.suiteId == 'grok-qualification' || matrix.suiteId == 'grok-subscription-qualification') && (matrix.caseId == 'build-revise' || matrix.caseId == 'delegate-feedback' || matrix.caseId == 'agent-review-handoff' || matrix.caseId == 'hire-reuse' || matrix.caseId == 'recover-controller' || matrix.caseId == 'stop-redirect')
if: matrix.caseId == 'build-revise' || matrix.caseId == 'delegate-feedback' || matrix.caseId == 'agent-review-handoff' || matrix.caseId == 'hire-reuse' || matrix.caseId == 'recover-controller' || matrix.caseId == 'stop-redirect'
run: |
set -euo pipefail
oracle_image='python@sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285'
@@ -1426,7 +1475,8 @@ jobs:
working-directory: .manual-private-trusted
env:
MANUAL_PRIVATE_IDS: ${{ needs.authorize.outputs.manual_private_ids }}
MANUAL_PRIVATE_APPROVALS_JSON: ${{ vars.RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON }}
PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: v1
MANUAL_DISPATCH_ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }}
TARGET_SHA: ${{ needs.authorize.outputs.target_sha }}
TARGET_ROOT: ${{ github.workspace }}
SELECTED_EXECUTION_ID: ${{ matrix.executionId }}
@@ -1443,7 +1493,11 @@ jobs:
test_recurring:
name: ${{ matrix.executionId }}
if: needs.catalog.outputs.operations_cadence != '' && needs.authorize.outputs.target_sha == github.sha && github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
if: >-
!cancelled() && needs.catalog.result == 'success' && needs.daytona_image.result == 'success' &&
needs.build_runner_artifacts.result == 'success' && needs.build_remote_provider_pack.result == 'success' &&
needs.catalog.outputs.operations_cadence != '' && needs.authorize.outputs.target_sha == github.sha && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) &&
inputs.image_only != true
needs: [authorize, target_lock, catalog, daytona_image, build_runner_artifacts, build_remote_provider_pack]
runs-on: ${{ needs.authorize.outputs.test_runner }}
timeout-minutes: ${{ matrix.timeoutMinutes }}
@@ -1460,7 +1514,11 @@ jobs:
test_manual_private:
name: ${{ matrix.executionId }}
if: needs.catalog.outputs.operations_cadence == '' && needs.authorize.outputs.evidence_retention == 'private' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
if: >-
!cancelled() && needs.catalog.result == 'success' && needs.daytona_image.result == 'success' &&
needs.build_runner_artifacts.result == 'success' && needs.build_remote_provider_pack.result == 'success' &&
needs.catalog.outputs.operations_cadence == '' && needs.authorize.outputs.evidence_retention == 'private' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) &&
inputs.image_only != true
needs: [authorize, target_lock, catalog, daytona_image, build_runner_artifacts, build_remote_provider_pack]
runs-on: ${{ needs.authorize.outputs.test_runner }}
timeout-minutes: ${{ matrix.timeoutMinutes }}
@@ -1477,7 +1535,7 @@ jobs:
report:
name: Merge and enforce campaign result
if: always() && !cancelled() && needs.catalog.result == 'success' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
if: always() && !cancelled() && needs.catalog.result == 'success' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && inputs.image_only != true
needs: [authorize, catalog, daytona_image, test, test_recurring, test_manual_private]
outputs:
history_source_ready: ${{ steps.history_source_ready.outputs.ready }}
@@ -1519,7 +1577,8 @@ jobs:
if: needs.authorize.outputs.evidence_retention == 'private'
env:
MANUAL_PRIVATE_IDS: ${{ needs.authorize.outputs.manual_private_ids }}
MANUAL_PRIVATE_APPROVALS_JSON: ${{ vars.RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON }}
PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: v1
MANUAL_DISPATCH_ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }}
TARGET_SHA: ${{ needs.authorize.outputs.target_sha }}
RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET }}
NODE_OPTIONS: ''
@@ -1557,7 +1616,8 @@ jobs:
if: steps.manual_read_credentials_0.outcome == 'success'
env:
MANUAL_PRIVATE_IDS: ${{ needs.authorize.outputs.manual_private_ids }}
MANUAL_PRIVATE_APPROVALS_JSON: ${{ vars.RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON }}
PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: v1
MANUAL_DISPATCH_ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }}
TARGET_SHA: ${{ needs.authorize.outputs.target_sha }}
RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET }}
NODE_OPTIONS: ''
@@ -1588,7 +1648,8 @@ jobs:
if: steps.manual_read_credentials_1.outcome == 'success'
env:
MANUAL_PRIVATE_IDS: ${{ needs.authorize.outputs.manual_private_ids }}
MANUAL_PRIVATE_APPROVALS_JSON: ${{ vars.RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON }}
PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: v1
MANUAL_DISPATCH_ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }}
TARGET_SHA: ${{ needs.authorize.outputs.target_sha }}
RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET }}
NODE_OPTIONS: ''
@@ -1619,7 +1680,8 @@ jobs:
if: steps.manual_read_credentials_2.outcome == 'success'
env:
MANUAL_PRIVATE_IDS: ${{ needs.authorize.outputs.manual_private_ids }}
MANUAL_PRIVATE_APPROVALS_JSON: ${{ vars.RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON }}
PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: v1
MANUAL_DISPATCH_ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }}
TARGET_SHA: ${{ needs.authorize.outputs.target_sha }}
RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET }}
NODE_OPTIONS: ''
@@ -1650,7 +1712,8 @@ jobs:
if: steps.manual_read_credentials_3.outcome == 'success'
env:
MANUAL_PRIVATE_IDS: ${{ needs.authorize.outputs.manual_private_ids }}
MANUAL_PRIVATE_APPROVALS_JSON: ${{ vars.RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON }}
PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: v1
MANUAL_DISPATCH_ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }}
TARGET_SHA: ${{ needs.authorize.outputs.target_sha }}
RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET }}
NODE_OPTIONS: ''
@@ -1681,7 +1744,8 @@ jobs:
if: steps.manual_read_credentials_4.outcome == 'success'
env:
MANUAL_PRIVATE_IDS: ${{ needs.authorize.outputs.manual_private_ids }}
MANUAL_PRIVATE_APPROVALS_JSON: ${{ vars.RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON }}
PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: v1
MANUAL_DISPATCH_ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }}
TARGET_SHA: ${{ needs.authorize.outputs.target_sha }}
RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET }}
NODE_OPTIONS: ''
@@ -1712,7 +1776,8 @@ jobs:
if: steps.manual_read_credentials_5.outcome == 'success'
env:
MANUAL_PRIVATE_IDS: ${{ needs.authorize.outputs.manual_private_ids }}
MANUAL_PRIVATE_APPROVALS_JSON: ${{ vars.RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON }}
PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: v1
MANUAL_DISPATCH_ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }}
TARGET_SHA: ${{ needs.authorize.outputs.target_sha }}
RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET }}
NODE_OPTIONS: ''
@@ -1743,7 +1808,8 @@ jobs:
if: steps.manual_read_credentials_6.outcome == 'success'
env:
MANUAL_PRIVATE_IDS: ${{ needs.authorize.outputs.manual_private_ids }}
MANUAL_PRIVATE_APPROVALS_JSON: ${{ vars.RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON }}
PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: v1
MANUAL_DISPATCH_ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }}
TARGET_SHA: ${{ needs.authorize.outputs.target_sha }}
RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET }}
NODE_OPTIONS: ''
@@ -1774,7 +1840,8 @@ jobs:
if: steps.manual_read_credentials_7.outcome == 'success'
env:
MANUAL_PRIVATE_IDS: ${{ needs.authorize.outputs.manual_private_ids }}
MANUAL_PRIVATE_APPROVALS_JSON: ${{ vars.RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON }}
PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: v1
MANUAL_DISPATCH_ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }}
TARGET_SHA: ${{ needs.authorize.outputs.target_sha }}
RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET }}
NODE_OPTIONS: ''
@@ -1793,7 +1860,8 @@ jobs:
if: steps.manual_report.outcome == 'success'
env:
MANUAL_PRIVATE_IDS: ${{ needs.authorize.outputs.manual_private_ids }}
MANUAL_PRIVATE_APPROVALS_JSON: ${{ vars.RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON }}
PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: v1
MANUAL_DISPATCH_ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }}
TARGET_SHA: ${{ needs.authorize.outputs.target_sha }}
PRIVATE_REPORT_ROOT: ${{ github.workspace }}
NODE_OPTIONS: ''
@@ -1987,7 +2055,8 @@ jobs:
if: always() && steps.manual_report_credentials.outcome == 'success' && steps.manual_cells_downloaded.outcome == 'success'
env:
MANUAL_PRIVATE_IDS: ${{ needs.authorize.outputs.manual_private_ids }}
MANUAL_PRIVATE_APPROVALS_JSON: ${{ vars.RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON }}
PAPERCLIP_MANUAL_DISPATCH_AUTHORITY: v1
MANUAL_DISPATCH_ALLOWED_ACTOR_IDS: ${{ vars.RUNNER_E2E_ALLOWED_ACTOR_IDS }}
TARGET_SHA: ${{ needs.authorize.outputs.target_sha }}
RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET: ${{ vars.RUNNER_E2E_PRIVATE_HISTORY_S3_BUCKET }}
NODE_OPTIONS: ''
@@ -2016,9 +2085,9 @@ jobs:
fi
publish_testops:
name: Publish bound Product regression results to TestOps
name: Publish bound TestOps plan results
needs: [authorize, testops_selection, catalog, report]
if: always() && needs.testops_selection.outputs.job_run_started == 'true'
if: always() && needs.testops_selection.outputs.job_run_started == 'true' && inputs.image_only != true
runs-on: ubuntu-latest
timeout-minutes: 10
environment: runner-e2e-testops-reporting
@@ -2081,7 +2150,7 @@ jobs:
publish_history:
name: Publish S3 history and Pages bundle with declared screenshots
needs: [authorize, catalog, report]
if: always() && needs.catalog.result == 'success' && needs.catalog.outputs.operations_cadence == '' && needs.authorize.outputs.evidence_retention == 'actions' && needs.report.outputs.history_source_ready == 'true'
if: always() && needs.catalog.result == 'success' && needs.catalog.outputs.operations_cadence == '' && needs.authorize.outputs.evidence_retention == 'actions' && needs.report.outputs.history_source_ready == 'true' && inputs.image_only != true
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
@@ -2159,7 +2228,7 @@ jobs:
pages:
name: Publish latest dashboard with declared screenshots
needs: [report, publish_history]
if: always() && needs.report.outputs.history_source_ready == 'true' && needs.publish_history.result == 'success' && vars.RUNNER_FULL_STACK_E2E_PUBLISH_PAGES == 'true'
if: always() && needs.report.outputs.history_source_ready == 'true' && needs.publish_history.result == 'success' && vars.RUNNER_FULL_STACK_E2E_PUBLISH_PAGES == 'true' && inputs.image_only != true
runs-on: ubuntu-latest
permissions:
pages: write
@@ -71,7 +71,6 @@ jobs:
testops_enabled: ${{ steps.mode.outputs.testops_enabled }}
accounting_mode: ${{ steps.mode.outputs.mode }}
retry_count: ${{ steps.mode.outputs.retries }}
controller_lock_sha256: ${{ steps.mode.outputs.controller_lock_sha256 }}
test_runner: ${{ steps.runner.outputs.runner }}
max_parallel_default: ${{ steps.runner.outputs.max_parallel_default }}
max_parallel_limit: ${{ steps.runner.outputs.max_parallel_limit }}
@@ -168,7 +167,6 @@ jobs:
TESTOPS_REQUESTED: ${{ inputs.report_to_testops || inputs.ALLURE_JOB_RUN_ID != '' }}
REQUESTED_ROSTERS: ${{ inputs.rosters }}
RETRIES: ${{ inputs.max_infrastructure_retries || 0 }}
CONTROLLER_POLICY_JSON: ${{ vars.RUNNER_PROTOCOL_EVAL_CONTROLLER_POLICY_JSON }}
TARGET_SHA: ${{ steps.target.outputs.sha }}
run: |
set -euo pipefail
@@ -179,12 +177,6 @@ jobs:
test "$TARGET_SHA" = "$GITHUB_SHA"
test "$GITHUB_RUN_ATTEMPT" = 1
test "$RETRIES" = 0
if ! jq -e '.schema == "paperclip.runner-eval.hosted-policy/v1"' <<< "${CONTROLLER_POLICY_JSON:-}" >/dev/null; then
echo 'Controlled runs require a reviewed RUNNER_PROTOCOL_EVAL_CONTROLLER_POLICY_JSON; no provider work started.' >&2
exit 1
fi
lock_sha="$(jq -er '.controllerLockSha256 | select(type == "string" and test("^[a-f0-9]{64}$"))' <<< "$CONTROLLER_POLICY_JSON")"
echo "controller_lock_sha256=$lock_sha" >> "$GITHUB_OUTPUT"
;;
legacy) test "$EVENT_NAME" = workflow_dispatch ;;
*) exit 1 ;;
@@ -415,15 +407,19 @@ jobs:
- name: Enforce controlled limits against the selected catalog
if: needs.authorize.outputs.accounting_mode == 'controlled'
env:
CONTROLLER_POLICY_JSON: ${{ vars.RUNNER_PROTOCOL_EVAL_CONTROLLER_POLICY_JSON }}
run: |
set -euo pipefail
jq -e 'all(.cells[]; (.provider == "codex" and (.acpxAgent // "") == "" and .driver == "codex_app_server") or (.provider == "acpx" and .acpxAgent == "claude" and .driver == "acpx_runtime"))' runner-protocol-eval-catalog.json >/dev/null || {
echo 'Controlled selection supports native Codex and ACPX Claude only; select their rosters explicitly.' >&2; exit 1;
}
maximum="$(jq -er '.maxCells | select(type == "number" and floor == . and . > 0 and . <= 512)' <<< "$CONTROLLER_POLICY_JSON")"
jq -e --argjson maximum "$maximum" '.cells | length <= $maximum' runner-protocol-eval-catalog.json >/dev/null
# Every selected profile needs a committed allocation. No evals code has run in this job yet.
jq -e --slurpfile defaults packages/paperclip-runner/scripts/controlled-evals/hosted-defaults.json \
'($defaults[0].allocations | map([.provider, (.acpxAgent // ""), .model])) as $allowed | all(.cells[]; [.provider, (.acpxAgent // ""), .model] as $profile | any($allowed[]; . == $profile))' \
runner-protocol-eval-catalog.json >/dev/null || {
echo 'Controlled selection includes a profile without a committed allocation in hosted-defaults.json.' >&2; exit 1;
}
# The campaign size is the selected catalog; the spend cap is enforced when the manifest is prepared.
jq -e '(.cells | length) >= 1 and (.cells | length) <= 512' runner-protocol-eval-catalog.json >/dev/null
- name: Require the chat-report renderer before paid execution
run: |
@@ -549,7 +545,7 @@ jobs:
build_controller:
name: Build trusted controller without private archive access
needs: [authorize, target_lock]
needs: [authorize]
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
@@ -579,26 +575,14 @@ jobs:
with:
version: 9.15.4
- name: Download resolved lockfile for review binding
if: needs.authorize.outputs.accounting_mode == 'controlled'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
artifact-ids: ${{ needs.target_lock.outputs.artifact_id }}
path: ${{ runner.temp }}/runner-controller-lock
- name: Build portable controller in an unprivileged job
if: needs.authorize.outputs.accounting_mode == 'controlled'
id: build
env:
EXPECTED_LOCK_SHA256: ${{ needs.authorize.outputs.controller_lock_sha256 }}
RESOLVED_LOCK_SHA256: ${{ needs.target_lock.outputs.lock_sha256 }}
run: |
set -euo pipefail
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
test "$RESOLVED_LOCK_SHA256" = "$EXPECTED_LOCK_SHA256"
lock="$RUNNER_TEMP/runner-controller-lock/pnpm-lock.yaml"
test "$(sha256sum "$lock" | cut -d ' ' -f 1)" = "$EXPECTED_LOCK_SHA256"
cp "$lock" pnpm-lock.yaml
# The controller installs exactly the committed lockfile of the trusted revision.
git diff --quiet HEAD -- pnpm-lock.yaml
pnpm install --frozen-lockfile --ignore-scripts --store-dir "$RUNNER_TEMP/runner-controller-pnpm-store"
node packages/paperclip-runner/scripts/controlled-evals/build-runtime.mjs \
"$RUNNER_TEMP/runner-protocol-runtime" "$GITHUB_SHA" > "$RUNNER_TEMP/controller-runtime-receipt.json"
@@ -700,21 +684,27 @@ jobs:
- name: Verify copied controller before private preparation
if: needs.authorize.outputs.accounting_mode == 'controlled'
id: controller_lock
env:
EXPECTED_LOCK_SHA256: ${{ needs.authorize.outputs.controller_lock_sha256 }}
RUNTIME_RECEIPT_SHA256: ${{ needs.build_controller.outputs.runtime_receipt_sha256 }}
run: |
set -euo pipefail
node packages/paperclip-runner/scripts/controlled-evals/verify-runtime.mjs "$RUNNER_TEMP/runner-protocol-runtime" "$GITHUB_SHA" "$RUNTIME_RECEIPT_SHA256"
test "$(jq -r '.inputs["pnpm-lock.yaml"]' "$RUNNER_TEMP/runner-protocol-runtime/runtime.json")" = "$EXPECTED_LOCK_SHA256"
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
committed="$(sha256sum pnpm-lock.yaml | cut -d ' ' -f 1)"
test "$(jq -r '.inputs["pnpm-lock.yaml"]' "$RUNNER_TEMP/runner-protocol-runtime/runtime.json")" = "$committed"
echo "sha256=$committed" >> "$GITHUB_OUTPUT"
- name: Compile requests and bind the reviewed operating policy
- name: Compile requests and derive the committed operating policy
if: needs.authorize.outputs.accounting_mode == 'controlled'
id: prepare
env:
PAPERCLIP_PROTOCOL_EVAL_SOURCE_SHA: ${{ needs.authorize.outputs.target_sha }}
PAPERCLIP_PROTOCOL_EVALS_SHA: ${{ needs.authorize.outputs.evals_sha }}
CONTROLLER_POLICY_JSON: ${{ vars.RUNNER_PROTOCOL_EVAL_CONTROLLER_POLICY_JSON }}
PAPERCLIP_RUNNER_EVAL_CONTROLLER_LOCK_SHA256: ${{ steps.controller_lock.outputs.sha256 }}
PAPERCLIP_RUNNER_EVAL_BRAINTRUST_PROJECT_ID: ${{ vars.RUNNER_E2E_BRAINTRUST_PROJECT_ID }}
# Optional campaign spend cap; the committed default applies when unset.
RUNNER_PROTOCOL_EVAL_MAX_USD: ${{ vars.RUNNER_PROTOCOL_EVAL_MAX_USD }}
run: |
set -euo pipefail
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
@@ -723,12 +713,6 @@ jobs:
(cd runner-protocol-build && sha256sum --check runner-protocol-build.tar.gz.sha256)
mkdir runner-protocol-build/extracted
tar -xzf runner-protocol-build/runner-protocol-build.tar.gz -C runner-protocol-build/extracted
policy="$RUNNER_TEMP/runner-controller-policy.json"
POLICY_PATH="$policy" python3 - <<'PYTHON'
import os
with os.fdopen(os.open(os.environ["POLICY_PATH"], os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w") as target:
target.write(os.environ["CONTROLLER_POLICY_JSON"])
PYTHON
worker="/tmp/paperclip-runner-controlled-${GITHUB_RUN_ID}"
python3 .paperclip-evals/evals/paperclip-runner/tools/prepare_controlled_campaign.py \
--catalog runner-protocol-catalog/runner-protocol-eval-catalog.json \
@@ -739,8 +723,7 @@ jobs:
control="$RUNNER_TEMP/runner-protocol-control"
node "$RUNNER_TEMP/runner-protocol-runtime/controlled-evals/hosted.mjs" prepare \
runner-protocol-catalog/runner-protocol-eval-catalog.json "$RUNNER_TEMP/prepared-requests.json" \
"$policy" runner-protocol-build/extracted "$control" > "$RUNNER_TEMP/control-preparation.json"
cp "$policy" "$control/hosted-policy.json"
runner-protocol-build/extracted "$control" > "$RUNNER_TEMP/control-preparation.json"
echo "manifest_sha256=$(jq -r .manifestSha256 "$RUNNER_TEMP/control-preparation.json")" >> "$GITHUB_OUTPUT"
- name: Obtain private archive credentials for transfer steps only
@@ -1294,12 +1277,16 @@ jobs:
fi
exit "$status"
- name: Render the same canonical grid from a public-safe evidence projection
- name: Project safe run records for reporting
if: needs.authorize.outputs.accounting_mode == 'legacy' || needs.authorize.outputs.testops_enabled == 'true'
run: |
node packages/paperclip-runner/scripts/runner-protocol-eval-campaign.mjs sanitize \
--runs-root runner-protocol-merged/runs \
--output runner-protocol-merged/public-runs
- name: Render the same canonical grid from a public-safe evidence projection
if: needs.authorize.outputs.accounting_mode == 'legacy'
run: |
python3 .paperclip-evals/evals/paperclip-runner/tools/eval_program.py report \
--runs-root runner-protocol-merged/public-runs \
--output runner-protocol-merged/public-report \
@@ -1312,11 +1299,24 @@ jobs:
- name: Create the closed TestOps projection inside the report boundary
if: always() && !cancelled() && needs.authorize.outputs.testops_enabled == 'true' && needs.testops_selection.result == 'success'
id: testops_projection
env:
ACCOUNTING_MODE: ${{ needs.authorize.outputs.accounting_mode }}
run: |
mkdir -m 700 runner-protocol-testops-projection
node packages/paperclip-runner/scripts/runner-protocol-testops.mjs export \
runner-protocol-testops-selection/runner-protocol-testops-selection.json \
runner-protocol-merged/public-report runner-protocol-testops-projection/results
case "$ACCOUNTING_MODE" in
controlled)
node packages/paperclip-runner/scripts/runner-protocol-testops.mjs export-runs \
runner-protocol-testops-selection/runner-protocol-testops-selection.json \
runner-protocol-merged/campaign.json runner-protocol-merged/public-runs \
runner-protocol-testops-projection/results
;;
legacy)
node packages/paperclip-runner/scripts/runner-protocol-testops.mjs export \
runner-protocol-testops-selection/runner-protocol-testops-selection.json \
runner-protocol-merged/public-report runner-protocol-testops-projection/results
;;
*) exit 1 ;;
esac
- name: Transfer only the closed TestOps results and receipt
if: always() && steps.testops_projection.outcome == 'success'
+1 -1
View File
@@ -66,4 +66,4 @@ jobs:
env:
NODE_PATH: ${{ runner.temp }}/sentry-contract-sdk/node_modules
PAPERCLIP_REQUIRE_SENTRY_TEST_SDK: "1"
run: pnpm --filter @paperclipai/server exec vitest run src/__tests__/run-failure-sentry-real-sdk.test.ts
run: pnpm --filter @paperclipai/server exec vitest run src/__tests__/run-failure-sentry-real-sdk.test.ts src/__tests__/sentry-dynamic-import-provenance.test.ts
+1
View File
@@ -70,6 +70,7 @@ diagnostics/
tests/e2e/test-results/
tests/e2e/playwright-report/
tests/runner-e2e/results/
tests/restart-chaos/results/
.env.runner-e2e.local
tests/release-smoke/test-results/
tests/release-smoke/playwright-report/
+26
View File
@@ -94,6 +94,32 @@
"packages/paperclip-runner/src/providers/**",
"packages/paperclip-runner/protocol/**"
]
},
{
"path": "rules/ui-render-cost.mdc",
"description": "Render cost of UI components and hooks over large collections: no nested scans, stable memo dependencies, and realistic-size tests for changed large-collection paths.",
"scope": [
"ui/src/**/*.tsx",
"ui/src/**/use*.ts"
]
},
{
"path": "rules/upgrade-safe-run-state.mdc",
"description": "Upgrade safety for persisted run, session, launch, and pack state: state saved by the previous release resumes or fails with a permanent, named code, with old-saved/new-resumed and mid-run deploy tests.",
"scope": [
"server/src/services/native-runtime/**",
"server/src/services/recovery/**",
"server/src/services/heartbeat/session-preparation.ts",
"server/src/services/heartbeat/run-state.ts",
"server/src/services/runner-revision-mismatch.ts",
"server/src/services/sandbox-provider-runtime.ts",
"packages/paperclip-runner/**",
"packages/adapter-utils/src/acpx-engine/**",
"docker/daytona-runner/**",
"packages/db/src/schema/native_*.ts",
"packages/db/src/schema/agent_task_sessions.ts",
"packages/db/src/schema/run_identity_contexts.ts"
]
}
]
}
+2
View File
@@ -12,3 +12,5 @@ referenced file as the source of truth rather than maintaining a separate copy
in dashboard instructions.
Do not add package.json scripts for new utilities, checks, benchmarks, or specialized tests.
The shared `verify` entry point is the repository-wide exception. Keep new
specialized checks as direct script invocations.
+40 -5
View File
@@ -45,6 +45,10 @@ For ACPX provider profiles, their release attestations and pins, the provider-pa
contract registry, and ACPX driver and provider code under
`packages/paperclip-runner/`, read
[`rules/acpx-profile-identity.mdc`](rules/acpx-profile-identity.mdc).
For native runtime, recovery, session preparation, runner, ACPX engine, and
provider-pack or sandbox image code that persists or resumes run state, and for
the native run, checkpoint, and session schema in `packages/db`, read
[`rules/upgrade-safe-run-state.mdc`](rules/upgrade-safe-run-state.mdc).
See [`rules/README.md`](rules/README.md) for rule authoring and Greptile setup.
## 3. Repo Map
@@ -198,13 +202,34 @@ ignored; never commit aggregate catalogs, refresh review fingerprints, or append
a shared review journal. Use Git diffs and keep review evidence in the PR or issue.
Structural checks are not production acceptance.
Default local/agent test path:
Prepare a new or updated worktree with `node scripts/agent-preflight.mjs` instead of running
`pnpm install`, the Runner build and `build:rust` by hand. It skips unchanged
steps and reuses other worktrees' Runner builds, debug binaries and third-party
crates from a content-keyed cache in `~/.cache/paperclip/agent-build`. Do not
share `CARGO_TARGET_DIR` between worktrees. See "Agent worktree preflight" in
`doc/DEVELOPING.md`.
Default local/agent verification path:
```sh
pnpm test
pnpm verify
```
This is the cheap default and only runs the Vitest suite. Browser suites stay opt-in:
This selects affected Vitest files with the same conservative dependency policy
as CI. It includes committed, staged, unstaged, and untracked changes against the
merge base with local `origin/master` (or `master`). Use `--base <ref>` for another
base and `--explain` to inspect the selection. Passing checks with complete input
graphs can be reused within this worktree. Opaque integration checks run fresh.
Source, test, fixture, configuration, dependency, environment, and toolchain changes
invalidate matching results. Failed or interrupted attempts are never passes.
Exit 2 means the change needs broader verification: run focused checks and let
required CI supply that coverage, or explicitly use `pnpm verify --full` for the
full local Vitest suite. Do not automatically follow exit 2 with a full local run.
Use `--no-cache` to request fresh execution without reading or saving passes.
`pnpm test` and `pnpm test:run` retain their full-suite behavior.
Browser suites stay opt-in:
```sh
pnpm test:e2e
@@ -215,7 +240,17 @@ Run the browser suites only when your change touches them or when you are explic
For normal issue work, run the smallest relevant verification first. Do not default to repo-wide typecheck/build/test on every heartbeat when a narrower check is enough to prove the change.
Run this full check before claiming repo work done in a PR-ready hand-off, or when the change scope is broad enough that targeted checks are not sufficient:
For a PR-ready handoff, run the relevant local checks and require the applicable
CI typecheck, build, and test checks to pass for the final tested revision. Record
their scope, revision, environment, and result links. Fresh CI can supply broad
coverage; do not duplicate a full local run merely to prepare a PR, resume a chat,
or repeat unchanged passing verification. A selected local pass does not replace
required CI, release qualification, browser acceptance, or a local/platform-specific
regression. After edits or a rebase, rerun affected checks and invalidate evidence
whose inputs or required coverage changed.
Use broad local checks when they resolve an actual remaining gap, such as a local
platform fault or explicit full-suite qualification:
```sh
pnpm -r typecheck
@@ -264,7 +299,7 @@ When creating a pull request (via `gh pr create` or any other method), you **mus
A change is done when all are true:
1. Behavior matches `doc/SPEC-implementation.md`
2. Typecheck, tests, and build pass
2. Relevant local verification and required CI typecheck, test, and build checks pass for the final revision, with any acceptance or platform-specific gaps resolved
3. Contracts are synced across db/shared/server/ui
4. Docs updated when behavior or commands change
5. PR description follows the [PR template](.github/PULL_REQUEST_TEMPLATE.md) with all sections filled in (including Model Used)
+6 -1
View File
@@ -111,7 +111,12 @@ Every PR must include a **Model Used** section specifying which AI model produce
### Tests Must Pass
All tests must pass before a PR can be merged. Run them locally first and verify CI is green after pushing.
All required checks must pass before a PR can be merged. Run relevant local checks
with `pnpm verify`, then require applicable typecheck, build, and test CI checks on
the final tested revision. Fresh CI can supply broad coverage. Run a full local
suite when it resolves a remaining local/platform-specific gap or is explicitly
required for qualification. See `AGENTS.md` and `doc/DEVELOPING.md` for result reuse
and coverage requirements.
### Telemetry Changes
+81 -7
View File
@@ -95,6 +95,16 @@ missing photos use the shared initials fallback. Match ownership by the stored
author user ID and the authenticated viewer ID, never by display name or the bot
that relayed a message from another channel.
Images produced in a task turn appear as compact thumbnails in a wrapping grid.
Keep the first image's row and each tile mounted as more results arrive. Documents,
video, and authored messages retain their chronological positions and separate
image batches. Open thumbnails in the existing gallery, with keyboard navigation
and downloads. Images without an originating run stay separate. Generated native
image preparation receipts are redundant with those tiles; identify them by the
attachment-preparation marker and links to the image, author, and originating run,
independent of receipt wording or image title. Older receipts use their exact
historical preparation sentence as a fallback. Authored progress text stays visible.
## Contextual feedback
Keep the runner turn's avatar, name, status header and current-activity slot
@@ -153,7 +163,7 @@ results in place; keep failures actionable inline. Notifications for other work
remain useful. Repeated delivery of the same run outcome must refresh cached state
without repeating its toast, including after reconnecting. A terminal outcome
delivered more than five minutes after the run finished is historical and should
refresh state silently. Expected cancellation is neutral gray, not an error. The composer's Stop action stops the current response and leaves the composer available for a new message. Pause work is a separate explicit task or subtree action. A paused task replaces the composer with an amber takeover. It says “Task is
refresh state silently. Expected cancellation is neutral gray, not an error. A busy workspace is a transient system wait with no user action. Show it only as a quiet status in the composer strip and the Workspace property row, never as a pinned banner, conversation notice or toast. The composer's Stop action stops the current response and leaves the composer available for a new message. Pause work is a separate explicit task or subtree action. A paused task replaces the composer with an amber takeover. It says “Task is
paused.” and “Resume this task to send a message.” with a “Resume task” action.
Subtrees use “Subtree is paused.” and “Resume subtree.” The takeover cannot be
dismissed, retains drafts, and hides message inputs until the pause is released.
@@ -239,8 +249,8 @@ semantic tokens.
Review **Design proposals → Foundations → Typography → Type specification**
(`design-foundations-typography--type-specification`). The proposal and the
opt-in design sandbox use Inter Variable for interface text and bundled Paper
Mono for all monospace text, including IDs, code and run diagnostics.
opt-in design sandbox use Inter Variable for interface text. All product
monospace text uses bundled Paper Mono, including IDs, code and run diagnostics.
**Conversation styles** (`design-foundations-typography--conversation-styles`)
shows the same composition as the sandbox task detail page by mounting
@@ -407,9 +417,12 @@ Project, Parent Task, Tags, Worktree, Last updated**. The reusable
width tokens and configurable column order. Review the complete composition in
**Design proposals → Components → Task list → All columns**.
- Created by is the recorded creator (human or agent); Assignee is the current
assignee. Both show names without avatars. Do not infer either from tree depth
or task status.
- Created by is the recorded creator (human or agent) and shows a name. Assignee
shows the current agent's avatar or human's profile photo beside their name,
with the shared avatar fallback when an image is unavailable. Human photos load
only from Paperclip asset URLs; external profile URLs use initials. The avatar stays
visible while a long name reveals on hover or focus. Do not infer either
identity from tree depth or task status.
- Task combines the status icon and title under one header and one Columns-menu
choice. Sorting Task uses the title. Status sorting is unavailable for now.
- Parent Task refers to the structural parent; it can show its ID and title.
@@ -951,7 +964,7 @@ Standard. Artifact type filters also support Compact for dense standalone uses.
patterns. Tabs contains only Playground, Standard and Compact. Playground
compares the two sizes with local fixture state; narrow width remains a control.
Accepted toolbar foundation for Design Proposals and the opt-in sandbox:
Accepted toolbar foundation for all production inline collection and audit toolbars:
- Use the shared `ToolbarSearch` and 44px control height, with one token-driven
border, radius and horizontal padding across Tasks, Decisions, Skills and
@@ -977,6 +990,31 @@ Further consolidation proposals, pending visual review:
Keep panel-switching tab semantics separate from exclusive collection
filters. Closable document tabs retain their additional behavior.
### Collection empty states and roster selection
Projects uses the large artifact stack with a dark grey Paperclip mark on an off-white
image front. The empty-state action is “Create project”. Artwork, message and
primary action have 20px gaps (`gap-5`). Hovering or keyboard-focusing the action
fans the same artwork as hovering the graphic. Artifacts uses that stack for
Tasks and the corresponding single full-size artifact for each other type filter.
Agent lists give chat and status their own vertically centered cells. Chat has a
screen-reader-only column label and a MessageCircleCheck icon action. Hover and
focus highlight the entire row; the identity link has no separate background.
Bulk actions portal to a fixed card centered in the viewport, 48px above the
bottom safe area, with the shared border and a soft shadow. The card retains its
contents during the quintic rise/drop transition; reduced motion removes travel.
Selection never inserts space above the roster.
Agent Overview's Profile uses the bulk-edit avatar/name chips for reporting
relationships. Within raised modules, their background uses the Skills chip
surface so they remain distinct from the module. Agent chips have hover/focus
states and link to the agent's overview. Reports to precedes Direct reports:
No manager opens the manager picker; an assigned manager has a linked chip and
an adjacent edit control. The picker disables self/descendant choices to prevent
reporting loops. The editable heading sizes to its live text so the primary-agent
crown stays beside the name throughout editing.
### Segmented controller comparison
`Design proposals / Components / Segmented controllers` owns the segmented
@@ -1024,3 +1062,39 @@ a visible Reload page action. Keep the character inline and embed the marked
`offline-recovery` section from `ui/src/index.css` at build time so the page needs
no network requests or cached assets. Preserve the supplied character palette
through the offline tokens.
### Agent overview and Identity editing
Agent headers lead with the 24px custom title and crown on one line, followed by “harness • name.” The harness logo and label link to the agent’s Harness / Runtime subtab with hover and keyboard-focus feedback. The logo has a 4px gap before its label; Chat and New task share the default 40px button height. Header spacing is reduced by 4px at each vertical edge.
The character renders in a 102px square (20% smaller than 128px, rounded to a whole pixel) with an 8px gap before the title, without CSS transform scaling. Reporting relationships live in Profile; its creation date shares the
header metadata position with the Skills count. Empty Direct reports sections
are hidden. The overview character tracks the page cursor, including paused
agents, while respecting reduced-motion and custom-avatar fallbacks. Chat sits
beside the text-only New task action. Recent tasks applies its raised surface to
the table, leaving the section heading on the page background.
Identity panels share 24px insets and gutters from the token layer. Advanced
management contains the external-profile controls. New files accept a portable
basename (letters, numbers, hyphens and underscores) with a fixed `.md` suffix.
Clicking file content enters editing without creating a dirty draft. The Identity
save footer slides from the viewport bottom only for unsaved changes or a save
in progress; undoing or discarding changes hides it. Successful saves swap the
orange-dot unsaved label for a rising green-dot saved label, hold it for two
seconds after arrival, then dismiss the footer. Copy feedback appears beside
the edit action for five seconds and then fades. File rows use monospaced,
single-line truncated names, 4px row spacing, a filled ROOT chip, and borderless
size labels. Hover uses the shared menu-option radius and muted highlight fill
with foreground text. The preview title uses 18px Paper Mono at medium (500) weight. Creation dates come from
recorded new-file saves; existing files without creation metadata show their
last modification date. Disclosure panels animate height and opacity and respect
reduced motion.
Agent roster rows reveal a muted MessagesSquare icon and “Chat” action on row
hover, using the task-list inline action treatment. Keyboard focus also reveals
the action; its reserved cell prevents layout movement. Its tooltip waits for
Radix’s standard 700ms hover delay on each entry, without the instant-tooltip
provider or warm-up shortcut; keyboard focus retains immediate accessible help.
Task-list titles use the remaining row width before truncating. Empty recovery,
external-status, and metadata slots do not reserve space; populated details and
trailing actions retain their existing layout.
+2 -2
View File
@@ -134,9 +134,9 @@ As companies grow, agents should be able to propose useful structural changes su
Paperclip should get better at turning completed work into reusable organizational knowledge. That includes capturing playbooks, recurring fixes, and decision patterns so future work starts from what the company has already learned.
### 🟡 Agent Chat (including CEO Chat)
### ✅ Agent Chat (including CEO Chat)
Experimental Agent Chat provides persistent conversations with any agent, including leadership. Conversations keep their history and plans, then hand execution off to linked tasks. Agent Chat is off by default. Experimental chat and email connectors provide additional entry points through configured external services; they have separate setup and access controls.
Agent Chat provides persistent conversations with any agent, including leadership. Conversations keep their history and plans, then hand execution off to linked tasks. Agent Chat is available to everyone. Experimental chat and email connectors provide additional entry points through configured external services; they have separate setup and access controls.
### 🟡 Cloud deployments
@@ -249,6 +249,16 @@ async function runCliJson<T>(
return JSON.parse(stdout.slice(jsonStart)) as T;
}
// `paperclipai run` loads the CLI and the whole server graph from source
// through tsx. The test runner gives every run a fresh TMPDIR, so tsx's
// transform cache is always cold: startup then costs about twice a warm start
// (11.5 s against 5.2 s from "Starting Paperclip server..." to the first server
// log on an idle 16-core machine). On contended 4-vCPU CI runners the hooks of
// passing runs took 15-25 s, and the old 30 s budget failed in the tail. This budget
// matches the 90 s test-body timeout; a child that exits early still fails at
// once.
const SERVER_STARTUP_TIMEOUT_MS = 90_000;
async function waitForServer(
instanceId: string,
runtimeInfoPath: string,
@@ -256,7 +266,7 @@ async function waitForServer(
output: { stdout: string[]; stderr: string[] },
) {
const startedAt = Date.now();
while (Date.now() - startedAt < 30_000) {
while (Date.now() - startedAt < SERVER_STARTUP_TIMEOUT_MS) {
if (child.exitCode !== null) {
throw new Error(
`paperclipai run exited before healthcheck succeeded.\nstdout:\n${output.stdout.join("")}\nstderr:\n${output.stderr.join("")}`,
@@ -342,7 +352,8 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
output,
);
expect(new URL(apiBase).port).not.toBe(String(port));
}, 60_000);
// Room for the embedded database start on top of the server budget.
}, SERVER_STARTUP_TIMEOUT_MS + 30_000);
afterAll(async () => {
await stopServerProcess(serverProcess);
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { deflateRawSync } from "node:zlib";
import { crc32, deflateRawSync } from "node:zlib";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -73,12 +73,7 @@ function buildDeflateZip(entryPath: string, text: string): Uint8Array {
const raw = Buffer.from(text, "utf8");
const body = deflateRawSync(raw);
const name = Buffer.from(entryPath, "utf8");
let crc = 0xffffffff;
for (const byte of raw) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) crc = (crc & 1) === 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
}
crc = (crc ^ 0xffffffff) >>> 0;
const crc = crc32(raw);
const local = Buffer.alloc(30 + name.length);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
+45
View File
@@ -1,3 +1,7 @@
import { randomBytes } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Agent, CompanySecret } from "@paperclipai/shared";
@@ -262,6 +266,47 @@ describe("secrets CLI helpers", () => {
expect(result.message).toContain("prod-us-1");
expect(result.message).toContain("AWS_PROFILE/shared config");
});
describe("local encrypted key file permissions", () => {
let tempDir: string;
let keyFilePath: string;
beforeEach(() => {
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-secrets-check-"));
keyFilePath = path.join(tempDir, "master.key");
fs.writeFileSync(keyFilePath, randomBytes(32).toString("base64"), "utf8");
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = keyFilePath;
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(tempDir, { recursive: true, force: true });
});
it.skipIf(process.platform === "win32")(
"warns when the key file is readable by group or others",
() => {
fs.chmodSync(keyFilePath, 0o644);
const result = secretsCheck(configWithSecretsProvider("local_encrypted"));
expect(result.status).toBe("warn");
expect(result.message).toContain("key file permissions are 644");
},
);
it("does not warn about POSIX permission bits on Windows", () => {
fs.chmodSync(keyFilePath, 0o666);
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const result = secretsCheck(configWithSecretsProvider("local_encrypted"));
expect(result.status).toBe("pass");
expect(result.message).not.toContain("chmod 600");
expect(result.repairHint).toBeUndefined();
});
});
});
describe("secrets API parity commands", () => {
@@ -31,6 +31,7 @@ describe("service definition generation", () => {
const unit = renderSystemdUnit({ instanceId: "team-a", shimPath: "/home/alice/.local/bin/paperclipai", homeDir: "/home/alice/.paperclip" });
expect(unit).toContain("Type=notify");
expect(unit).toContain("NotifyAccess=all");
expect(unit).toContain("KillMode=mixed");
expect(unit).toContain('ExecStart="/home/alice/.local/bin/paperclipai" run --instance "team-a"');
expect(unit).toContain("Restart=always");
expect(unit).toContain("TimeoutStopSec=300");
+3 -1
View File
@@ -141,9 +141,11 @@ export function secretsCheck(config: PaperclipConfig, configPath?: string): Chec
};
}
// Windows has no POSIX permission bits: stat() reports 0o666 for any writable
// file and chmod cannot restrict it, so this check would always warn there.
const keyMode = fs.statSync(keyFilePath).mode & 0o777;
const permissionWarning =
(keyMode & 0o077) !== 0
process.platform !== "win32" && (keyMode & 0o077) !== 0
? `; key file permissions are ${keyMode.toString(8)} (run chmod 600 ${keyFilePath})`
: "";
+2 -11
View File
@@ -1,3 +1,5 @@
import { crc32 } from "node:zlib";
// The node-side portability zip reader lives in @paperclipai/shared so the
// server can consume the same codec (a raw uploaded zip is unzipped into the
// exact `{ rootPath, files }` bundle the inline import source carries). This
@@ -31,17 +33,6 @@ function writeUint32(target: Uint8Array, offset: number, value: number) {
target[offset + 3] = (value >>> 24) & 0xff;
}
function crc32(bytes: Uint8Array) {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc & 1) === 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
}
}
return (crc ^ 0xffffffff) >>> 0;
}
/**
* Build a stored (uncompressed) zip of `files` under a single `rootPath/`
* top-level directory — the layout `readZipArchive` folds back into the
+1
View File
@@ -134,6 +134,7 @@ StartLimitBurst=5
[Service]
Type=notify
NotifyAccess=all
KillMode=mixed
ExecStart="${escapeSystemd(input.shimPath)}" run --instance "${escapeSystemd(input.instanceId)}"
Environment="PAPERCLIP_SERVICE_MANAGED=1"
Environment="PAPERCLIP_INSTANCE_ID=${escapeSystemd(input.instanceId)}"
+16
View File
@@ -191,6 +191,22 @@ A disabled or removed plugin cannot silently release an existing requirement.
Restore that plugin to complete the operation.
A later plugin installation does not change an existing selection.
A plugin with an environment driver that declares `requiresAgent: true` is
selected only for an agent on that environment. The host uses the agent's default
environment, or the instance default when the agent has none. Other lifecycle
plugins are selected for all agents, including a sandbox provider that declares
`requiresAgent: true`.
When an agent moves to such an environment, the host adds the plugin at the next
`preparing` or `resuming` step. An agent in `ready` cannot move to it until it is
paused, because the plugin must finish its work before the agent uses that
environment. An agent in `verifying` returns to `preparing`. An agent that moves away keeps the selection until it is terminated,
so the plugin keeps its resources until then.
Saving an environment or the instance default that gives such a plugin to `ready`
or `verifying` agents without that selection returns a conflict. Pause those agents,
save the change, and then resume them.
Each request contains these fields:
- `companyId` and `agentId` identify the agent.
+128
View File
@@ -72,6 +72,81 @@ failed results and recovery blockers stay unchanged. A successful retained
session without artifact proof still blocks migration. The migration receipt
reports failed-session archives separately from imported recovery authorities.
An internal maintenance owner can separately record
`runnerProfileJson.nativeHarnessFilesystemIndependence` when an original-volume
inspection finds no uncovered provider or Runner payload. This is not a backup
or a successful recovery. It requires a fresh, independently reviewed provider
attachment witness and a private nonce anchor on that original volume. The
anchor survives normal releases; current deployment and mount identities are
verified afresh. Replacement hosts cannot create the receipt from missing files.
Publication binds the retained session, canonical newest resume source, frozen
inputs and checkpoints, every associated lease and destroyed-provider receipt,
released controller/finalizer ownership, and existing immutable skill references.
Any surviving skill files must match their admitted package bytes and modes.
Surviving controller and supervisor files require separate authenticated raw-byte
archives through the existing encrypted backup ledger. This evidence-only path
also accepts a settled successful source whose controller records match its
durable checkpoint. Its `original_volume_source_evidence` manifest reason does
not grant provider recovery or Runner execution authority. Publication checks
the source bytes again and retains the first authenticated archive during a
concurrent capture. The receipt binds the exact archive row and checkpoint parts.
Retired authority epochs can use the same evidence archive with a closed
inventory of their original controller and suspended Runner files, bytes and
modes. Each epoch must match a retained same-session run and its canonical
checkpoint identity when a header exists. A genuinely absent historical header
instead requires the exact admitted authority binding and remains explicitly
absent. Malformed, retired, or foreign headers cannot use this path. Current
source checkpoint and artifact requirements remain unchanged. Source fences
recheck the complete inventory before archive and receipt publication. Unknown
children and other nonempty history directories remain blockers. The inventory
and historical headers or their explicit absence stay bound in replacement
validation. History-free manifests and receipts keep their exact
existing shape; current-controller-only evidence remains retained when a later
archive adds historical files. These archives do not become executable backups.
This maintenance inventory is limited to 128 entries, 16 MiB per file and 64 MiB
total. These bounds are intentionally smaller than the Runner control-plane
state's 256 MiB maximum. A larger otherwise valid epoch remains on the original
volume and blocks filesystem independence; it is never truncated or waived.
Source locks and a final comparison fence publication; original files, stamps,
warnings, failed statuses and session pointers remain unchanged. Startup can
validate the published receipt using only database bindings, without the old
volume, a fresh source witness, or storage reads. Changed or missing bindings
invalidate it. Existing resume and replacement policies remain authoritative,
including governed-wait failures and warm-session refusals. No provider history
is reconstructed or declared recovered.
The same original-volume maintenance owner has a separate `web_evidence` mode.
It stores a `paperclip.native-harness-web-evidence.v1` receipt. This mode proves
that the web server's retained files have durable byte coverage. It does not
require an external VM's termination receipt and does not settle that VM's
recovery obligation. It requires the same settled web controller, finalizer,
task and physical-writer fences. The encrypted `original_volume_source_evidence`
archive includes a closed inventory of all nonexcluded files under the canonical
session root, the separate warm supervisor, and each admitted skill manifest.
Files retain their bytes and modes, including local edits and a skill whose
version row was deleted. Such a skill remains forensic evidence: the owner does
not recreate its version or make it executable. Canonical credential exclusions
stay excluded. Unknown directories, symlinks, incomplete manifests, active web
writers and changed source facts fail closed. The same 128-entry, 16-MiB-file and
64-MiB-total limits apply independently to this inventory.
A genuinely absent raw controller stays absent. A supervisor-only archive still
requires the exact durable controller checkpoint and source identity. Old
terminated-provider receipts and history-free manifests keep their existing
shape. External lease status and cleanup observations can change without
creating a new web filesystem dependency. Allocation, stamp, selected source,
checkpoint and admitted-input changes invalidate coverage. VM references,
recovery actions, warnings and continuation policies stay unchanged. Replacement
validation uses only the published archive and database bindings; it needs no
original-volume anchor or filesystem read. These validations add bounded
per-retained-scope database reads during startup. They do not add a worker or a
poll. Missing or invalid byte evidence remains an enrollment blocker.
Receipt validation adds bounded per-scope database reads to startup eligibility.
It does not add a recurring scan or network work. Qualification must measure the
selected cohort's startup latency on the serving revision before enrollment.
### Proactive agent chat messages
Migration `0419_slippery_impossible_man.sql` adds an agent-scoped unique constraint
@@ -248,6 +323,18 @@ indexes concurrently and can resume an interrupted build without blocking run
writes for the full build. The indexes add storage and run-write overhead; they
do not change task visibility or secret redaction.
The task Context row reads the agent's latest context-window reading through
two small partial indexes on `heartbeat_run_events`. One index covers only usage
items, by run and time. The other index covers only `context.compacted` events,
by company and normalized session. Migration 0432 builds both indexes
concurrently. A poll therefore reads a few index entries, not a run's full event
history.
A subtree pause release finds the runs that a dispatch gate refused for that pause
through the partial index `heartbeat_runs_tree_hold_suppression_idx`. The index
covers only cancelled runs with a `treeHoldSuppression` marker, keyed by company and
hold. Migration 0436 builds it concurrently.
The chat wake-notice sweep compares UUID columns directly against guarded,
canonical lowercase UUID payload values. Malformed and noncanonical payloads
still match nothing. The query can use existing primary keys without adding a
@@ -859,6 +946,36 @@ with `NOWAIT`. A real `55P03` aborts the publication transaction and reports
archive and local files remain available for a later administrative retry. Other
database errors propagate. No automatic retry or lock-strength reduction occurs.
Offline import can preserve an active task recovery hold after its exact native
workspace reference is finalized. It checks the terminal run, released controller
and coordinator ownership, task relationship, and any recorded file allocations.
An accepted result with a retrying coordinator also needs the successful
company/run/task `workspace_finalize` receipt consumed by the normal finalizer.
Historical receipts can have no workspace foreign key. Import retains recovery
rows, task pointers, results and coordinator phases. It does not establish provider
termination or resolve the hold. It locks and rechecks the saved evidence before
publication. An unrelated historical folder keeps the obligation on its real
source workspace.
Finalized file sources receive a separate `finalizedWorkspaceRecoveryMigration`
run marker and an exact migration-operation receipt. They do not receive the
legacy execution-replay suppression marker. Future recovery exports an immutable
snapshot of the recorded allocation instead of merging into the retired local
folder. The existing source, provider-stop and archive checks still apply.
Completed copyback recovery receipts remain valid. Unknown or unverified recovery
sources continue to block import.
Online import can preserve an established provider session's prepared source
after a terminal provider-artifact failure. It requires the exact board-owned
native failure block and the task's current blocked status version, released
controller and coordinator ownership, and all declared allocation bindings.
Source locks and the finalizer lock protect the full action, task and source
fingerprints through publication. Import saves only the current local files. It
retains the prepared reference, provider checkpoint, remote allocations and
recovery hold. The existing migration recipe routes later file recovery away
from the retired local destination. This neither declares preexecution nor
proves remote export, provider termination or permission to resume execution.
The workspace fingerprint pins the recorded source. An arriving reservation can
reach a legacy workspace before refusing it, so publication must follow this
order too. The lifecycle advisory lock alone does not fence runtime operations.
@@ -1901,3 +2018,14 @@ pre-upgrade database transactions before enabling independent writers. Do not ru
pre-upgrade synchronous counter/evaluation writers beside unlocked writers.
Rollback must use a compatible version that reads the ledger and retains the
durable evaluation feed; a pre-contract binary is not a safe rollback target.
## Standard workspace and Classic opt-in
Migration `0433_workspace_defaults.sql` adds `classic_workspace_enabled`, default
false, to personal workspace preferences and sets the legacy Coordinator column's
default to true. Existing rows retain their old Coordinator experiment values,
but those values no longer select the active workspace. API reads derive
`coordinatorWorkspaceEnabled` from the inverse of the Classic choice. Saves
retain the existing account and company-audit checks. Replaying the migration
preserves explicit Classic choices. Agent Chat ignores its retired stored flag
without rewriting instance settings. No task, agent, or execution state changes.
+610 -22
View File
@@ -132,11 +132,26 @@ Current implementation status:
## Dependency Lockfile Policy
GitHub Actions owns `pnpm-lock.yaml`.
GitHub Actions owns every managed pnpm lockfile:
- Do not commit `pnpm-lock.yaml` in pull requests.
- Pull request CI validates dependency resolution when manifests change.
- Pushes to `master` regenerate `pnpm-lock.yaml` with `pnpm install --lockfile-only --no-frozen-lockfile`, commit it back if needed, and then run verification with `--frozen-lockfile`.
- `pnpm-lock.yaml` at the repository root
- `scripts/typecheck-sandbox-providers/pnpm-lock.yaml`, for the sandbox providers
- `packages/plugins/sandbox-providers/daytona/pnpm-lock.yaml`
`LOCKFILES` in `.github/scripts/lockfile-trust.mjs` lists them.
- Do not commit any of them in pull requests. Only the lockfile refresh workflow
and verified Dependabot commits may change them.
- Pull request CI validates dependency resolution when manifests change. A stale
lockfile is resolved in place for that run.
- Pushes to `master` run `.github/workflows/refresh-lockfile.yml`. It regenerates
each lockfile with `pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile`
and opens one `chore/refresh-lockfile` pull request for all of them. See
`doc/RELEASE-AUTOMATION-SETUP.md` for when the bot merges it.
The Pi and Kimi distribution lockfiles under `packages/paperclip-runner/scripts/`
are not on this list. They hold attested provider pins, so change them by hand
(see `rules/acpx-profile-identity.mdc`).
## Trusted PR Workflow
@@ -171,6 +186,19 @@ Authorized test jobs and the final `verify` and `e2e` aggregates use the selecte
AWS fleet. A stale admission roster can allocate only the short metadata gate;
the fresh CODEOWNERS check still denies PR execution for a removed owner.
AWS runners are sometimes lost mid-job. `ci-runner-loss-rerun.yml` re-runs the
failed jobs of a PR's first CI attempt once when every failed job lost its
runner. A lost runner is recognized by the check-run annotation "lost
communication with the server" or "received a shutdown signal". When the only
annotation is "The operation was canceled.", the job log is checked for the
same messages. The `verify` and `e2e` aggregates are not counted. Any other
failure, a failed routing gate, or a PR whose head has moved prevents the
automatic re-run. The workflow job summary lists each failed job and its
evidence. Like a maintainer's "Re-run failed jobs", the re-run reuses the
gate's successful outputs, including its AWS admission. Attempt 2's
triggering actor is `github-actions[bot]`, so `profile-ci` does not export
that attempt. Later attempts are never re-run automatically.
The routing workflow also selects the CI scope. Middle PRs in a stack and PRs
whose diff changes only release notes (`releases/v*.md` or
`releases/beta/v*.md`) skip the build, test, and e2e lanes. The `policy` job
@@ -392,12 +420,18 @@ This gives interrupted jobs an independent recovery path. It keeps the same
source, commands, assertions and required gates. A failed test still needs a fix;
a successful rerun does not explain an earlier failure.
The two PR Runner Vitest jobs separate package tests from native server tests.
The first runs the complete package inventory; the second runs the complete
native server group. The existing `--shard=1/2` and `--shard=2/2` commands route
through `run-pr-vitest-lane.mjs` for compatibility with the master-pinned
workflow. Other workflow names and shard counts keep their existing coverage.
Both jobs still prepare debug binaries. A separate exact-key debug cache holds
The PR Runner Vitest jobs separate package tests from native server tests.
Three `--package-lane=K/3` jobs each run one duration-balanced partition of the
package inventory, and one `--native-server` job runs the complete native server
group. `run-pr-vitest-lane.mjs` asks `vitest list` for the inventory, so it
follows the `vitest.config.ts` includes. It partitions the files with the general
server partitioner, using the weights in
`packages/paperclip-runner/scripts/vitest-lane-durations.json`. Files missing
from that manifest get the median weight. The legacy `--shard=1/2` and
`--shard=2/2` commands keep their earlier PR behavior: the first runs the whole
package inventory and the second runs the native group. Other workflow names and
shard counts keep their existing coverage. Every lane still prepares debug
binaries. A separate exact-key debug cache holds
the workspace library and every Cargo binary target. Its key includes source,
protocol and fixture content, Cargo configuration, compiler flags, toolchain
and checkout path. Only the Rust lane of a verified master push saves it.
@@ -507,6 +541,16 @@ pnpm build-storybook
These run the `@paperclipai/ui` Storybook on port `6006` and build the static output to `ui/storybook-static/`.
**Inline links** mounts the shared Markdown renderer with GitHub repository,
pull request, and issue references, custom labels, connected status metadata,
generic URL fallbacks, and streaming playback. Pasted GitHub resource URLs use compact labels;
the exact destination, query, and fragment stay intact. The presentation makes
no remote metadata requests. Run its light/dark desktop/mobile checks with:
```sh
STORYBOOK_TEST_BASE_URL=http://localhost:6006 pnpm exec playwright test inline-links.spec.ts --config=tests/storybook-visual/playwright.config.ts --project=stories
```
**Task chat → Failure notices** covers compact run errors and adjacent recovery
notices in both presentation modes, with expanded details, fixture retry
callbacks (including the legacy run retry when recovery retry is unavailable),
@@ -521,6 +565,20 @@ STORYBOOK_TEST_BASE_URL=http://localhost:6006 pnpm exec playwright test failure-
The suite checks Storybook play and accessibility reports and saves screenshots.
It does not compare visual baselines or dispatch real agent work.
**Patch previews** mounts production inline document cards, attachment tabs,
workspace source views, and Markdown diff fences. Use **Preview patch** to load
an inline attachment preview. Opening the artifact list does not load patch bytes.
The fixtures include long
unbroken lines, multiple files, and literal HTML. The browser checks cover both
themes at desktop and mobile widths, source preservation, and overflow:
```sh
STORYBOOK_TEST_BASE_URL=http://localhost:6006 pnpm exec playwright test patch-preview.spec.ts --config=tests/storybook-visual/playwright.config.ts --project=stories
```
These checks save screenshots. They do not compare external visual baselines or
verify live file storage.
**HTML artifacts** covers secure rendered/raw attachment previews, interactive
reports, security probes, workspace previews, and opening a report from a task.
See [HTML artifact previews](html-artifact-previews.md) for the security boundary,
@@ -554,8 +612,14 @@ views. The plus button opens the existing agent picker; adding an agent creates
one sidebar entry, and choosing them again reopens the same conversation. The
**Add and reopen** component story exercises that constraint. Agent selection
and sending use local fixtures. The live `/chats` and `/chats/:agentRef` routes
use the same sidebar, picker, landing page, and existing chat surface when the
Agent Chat experimental setting is enabled.
use the same sidebar, picker, landing page, and existing chat surface for everyone.
The **Switch agents · Unread chats** page stories cover per-agent unread badges
in the chat header picker in dark, light, and mobile views. The header and Add
chat pickers show numeric unread message counts from the shared account-scoped
chat-list cache, with indexed lookups and no per-agent read-state requests.
Chat endpoints return `unreadMessageCount` alongside `isUnreadForMe`; the count
uses the same batched query and excludes human messages, deleted messages, system
notices, and fast responses. Opening or selecting a chat does not mark unseen messages read.
Use **Chat & Comments → Issue Thread Interactions → Composer Questions Auto Advance**
to try the paged composer form. A single selection shows a brief checked-state animation before advancing to the
@@ -569,6 +633,13 @@ choices, item verdicts, suggested tasks, tool reviews, runtime questions, and ph
the bottom navigation. The normal message composer remains usable below the
pending card.
Plan reviews leave the composer and pending count when a newer saved plan
revision is known. Document activity refreshes the interaction list, and a
superseded or stale-target response refreshes it immediately. The UI keeps a
newer review visible if its document query still has an older revision. These
display rules do not resolve requests or change the server's approval checks.
Recovery retry uses the same plan freshness check as the composer.
Use **Chat & Comments → Task Chat Unanswered Questions → Test Drive** to
try a regular task question: dismiss it, reload, reopen its compact feed entry,
and submit the preserved draft. The composer has no question pending badge.
@@ -577,6 +648,10 @@ behavior for review cards. Its dismissal save and decisions are simulated;
server persistence and permission boundaries have separate regression tests.
The demo uses real thread/form components with fixture response callbacks.
The Agent Chat stories share the same component fixture.
The **Send instead of confirming** and **Reopen skipped confirmation** stories
exercise sending a separate message while a completion review is open, then
opening that unchanged request from the feed. These use the production thread
with simulated message saves and decision callbacks.
Use **Composer → New task** to review task creation through the production
`TaskChatComposer`. The editor, file menu, work modes, assignee/model picker,
@@ -1055,7 +1130,101 @@ defines synchronization, permissions and evidence. After map edits, run
`node scripts/check-maps.mjs` for structural/reference checks; it does not start an
instance or qualify access as an agent.
Use the cheap local default unless you are specifically working on browser flows:
### Agent worktree preflight
Prepare a new or updated agent worktree with one command:
```sh
node scripts/agent-preflight.mjs
```
It does what `pnpm install --frozen-lockfile`, the Runner's `ensure:eval-build-deps`,
`build:rust`, `build` and `build:runner-binaries` did, and skips work whose inputs
are unchanged:
- **install** is skipped when the lockfile, every `package.json`, `patches/`, Node
and pnpm match this worktree's last install.
- **Runner build** is skipped when the Runner's build stamp is current. When another
worktree built the same inputs (the build-input digest described under
"Reused Runner build in the server build", plus the compiler), the Runner and eval
kernel `dist` are cloned from the shared cache. A restore skips the build's lint
checks; CI runs them.
- **Runner debug binaries** (`runner/target/debug`) are reused the same way, keyed
by every file the Rust build reads.
- A worktree without `runner/target` starts from a clone of a shared cargo seed
that holds only third-party crates, so cargo compiles only the Runner crate.
The cache lives in `~/.cache/paperclip/agent-build` (`PAPERCLIP_AGENT_CACHE_DIR`
moves it, `PAPERCLIP_AGENT_CACHE=0` or `--no-cache` turns it off). Entries are
keyed by content and toolchain, never by path or mtime, are written once with an
atomic rename, and are restored as APFS clones, so concurrent worktrees can share
them. The seed never holds the Runner crate: cargo records its out-of-package
includes by absolute path, so units from another checkout cannot be trusted. Keep
the cache on the worktrees' volume so clones stay cheap. `--explain` prints each
step's decision; `--skip-rust-debug` skips the debug binaries.
Do not share `CARGO_TARGET_DIR` between worktrees: cargo serializes builds on its
lock, and the Runner crate's absolute-path dependency records would make one
worktree's freshness check read another's files. The preflight disables the cache
when `CARGO_TARGET_DIR` is set.
### Incremental local verification
Use the local verification entry point for ordinary issue work:
```sh
pnpm verify
pnpm verify --explain
pnpm verify --base origin/master
```
The command compares the actual working tree with the merge base of the supplied
local ref, including committed, staged, unstaged, deleted, renamed, and untracked
paths. The default ref is local `origin/master`, falling back to `master`; it does
not fetch. Tests read the current working-tree bytes. CI must still verify the
final committed revision. The task/input policy, conservative import and file-read
graph, exact Vitest specifications, and stable test runner are shared with CI.
Serialized server tests retain their owner. Native server tests remain in local
coverage. Selected chat tests retain six sequential isolated line shards.
Successful selected checks with complete input graphs are reusable in this
worktree. Opaque filesystem, subprocess, network, generated/untracked-module, and
unreviewed dependency consumers continue to run fresh. A selected pass never
satisfies a full run. Non-isolated projects and uncertain selection require broader
verification. The command prints the local test scope and remaining CI lanes.
An unchanged repeat can reuse both graph discovery and passing checks. Keys include
actual tracked and non-ignored file contents (including dirty and untracked files),
the shared audited-prose exclusions, dotenv files, lockfile, installation metadata,
dependency bindings and installed package contents, the complete effective child environment, Node/pnpm/Rust toolchains,
platform, checkout path, comparison, and selection. Values from the environment
are hashed, not written to results. External database/live-test settings and
out-of-checkout source symlinks disable reuse. Cache state lives under the ignored
`.turbo/local-verify/` directory and is not shared across worktrees or machines.
Installed package content digests are memoized against file identity, size, mode,
and nanosecond modification/change times; every check rescans the installation.
Generated Vite/cache directories are excluded. The first run after installation
reads package contents; unchanged repeats avoid reading those bytes again.
`last-run.json` retains the latest completed execution's status and scope. Failed/interrupted
attempts do not publish passes. Inputs changed during a run invalidate its result.
Exit 2 reports a broad input, incomplete graph, or unsupported selection. It does
not silently launch the full suite or claim affected coverage passed. Use focused
checks and required CI to close that gap, or explicitly request a broad local run:
```sh
pnpm verify --full
pnpm verify --no-cache
```
`--full` executes the complete existing Vitest path with fresh integration coverage.
`--no-cache` bypasses pass/plan reuse and does not save passes. A fresh failed or
interrupted attempt removes its old reusable pass. Required CI on the final tested
revision may supply broad typecheck/build/test coverage for a PR handoff; do not
repeat a full local suite solely because a chat resumed or a PR is ready. Keep
release checks and any real browser, provider, local-platform or acceptance proof.
The full local Vitest commands remain available:
```sh
pnpm test
@@ -1093,7 +1262,9 @@ the development server's unbundled module graph on each navigation. Browser
assertion deadlines and retries remain unchanged. Use `pnpm dev` separately when
verifying Vite/HMR behavior.
For normal issue work, start with the smallest targeted check that proves the change. Reserve repo-wide typecheck/build/test runs for PR-ready handoff or changes broad enough that narrow checks do not cover the risk.
For normal issue work, start with the smallest targeted check that proves the change.
Use the incremental command above and required final-revision CI for PR handoff.
Run broad local checks when an unresolved risk or explicit qualification needs them.
The Runner transport suite keeps attachment recovery, warm attachment, live
adoption, and suspension in separate test files. Vitest can schedule these
@@ -1111,6 +1282,26 @@ family, including the separate deadline and settlement regressions, with:
pnpm --filter @paperclipai/paperclip-runner exec vitest run runnerd-codex-transport
```
#### PR Vitest retry
PR CI retries a failing Vitest case once, so a single contention flake does not
force a re-run of the whole workflow. `scripts/ci/vitest-retry.mjs` owns the gate.
The default is one retry when `GITHUB_ACTIONS=true` and `GITHUB_WORKFLOW=PR`.
Reusable workflows inherit the caller's name, so `pr-trusted.yml` gets the retry
through `pr.yml`. Release, release verify, cloud readiness, master and local runs
keep zero retries. `PAPERCLIP_VITEST_RETRY` overrides the default in any context.
For example, set the `CI_VITEST_RETRY` repository variable to `0` to turn the retry
off, or set `PAPERCLIP_VITEST_RETRY=1` locally to reproduce the CI behavior.
`scripts/run-vitest-stable.mjs` passes `--retry` to every `vitest run`. This
covers the general, workspace, chat, native Runner and serialized lanes and the
selected-file path. The Runner package config sets `retry` itself, because its
lane runs Vitest directly. A test that passes only on a retry is still a flake.
`scripts/ci/retry-reporter.mjs` adds a `Flaky test passed on retry` warning
annotation for each such test, and a row to the step summary with the file, test,
retry count and job. Fix a test that this reporter reports. Do not treat the
retry as the fix. Playwright retries stay at zero.
#### Embedded Postgres template cache
To run the suite with one owned PostgreSQL server and a separate disposable
@@ -1262,6 +1453,74 @@ that file, and it fails when a referenced config does not exist yet. The
program. That program peaks at about 15 GiB at default settings and about 8 GiB on
one thread.
#### Typecheck test files outside the build
Most package tsconfigs exclude tests, and Vitest strips types, so a type error in
one of those tests fails nothing. `scripts/typecheck-test-programs.mjs` reads them
through separate programs, usually a `tsconfig.test.json` beside the package's
`tsconfig.json`:
```sh
node scripts/typecheck-test-programs.mjs --low-memory
node scripts/typecheck-test-programs.mjs packages/paperclip-runner/tsconfig.test.json
```
The first form runs in the required `Typecheck + Release Registry` job. It checks
one program at a time and the largest peaks below 2 GiB. It then fails if a tracked
`*.test.ts(x)` or `*.spec.ts(x)` file is in no program. The compiler's file list
decides that, because `include` silently drops `foo.test.tsx` when `foo.test.ts`
exists.
To add a test directory, add it to the `include` of the package's
`tsconfig.test.json`. To add a package, add a program to `PROGRAMS` in the script.
Sandbox providers are outside the root workspace, so the root install has none of
their SDKs. `scripts/typecheck-sandbox-providers` is a small workspace that lists
them and holds one lockfile. Its install replaces any standalone `node_modules` in
those providers. After you change a provider's `package.json`, do not refresh or
commit that lockfile. `typecheck:test-programs` resolves a stale lockfile in place
for the run, and the lockfile refresh workflow commits the new one after merge (see
[Dependency Lockfile Policy](#dependency-lockfile-policy)).
Do not add a lockfile inside a provider.
`scripts/build-standalone-public-packages.mjs` uses a frozen install when one
exists, which changes what a release resolves. Daytona already has one; the
release build resolves it in place when it is stale.
Daytona is the one exception: it commits its own lockfile, so the workspace
leaves it out and the script installs it from that lockfile (`LOCKED_PROVIDERS`).
The same rule applies to it: do not refresh or commit that lockfile. The script
resolves a stale one in place for the run, and the lockfile refresh workflow
commits the new one after merge.
Dependabot updates the providers through this workspace (see
`.github/dependabot.yml`), and Daytona through a second entry. Dependabot does
not expand a workspace glob that starts with `../`, so
`scripts/typecheck-sandbox-providers/pnpm-workspace.yaml` lists each provider by
its full path. When you add a provider, add it there and to `SANDBOX_PROVIDERS`
in `scripts/typecheck-test-programs.mjs`. A test checks that the two lists match.
#### Sandbox provider tests
`pnpm test:run` does not reach the sandbox providers, because they are outside the
root workspace. `scripts/test-sandbox-providers.mjs` installs them the same way as
the typecheck above, then runs every provider's `pnpm test`, four at a time:
```sh
node scripts/test-sandbox-providers.mjs
```
The required `Sandbox provider tests` job runs it when a pull request changes a
provider, the plugin SDK, or `packages/shared`, which the SDK imports. Those paths
are the `//#ci:sandbox-providers` inputs in `turbo.json`. Global CI inputs, such as
`scripts/**` or a `package.json`, select every job. If the SDK gets a new workspace
dependency, add that package to the inputs. A test in
`.github/scripts/tests/ci-plan.test.mjs` fails until you do.
Some Daytona file-sync tests need GNU `tar` and coreutils. They skip on macOS
unless those tools are on `PATH`, so they run only on Linux. The job turns off
core dumps: a core file in an exported directory changes the transferred file count.
#### Reused Runner build in the server build
The server build vendors `packages/paperclip-runner/dist`, so its
@@ -1270,7 +1529,7 @@ build. It used to run the Runner's full `build` every time. Now the Runner's
`build` brackets itself with `scripts/record-build-inputs.mjs`: `--begin`, its
first step, forgets the previous stamp and records a digest of the build's
inputs (every file under `packages/paperclip-runner` except `dist`,
`dist-browser`, `runner/target`, `node_modules` and dot-directories; every
`dist-browser`, `runner/target`, `provider-assets` (written by the build), `node_modules` and dot-directories; every
file under `packages/paperclip-eval-kernel` except `dist` and `node_modules`;
every file under the root `patches` directory, which the acpx profile check
hashes and the provider entrypoint bundler copies into `dist`; and the root
@@ -1828,6 +2087,60 @@ that the whole web server is disposable or that all execution and recovery paths
survive web-server replacement. Run-log/trace storage and producer retention are
separate changes.
### Managed environments
The `environments` list in `PAPERCLIP_MANAGED_CONFIG` declares instance-level
environments that boot provisions and tenants cannot edit. It holds at most one
sandbox entry (no `driver`, or `driver: "sandbox"`) and any number of
`driver: "plugin"` entries. A plugin entry names a plugin by its manifest id
(`pluginKey`) and one of its environment drivers:
```json
{
"name": "Remote Host",
"description": "Runs the agent on a remote host",
"driver": "plugin",
"pluginKey": "example.remote-host-plugin",
"driverKey": "tasks",
"driverConfig": {},
"instanceDefault": true
}
```
Names and `pluginKey`–`driverKey` pairs must be unique, `driverConfig` must not
contain secret-like keys, and `instanceDefault` is allowed on at most one plugin
entry and never on the sandbox entry. Any other key refuses startup. `plugins.autoInstall`
holds catalog keys, not manifest ids, so boot also refuses to start unless an
auto-installed bundled or distribution catalog entry installs the entry's
`pluginKey`.
Boot validates the driver config through the plugin, marks the row
`managedPluginEnvironment: "<pluginKey>:<driverKey>"`, and adopts an existing
unmarked row for the same plugin and driver. While the plugin is down, the row is
archived; it comes back when the worker is ready. Removing an entry never archives
its row.
Boot fills the instance default only when it is unset, names the local
environment, or names a deleted one. A plugin entry with `instanceDefault` fills
first, right after its ensure; then, under `enableManagedSandboxOnly`, the
managed sandbox fills. So a new instance gets the plugin environment, and an
existing instance keeps the default it already has. If the plugin is down at a
new instance's first boot, the managed sandbox fills the default instead, and the
plugin environment does not replace it later. Recovery after boot never fills.
Like an admin's default write, the plugin fill declines while an active agent
that follows the default has not been prepared by that plugin; the managed
sandbox then fills as usual. The fill locks the default row and rechecks it, so a
concurrent boot or admin write is never overwritten. A filled default is an
ordinary set value: nothing reverts it when the mode is later turned off, and an
admin can change it in instance settings.
`server/src/services/managed-config-capabilities.json` lists the managed-config
capabilities a release supports, for the control plane to read at a release
commit. It must equal `MANAGED_CONFIG_CAPABILITIES` in `managed-config.ts`
(`managed-config-capabilities.test.ts` checks this), and the parser accepts plugin
entries only while that list includes `pluginEnvironments`.
### Experimental native remote workspace storage
`experimental.remoteWorkspaceStorageEnabled` is a managed feature, on by default
@@ -1931,6 +2244,36 @@ corrupt, or unbound sources block the import. Publication still requires an
independent download and restore of the new encrypted snapshot. The old folder
remains absent, and the importer does not change the original restore point.
An interrupted archived Git import reuses its original uploaded archive after
authentication, including when the upload reply was lost. It does not rebuild
that archive from today's remote branch pointers. The original import journal,
restoration recipe, task binding, and execution fences must still match before
publication. Saved nested sources must still belong to the same company and
project with the same repository URL; publication locks and rechecks those source
rows. Imports from mutable local folders still recapture and compare the
current files; a saved upload does not permit later local edits to be discarded.
If every recorded snapshot upload is definitively absent, an unpublished
archived Git import can recapture the same validated logical source. Private Git
administrative bytes can differ between reconstructions. The original journal
and every upload attempt remain recorded; available prior uploads and concurrent
publication winners are reused. Unknown or corrupt objects block recapture.
The source recipe and ownership checks still gate authority publication.
When the verified Git source still exists, reconstruction also copies its local
`info/exclude` rules into the private repository before applying a saved restore
point. This keeps saved ignored files, including recovery receipts and bundles,
in their original classification. The source ignore file stays unchanged.
Symlinked, oversized, or changing source ignore files block reconstruction.
Archived imports restore nested linked checkouts as private standalone
repositories. The saved branch name belongs to the old path, so reconstruction
does not register a new worktree or reset a branch in the shared project cache.
A verified cache can supply Git objects without copying its current working
files. Each private copy retains the restore point's extra commits under local
refs so they survive portable archive capture and garbage collection. Ordinary
linked-worktree recovery still requires its original path ownership.
Shared physical folders require Cloud's offline enrollment maintenance process.
A task-level hold cannot fence another workspace record pointing at the same
folder. Maintenance verifies that legacy deployments have stopped and that no
@@ -2084,6 +2427,13 @@ If a repository is detached or its source configuration changes, its previous ta
Agent, project, environment, secret, skill, and workspace config edits are sampled at the next run boundary. A heartbeat that is already running finishes with the config it started with. Native runners project explicitly configured task environment variables into provider processes and tool commands, including custom `PAPERCLIP_*` names such as `PAPERCLIP_PAGE_BUCKET`. Adding, changing, or removing a projected variable replaces a retained provider process at the next run boundary. Unchanged variables permit process reuse. The projection contains names only; secret values stay in the child environment. Ambient host secrets are not projected. Native projections allow up to 128 variable names, 64 KiB per entry, and 256 KiB of values in total. These native launch limits do not apply to legacy adapter configuration. Runtime authority, provider login credentials, and process-loader settings use their existing restricted paths.
Native OpenCode applies that projection when starting `opencode serve`, so its
shell tools receive both plain bindings and resolved company secrets from
`adapterConfig.env`. Forwarded values participate in diagnostic and provider-trace
redaction. The isolated HOME, provider credential proxy, and GitHub broker retain
their existing launch rules. OpenCode owns the `OPENCODE_*` and `XDG_*`
configuration variables; task bindings cannot override them.
When effective run config changes, Paperclip may intentionally skip a saved adapter session, refresh persisted workspace runtime config, replace a reused execution workspace, or avoid reusing a sandbox/environment lease. Fresh execution can lose adapter-specific session, workspace, or sandbox state; correctness of the next run's config takes priority over continuity. Plain environment values affect freshness through value hashes; run result JSON and workspace operation logs expose only the non-sensitive freshness decision categories, without storing secret values, full env maps, provider credentials, or private path details.
An explicit `reuse_existing` Git workspace keeps its recorded source repository, including after its project-workspace row is deleted or the project primary changes. The server verifies the original company, project, repository origin, and worktree registration before reuse. It does not attach a deleted association to the new primary. A conflicting explicit project-workspace selection, missing source identity, or an unverified legacy local checkout blocks the run. Restore the original source. Before intentionally clearing the existing-workspace binding to choose a new repository, review and preserve its retained work. Missing worktrees may be reconstructed only from the verified original source; current project defaults are not a fallback.
@@ -2580,6 +2930,13 @@ They read only the queued run's runtime mode. Mutation locks retain complete
wake and run rows, so edits, ordering, interruption, and saved execution inputs
keep their existing write contract.
The task composer offers Steer when the native session has an active provider
turn that accepts steering. If steering is unavailable or unsupported, it offers
an explicit Interrupt action instead. Interrupt records the board user's intent,
requests cancellation, and delivers the saved queue through normal admission after
the previous execution stops. Preparation and cleanup can keep messages queued;
the action does not bypass Stop confirmation or execution gates.
Run retrieval and session state are in `server/src/services/heartbeat/run-state.ts`.
It owns bounded run projections, database encoding checks, task session reads and
writes, explicit resumes, session compaction, and usage/billing helpers.
@@ -2769,7 +3126,7 @@ runtimes on Apple Silicon, Intel Mac, and Linux x64 with supported glibc;
Linux ARM64 remains Legacy until the pinned runtimes support it.
Unsupported or unqualified targets retain Legacy. Claude over Darwin SSH keeps
an automatic Legacy default; explicit native execution
requires matching remote assets. The runner override is in Advanced. Ordinary Claude models default to `claude-opus-5-5`; explicit models
requires matching remote assets. Agent setup and configuration select the runner automatically and do not expose a runner dropdown. Ordinary Claude models default to `claude-opus-5-5`; explicit models
and provider routing remain authoritative. Existing agents keep their saved runner. Converting an existing Claude, Codex, OpenCode, or Pi agent selects its
corresponding provider, preserves compatible models, credentials, workspace,
and instructions, and resets execution sessions while retaining run history.
@@ -2800,7 +3157,9 @@ Legacy runner; incompatible settings return remediation instead of being ignored
Saved OpenCode agents keep their runner and runtime policies.
Plugin environment drivers can declare `requiresAgent: true` when their leases
need an agent-owned resource. Setup saves the draft and environment, runs its
need an agent-owned resource. The host selects that plugin's lifecycle work only for
agents on that environment. See [Required plugin work](./AGENT-LIFECYCLE.md#required-plugin-work).
Setup saves the draft and environment, runs its
existing lifecycle preparation, and waits in `verifying` with the `setup` hold.
The worker becomes idle there until the user finishes hiring. Connection tests
use that draft's identity and a bounded Core-issued probe; ordinary tasks remain
@@ -2824,6 +3183,7 @@ them. Existing Pi agents retain their saved runner and native policies.
Agent configuration uses the same section layout across adapters: model and
provider belong to **Adapter**, environment variables have their own section,
and command/extra arguments are folded under **Configuration → Advanced**.
Advanced is omitted when the selected adapter has no settings in that section.
Lifecycle, timeout, and interrupt grace settings live under **Advanced Run
Policy**. Permission selectors with a single valid mode are hidden; a saved
unsupported mode still exposes remediation.
@@ -2883,11 +3243,14 @@ their existing preparation order.
OpenCode defaults to `allow`, with explicit `ask` and `deny` options; ACPX
defaults to `approve-all`, with explicit `approve-paperclip`, `approve-reads`,
and `deny-all` options. Codex conversion keeps a non-empty model and
otherwise stores the shared `gpt-5.6-sol` default. The native execution boundary
applies the same default to older runner rows whose model is missing or blank.
otherwise stores the shared `gpt-6.1-sol` default (half the `gpt-5.6-sol` token
price on the reviewed OpenAI rate card in `server/src/services/codex-pricing.ts`).
The native execution boundary applies the same default to older runner rows whose
model is missing or blank; a stored model, including `gpt-5.6-sol`, is never
rewritten. A remote Codex CLI older than 0.159.0 falls back to an older compatible
Sol model (`gpt-6-sol`, then `gpt-5.6-sol`).
For an Agent Chat test drive, enable **Agent Chat** in Experimental settings and
configure two agents with Paperclip Runner: native Codex and ACPX Claude. Connect
For an Agent Chat test drive, configure two agents with Paperclip Runner: native Codex and ACPX Claude. Connect
the Claude account through the agent's **AI connection** section (or supply an
explicit supported provider credential); an ambient Claude CLI login alone is
not a credential source for its isolated runner home. The default
@@ -3038,6 +3401,18 @@ Older packs without the declaration retain content verification until the image
is updated. Selected ACPX agent profile checks remain in force.
Daytona detects gzip/zstd headers and skips redundant transport compression.
### Daytona duplex close receipts
Lease teardown removes duplex routes from command admission before it closes
their transports. The provider retains up to 4096 route/session bindings and
their close promises, so a later host close joins the same cleanup and returns
the original binding. These records grant no write, stop, or open authority.
Unknown or evicted routes cannot supply a session acknowledgement. A pending
close still waits; an unconfirmed close still retires the worker through the
existing host guard. The acknowledgement identifies the channel. Provider stop
receipts and unresolved external-work checks still determine process termination.
No command is replayed or worker restarted by this mechanism.
### Remote GitHub launcher staging
Managed GitHub launchers stage their nine text files in one bounded control
@@ -3051,6 +3426,13 @@ and `packages/adapter-utils/src/sandbox-callback-bridge.test.ts` for these contr
### Native runner restart recovery
An explicit Retry can continue a recovered setup failure that began as a
scheduled retry. Its settlement proof accepts the host-authored promotion event
only when its deadline, reason and attempt match the run and its saved retry
context. The exact failure and durable measurement must still follow. Provider
dispatch evidence, an active controller, unsafe files or changed user authority
keep the Retry held. This adds no automatic replay or recovery loop.
For remote controller disk-loss work, use the existing control-plane snapshot
transaction and `native-controller-recovery.ts`; do not add a second snapshot
writer. The private versioned metadata is limited to 4 KiB. Its importer handles
@@ -3186,6 +3568,29 @@ or cancelled result must retire that session and collect its managed files befor
heartbeat stops the reusable sandbox. A later retry can resume the same sandbox
without inheriting the stopped provider transport.
A rejected warm-session close retains its owner, even when no managed instruction
copy exists. Idle expiry, failed-turn release, and restart must not report that
owner as quiet or launch a replacement over it. The next retirement attempt uses
the same owner and must finish provider closure and stopped-file collection.
The existing explicit-reset exception still requires its typed retirement proof.
`heartbeatService(...).prepareIdleWarmNativeSessionsForSleep({ canRetire })`
delegates to the native owner's retirement path for idle owners. Its caller owns
the admission hold and supplies a synchronous
current-authority check. The check runs before each retirement and after its
awaited effects. A started cleanup settles if the hold expires, but no further
owner retires under that expired hold. Busy turns remain active and do not inherit
a close-on-release request from an abandoned sleep attempt. Counts describe the
attempt; the caller must still validate its hold and final local-work proof.
Diagnostic-only inspection must never invoke this resource preparation.
The privileged `task-drain?idleSleepSafety=1` preflight invokes this operation only
for the exact current, unexpired idle owner with no active admitted work. It tracks
the actual cleanup promise, rejects concurrent work or a changed hold, and then
takes a fresh durable/local safety snapshot. Busy or failed retirement keeps the
instance awake. Plain drain reads and `idleSleepDiagnostics=1` remain passive with
respect to native retirement.
An idle warm owner proves only the exact prior run in the same company, session,
workspace and environment. Mutable configuration compatibility is checked
separately: changed settings retire the owned provider before a replacement can
@@ -3209,7 +3614,7 @@ successful warm-session retention or admit another provider turn.
Run the credential-free real-process restart suite with:
```sh
pnpm --filter @paperclipai/paperclip-runner build:runner-binaries
pnpm --filter @paperclipai/paperclip-runner run build:runner-binaries
pnpm exec vitest run server/src/services/native-runtime/native-runner-restart-recovery.integration.test.ts
pnpm --filter @paperclipai/paperclip-runner exec vitest run src/live/runnerd-codex-transport-adoption.test.ts -t 'adopts a live runner'
```
@@ -4139,6 +4544,16 @@ retryable restoration failure. It never substitutes the default branch.
Reopening failures reject the consuming request with HTTP 409 before saving a
comment or changing its task. The response preserves the restoration message, so
the composer keeps an editable draft and permits retry after access is restored.
It also carries `diagnostic: { step, kind }`, and the server log records the same
pair. Both are closed values: `step` names the failed stage (for example
`source_materialize`, `restore_point` or `saved_commit`), and `kind` names the
failure class (`remote_unavailable`, `auth_rejected`, `commit_unavailable`,
`timeout`, `verification`, `filesystem`, `git_command` or `unknown`). They never
contain paths, repository URLs or Git output.
Nested checkout failures retain their original restore step. The checkout
wrapper keeps process error codes and timeout flags privately for classification;
it does not attach the raw command error as a cause or add those flags to the
public error object.
Both enable switches default off. Rollback disables further expiration while
leaving saved recipes and restoration support intact. Recovery archives, runner
@@ -4463,6 +4878,102 @@ unavailable. Use a separate, disposable database whose name ends in `_test` for
`PAPERCLIP_SKILL_SOURCES_TEST_DATABASE_URL`. The suites apply migrations and create
test data; the synchronization suite truncates company data between tests.
#### Controlled default-cadence sleep acceptance
The opt-in `domain_deadlines_v1` negotiation adds owner-bound skill deadline
handoff coverage. Legacy callers retain the conservative blockers described above.
The checked-in `scripts/test-skill-default-cadence.mjs` launcher checks this local
flow with the actual app, signed tenant routes, Cloud controller and PostgreSQL
alarm store. Git contents, S3 and physical child stop/start use controlled local
providers. A matching service checkout with
`src/sleep/fixtures/default-cadence-bridge.ts` and its normal compiled output is
required for the host flow. The PG0 controls do not require that checkout or a DB.
The launcher does not start a cluster, build artifacts, or contact live providers.
Use Node **24.22.2**, pnpm **9.15.4**, and Node **22.23.4** for the matching service.
Prepare the server dependency closure with a frozen lockfile. Build only missing
TypeScript prerequisites explicitly; the launcher does not install or build them.
From the app repository root:
```sh
export NODE24=/absolute/path/to/node-v24.22.2/bin/node
export PNPM9=/absolute/path/to/pnpm-9.15.4/bin/pnpm
export PATH="$(dirname "$NODE24"):$(dirname "$PNPM9"):$PATH"
"$PNPM9" --filter @paperclipai/server... install --frozen-lockfile --ignore-scripts
for package in packages/shared packages/plugins/sdk packages/paperclip-eval-kernel packages/paperclip-runner; do
(cd "$package" && "$NODE24" node_modules/typescript/bin/tsc -p tsconfig.json)
done
(cd packages/paperclip-runner && "$NODE24" node_modules/typescript/bin/tsc -p tsconfig.surfaces.json)
export SKILL_SLEEP_RESULTS="$(mktemp -d "${TMPDIR:-/tmp}/paperclip-skill-sleep.XXXXXX")"
export PAPERCLIP_SKILL_DEFAULT_CADENCE_GUARD_LOG="$SKILL_SLEEP_RESULTS/controls-guard.jsonl"
"$NODE24" scripts/test-skill-default-cadence.mjs --controls
```
This PG0 lane runs 44 controls. It explicitly defers the single real-PostgreSQL
observer case before its support probe. It requires the actual worker's preloaded
guard marker and matching receipt. Ordinary Vitest runs keep all subprocess
controls and defer only that launcher-specific parent assertion. An explicit
prewire mode or guard-log setting makes the unchanged assertion mandatory; a
missing or invalid marker fails. The launcher never installs the guard late.
For host acceptance, separately create empty, owned databases on an existing local
PostgreSQL 17 server. Use distinct names ending in `_test`. The test applies normal
migrations and writes fixtures; it never starts an embedded cluster. Use a fresh
pair for each attempt, including the full run after bootstrap. Prepare the matching
service dependencies separately and compile its normal `tsconfig.build.json` with
Node 22. The recorded local runtime used service source
`2cb54f904031dcf69a32902cd853b43f74d6f301`. Current service publication
`389878364b42dd129835d263f91882c5774d5123` carries that measured helper source through
its normal upstream merge; the runtime was not repeated on that service merge.
An arbitrary service revision is not an interchangeable prerequisite.
```sh
export PAPERCLIP_SKILL_DEFAULT_CADENCE_CLOUD_CHECKOUT=/absolute/path/to/matching-service
export PAPERCLIP_SKILL_DEFAULT_CADENCE_CLOUD_NODE=/absolute/path/to/node-v22.23.4/bin/node
export PAPERCLIP_SKILL_DEFAULT_CADENCE_CLOUD_SHA=389878364b42dd129835d263f91882c5774d5123
test "$(git -C "$PAPERCLIP_SKILL_DEFAULT_CADENCE_CLOUD_CHECKOUT" rev-parse HEAD)" = "$PAPERCLIP_SKILL_DEFAULT_CADENCE_CLOUD_SHA"
(cd "$PAPERCLIP_SKILL_DEFAULT_CADENCE_CLOUD_CHECKOUT" && "$PAPERCLIP_SKILL_DEFAULT_CADENCE_CLOUD_NODE" node_modules/typescript/bin/tsc -p tsconfig.build.json)
export PAPERCLIP_TEST_POSTGRES_ADMIN_URL=postgres://paperclip@127.0.0.1:55448/postgres
export PAPERCLIP_SKILL_DEFAULT_CADENCE_TEST_DATABASE_URL=postgres://paperclip@127.0.0.1:55448/skill_sleep_bootstrap_app_test
export PAPERCLIP_SKILL_DEFAULT_CADENCE_CLOUD_TEST_DATABASE_URL=postgres://paperclip@127.0.0.1:55448/skill_sleep_bootstrap_cloud_test
export PAPERCLIP_SKILL_DEFAULT_CADENCE_EVIDENCE="$SKILL_SLEEP_RESULTS/bootstrap"
mkdir -p "$PAPERCLIP_SKILL_DEFAULT_CADENCE_EVIDENCE"
unset PAPERCLIP_MAINTENANCE_PG0_CONTROLS
export PAPERCLIP_SKILL_DEFAULT_CADENCE_STAGE=bootstrap
export PAPERCLIP_SKILL_DEFAULT_CADENCE_GUARD_LOG="$SKILL_SLEEP_RESULTS/bootstrap-guard.jsonl"
"$NODE24" scripts/test-skill-default-cadence.mjs
```
Bootstrap checks revision 1, the legacy refusal, the signed bounded handoff and the
exact durable ACK before child stop. It does not check a due wake. For the complete
run, first create a different empty pair, then run:
```sh
export PAPERCLIP_SKILL_DEFAULT_CADENCE_TEST_DATABASE_URL=postgres://paperclip@127.0.0.1:55448/skill_sleep_cycles_app_test
export PAPERCLIP_SKILL_DEFAULT_CADENCE_CLOUD_TEST_DATABASE_URL=postgres://paperclip@127.0.0.1:55448/skill_sleep_cycles_cloud_test
export PAPERCLIP_SKILL_DEFAULT_CADENCE_EVIDENCE="$SKILL_SLEEP_RESULTS/two-cycle"
mkdir -p "$PAPERCLIP_SKILL_DEFAULT_CADENCE_EVIDENCE"
export PAPERCLIP_SKILL_DEFAULT_CADENCE_STAGE=two-cycle
export PAPERCLIP_SKILL_DEFAULT_CADENCE_GUARD_LOG="$SKILL_SLEEP_RESULTS/two-cycle-guard.jsonl"
"$NODE24" scripts/test-skill-default-cadence.mjs
```
Use a new guard-log path for each attempt. The full mode takes about 10-14 minutes.
It retains the original 300-second source cadence, real app/SQL clocks, D-120 alarm
eligibility and the existing 850-second test timeout. Do not shorten the cadence,
write deadlines, or substitute a fake clock. The launcher uses one worker and no
file parallelism. Budget runtime pools for the app's potential 10 connections plus
Cloud's shared 2 and one transient observer; each pool is at most 4. The recorded
server limit was 40. Run this fixture without other heavy tests or builds.
Receipts retain both original deadlines, durable alarm generations, distinct cold
processes and persisted revisions 2 and 3. Each cold start also checks a measured
10-second app SQL/upstream/outgoing-HTTP quiet window. Awake guards can still show
2 PostgreSQL sockets: quiet SQL does not mean all connections are closed. This is
controlled local acceptance, not strict 90-second host acceptance, live-provider
qualification or proof of deployed Railway/Neon suspension. Keep failed attempts
and audit owned sessions/processes before database cleanup.
### Public MCP and Dot event delivery
Public MCP task events and Dot mailbox events use separate topics in the shared
@@ -4548,3 +5059,80 @@ one-time config-file seed preserves existing choices, then later starts use the
database without loading home `.env` files or repairing worktree config. See
[Cloud runtime configuration](cloud-runtime-configuration.md) for field ownership,
secret handling, initialization order and migration interfaces.
### Dedicated native recovery test database
The native idle continuation fixture reads `PAPERCLIP_RECOVERY_TEST_DATABASE_URL`
and passes it through `nativeRestartFixture({ dedicatedDatabaseUrl })`. The URL
must name a dedicated PostgreSQL database ending in `_test`, without database-name
or connection-option overrides. This explicit option applies pending migrations
and closes registered clients at cleanup; it does not create or drop that
database. Its provisioning owner remains responsible for disposal. The environment
variable alone does not opt other `startRecoveryPostgres()` callers into shared
storage: they retain their existing fresh-database allocation and cleanup.
The native idle-sleep continuation integration uses a controlled local provider
with the real heartbeat queue, authorized comment wake, native session
checkpoint, files, retained secret encryption and fresh resolution, and owned
idle preflight. Credential resolution through the secret owner does not prove
production backend credential injection. It uses an explicit two-connection
main pool, alongside the production auxiliary pools, and verifies the driver's
actual setting. It does not
exercise the remote Runner harness backup transport or a live provider.
The workspace runtime lifecycle test uses the actual local runtime owner and an
ephemeral HTTP child. Normal run release must remove the original PID, listener,
and service registry entry before the same owner starts another useful service
from the retained workspace files. This is physical local resource retirement
and next-use coverage; it does not establish global tenant quiet.
The plugin idle manager lifecycle test uses a real SDK worker process and a
controlled host state handler that persists worker requests to a temporary file.
It checks the actual manifest generation, refusal of work under the owned hold,
and a second useful write after release. This boundary does not exercise the
production plugin database loader or plugin jobs. Unsupported manifests, active
manual resources, and missing completion receipts remain sleep blockers.
### Read resilience during deploys
Read surfaces use `useQueryView` and `QueryErrorState`. Transient refetch failures keep loaded data visible. An initial outage keeps a quiet placeholder until recovery. Authorization, missing resources and validation failures still show readable errors. Exhausted first reads on a reachable server offer Retry, including task filters and hierarchy reads. Health probes and the socket own app-wide recovery; a successful component load may be an older in-flight response or a local no-op. Sensitive manual reads use `useResilientFetch`; browser viewer URLs and pairing previews remain in component memory and never enter a shared cache.
Run `node scripts/check-query-error-rendering.mjs`. The guard now enforces across `ui/src`, including multiline expressions, and CI runs it as a policy check. Its small allowlist covers standalone preview/performance fixtures, transcript protocol result flags, and the existing account-resolution and Copilot discovery retry owners. These are not ordinary read-error renders. Additions require a specific reason; do not allowlist a page to hide an outage regression.
The browser saves a small read cache in `paperclip:query-cache:v1:*` local storage records. It contains session display identity, health and shell preferences, company and sidebar data, agent/project lists, plugin UI contribution metadata, the first inbox page (including up to 100 rows per legacy Inbox query), and up to 20 recently opened tasks. Records belong to one user and company, expire 24 hours after each read, and are discarded when the build changes. Normal in-memory query garbage collection does not erase saved recent tasks or extend their expiry. Builds without a source commit stamp skip read-cache hydration and persistence, but still revoke and clear saved reads on sign-out. Plugin contributions use the existing instance-wide query inside the user's root record. Session tokens, bootstrap payloads, documents, comments, browser viewer capabilities, pairing data, other queries and mutations are excluded. Task content in this allowlist remains on the browser until expiry or sign-out; use the normal sign-out action to clear it across tabs. Account changes also detach pending shared GETs so a new user cannot join the previous user’s private reads. Editors that need fields excluded from the display cache, including bulk agent settings, fetch complete current data before opening.
Restored identity permits cached display only. Restored inline task drafts compare their saved base with a fresh field read before each replay; conflicting edits stay in the editor for review. Account-bound writes wait for a fresh server session and are cancelled if its user changes. A definitive session denial removes saved account reads. Service-worker updates reload hidden tabs only after connectivity is online and mutations, durable sends and autosaves have settled. Durable queues synchronously report each unresolved operation, including the intervals between online rate-limit retries. Sign-out revocation also works on plain-HTTP installs without `crypto.randomUUID`.
Embedded plan documents and preview work products are stripped from saved payloads. A denied task clears its UUID and identifier projections. Sign-out revocation markers contain no task content and remain after sign-out to reject stale records and delayed writes from older tabs. Session responses started before revocation cannot restore those reads, even before the storage event reaches the other tab.
Cached agents retain only safe adapter display fields and an empty runtime configuration. Their configuration objects remain present for existing display consumers; provider credentials and environment values are excluded.
A restored task shows its header and properties while uncached history is still loading. History is not persisted, and its normal placeholder remains until reads recover. The header stays visible across recovery.
Query-cache cleanup snapshots storage keys before deletion because native storage can reorder them as records are removed. Flush cleanup affects only its owning account and yields if another account becomes active.
Run `pnpm exec playwright test -c tests/e2e/playwright.config.ts tests/e2e/disconnect-resilience.spec.ts` for the built-UI outage regressions. The suite uses fresh isolated local data, 20-second API interceptions, and a controlled sign-out session response. It covers retained tasks, comment and document recovery, queued-comment reloads, cached settings/sidebar/task reads, legacy Inbox reloads, real error status handling and sign-out clearing across several company records. The existing CI e2e shards discover it automatically. A real hosted restart or deploy remains a separate acceptance check.
### Chat and task navigation caching
Task-link intent warms the compact core and recent-comments queries used by the
thread, rather than fetching full enrichment and an unused older-history page.
Classic and already-expanded tasks keep their full-history prefetch.
The mounted reads reuse the normal 30-second freshness window; live invalidation
and background revalidation still apply. Recent-window catch-up retains messages
observed before navigation. Agent Chat can render from the current person's
company-scoped navigation list, carrying its original fetch timestamp so stale
conversations refresh in the background. No conversation is created by a read.
### Remote repository admission saves
Remote admission validates each registered repository against its current sandbox,
origin, and pinned commit. A matching descriptor in the committed snapshot receipt
allows admission without another archive upload. New repositories and legacy
repositories without that receipt require one workspace snapshot for the admission
batch. Repository ready receipts publish after the snapshot commits. An upload
failure remains retryable even when the clone directory already exists. Later
workspace saves retain repository coverage only for directories present in the
captured archive. Deleted checkouts lose coverage and can be acquired again.
A failed batch upload marks the affected repository rows as needing repair.
+8
View File
@@ -60,6 +60,14 @@ run lacks direct-chat authority or the user lacks editing permission. The user
can request the edit in their authorized chat or apply it directly. Creating
another task does not authorize the instruction change.
When a low-trust agent's task is in a project outside its boundary, setup fails
the run with the typed `project_access_denied` code and the denied project ID
before provider work starts. The task notice links Manage project access. That
exact pre-dispatch shape is an owner action, so it stays on the task instead of
the unexpected Sentry run-failure stream. The legacy `setup_failed` code and the
denial message alone never suppress a report, and
`PAPERCLIP_SENTRY_RUN_FAILURES=all` still reports it.
## Direct-chat instruction edits
When an authorized user directly asks an agent in their own Agent Chat to edit
+94
View File
@@ -0,0 +1,94 @@
# Advisory PR review notifications
The optional `PR merge review notification` workflow sends a private webhook
after a current, open, non-draft PR targeting `master` passes verification.
It does not approve or merge a PR. Webhook failures do not change PR CI results.
## Private configuration
Create a GitHub Actions environment named `pr-merge-review`. Restrict its
deployment branch policy to the `master` branch only, with no tag policy.
Keep these two values in **environment secrets**, not repository variables,
repository secrets, committed files, task comments, or workflow outputs:
- `PAPERCLIP_PR_REVIEW_WEBHOOK_URL`: a Paperclip routine webhook URL.
- `PAPERCLIP_PR_REVIEW_WEBHOOK_TOKEN`: its separate bearer signing secret.
Use a webhook trigger with `signingMode: bearer`. The routine should use
`always_enqueue` concurrency so separate PRs cannot be coalesced into one task.
Define required text variables `pr_url`, `pr_number`, `head_sha`, and `base_sha`.
The notifier sends only those identifiers. The receiving agent must treat them
as untrusted data and verify the repository, PR identity, and current revision.
Give the routine instructions to run `pr-merge-review` and publish its MERGE,
HOLD, or DO NOT MERGE recommendation on the routine execution's Paperclip task.
Require a fresh check of the head, base, reviews, required checks, and task gates.
Keep GitHub approval, merge, auto-merge, and public-comment actions disabled in
the instructions for this advisory phase. Configure the routine in Paperclip;
do not put instance-specific URLs or credentials in this repository.
## Delivery and verification
The notification workflow runs trusted default-branch code. It never checks out
PR code or downloads PR artifacts or caches. It reacts to the repository's PR
workflow completions, external check completions, and legacy commit statuses.
When adding another PR verification workflow, add its name to `workflow_run`.
GitHub cannot filter `check_run` or `status` triggers, so the job condition
skips events that cannot complete a green state before a runner is allocated.
Only successful, skipped, or neutral PR workflow runs and non-Actions check runs,
and successful commit statuses, schedule the job. GitHub Actions check runs are
skipped because their workflow completions already arrive through `workflow_run`.
A job-level concurrency group keyed by the PR head SHA cancels older pending or
running notifiers, because the newest event sees the most complete PR state.
The group is on the job so skipped events cannot cancel a scheduled notifier.
The separate `PR review API access` workflow checks notifier changes using
the real read-only Actions token. It has no private environment or webhook
credentials and sends no webhook. Both workflows explicitly grant
`actions`, `checks`, `contents`, `pull-requests`, and `statuses` read access.
The GraphQL status rollup needs both check and commit-status permissions.
The notifier reads the current PR's status rollup and branch rules. Both
`ci / verify` and `ci / e2e`, plus any additional required checks, must succeed.
Other checks must succeed or finish as skipped or neutral. Optional skipped or
neutral checks permit an assessment; they do not prove successful verification.
For example, a neutral scanner can mean coverage is unavailable. The advisory
routine evaluates these evidence gaps and reviews before recommending merge.
Workflow names do not exclude checks. The notifier and CI profile exporter run
on the default branch, so their own checks do not belong to the PR revision.
Check names alone are not trusted. Both core aggregates must belong to the same
GitHub Actions suite and a successful run of the repository's actual
`.github/workflows/pr.yml`. Its run metadata must reference
`pr-trusted.yml@master`. The complete PR file list must prove that the caller
`pr.yml` was not changed or renamed. A missing or truncated list stops delivery.
PRs that change this caller need a manual advisory review until their caller
change reaches the trusted branch. Other workflow changes can still be reviewed.
A final refresh rejects head or base changes during evaluation. Paperclip's
idempotency receipt deduplicates events for the same PR, head, and base. A new
base can produce a new review. The routine must refresh again before its final
recommendation. Event delivery and host check visibility can race; notifications
are best effort, with no polling or delivery guarantee.
The HTTP request uses TLS, a bearer header, no redirects, and a 15 second timeout.
Logs contain fixed delivery states only. Request and response bodies, URLs,
tokens, and raw exceptions are never logged. Missing secrets skip delivery.
Run the focused regression checks with:
```sh
node --test .github/scripts/tests/notify-pr-merge-review.test.mjs
```
For setup verification, use Paperclip's webhook `setupPending` test mode before
activation. This verifies authentication and ingress without starting a review.
After the workflow is merged to `master`, inspect a real successful workflow
delivery and its linked routine task to prove end-to-end operation. A saved
configuration or a setup test alone does not prove that journey.
To pause reviews, pause the routine in Paperclip or disable its trigger. To stop
notifications, disable this workflow or remove the two environment secrets.
Rotate the trigger signing secret in Paperclip and replace the token environment
secret together. Keep the URL secret even though it has no embedded bearer token.
+47 -6
View File
@@ -97,6 +97,12 @@ When an agent creates a subtask or follow-up task, its source task's feed shows
a linked card at creation time with the current title, task number, and status.
The Tasks tab keeps the full relationship list.
Agent-owned tasks show **In Progress** only while a task-linked run is queued
or running. When execution ends, their visible status reflects recovery
(**Blocked**), a pending response or review (**In Review**), monitoring, or
remaining work (**Todo**). The stored lifecycle status still controls execution
and recovery. Human-owned tasks retain their selected ownership status.
Tasks waiting between automatic checks show **Monitoring** with a blue clock.
Task lists, board columns, filters, search, recent navigation and task properties
use the same read-only display state. The task retains its execution lifecycle
@@ -141,13 +147,34 @@ the latest agent that assigned it to them. If no assignment is available, it use
the latest other commenter, or leaves the recipient empty. A manual recipient
choice stays selected while task data refreshes.
Task reply and new-task composers submit with Enter by default; Shift+Enter
adds a new line. Settings → Experimental → **Command/Ctrl+Enter to Submit**
restores the previous behavior, where Enter adds a new line and Command/Ctrl+Enter
submits.
### Routine responsibility
The routine Overview shows its responsible user beside Default agent. People with
`tasks:assign` can select an active company member from the name's picker.
The change creates a routine revision and applies to future automatic tasks.
Existing tasks keep their responsibility. Manual runs use the person who starts
them. Agents cannot transfer routine responsibility.
### Task collection creation filters
Tasks opens All tasks for new users. A saved view or an explicit view in the URL
takes precedence over this default.
The task table orders rows by Last updated, newest first, without highlighting a
column header. Selecting a column sort activates its header and direction arrow;
explicit sort URLs retain that selection.
The task Filters menu also offers **Routine tasks**. It selects tasks with
`originKind=routine_execution`, including manually triggered routine runs, even
when other automatic tasks are hidden. The routines list's **View all tasks**
link opens `/issues?view=all&originKind=routine_execution&sortField=created&sortDir=desc` with that checkbox
selected. The URL retains the selection across reloads and task navigation.
Coordinator task labels use repeated `label` URL parameters so selected labels survive reloads and shared task-list links.
Sort controls save `sortField` and `sortDir` in the URL. URL sorting overrides
saved collection sorting, and the routines link opens newest-created tasks first.
Shared sort links work across Coordinator, Streamlined UI, and Classic UI, including
@@ -371,17 +398,17 @@ allocation retains one selected number and individually enabled groups.
See [iMessage Photon](connections/IMESSAGE-PHOTON.md) for the implementation
contract, setup, recovery, boundaries, and qualification status.
### Experimental persistent agent conversations
### Persistent agent conversations
Agents can deliver proactive task and routine results into their own chat with
the task's responsible human. A morning PR digest arrives as an agent message
with a source-task link and unread indicators, even before the first human
message. The person can reply using the digest as context. Publishing a message
and completing work are separate actions; sending never starts or interrupts a
chat turn. Delivery obeys membership, run authority and task privacy, and stays
behind the Agent Chat setting. This version delivers in-app only.
chat turn. Delivery obeys membership, run authority and task privacy. This version delivers
in-app only.
Agent Chat is an opt-in core task presentation (`enableAgentChat`, off by default). Each person has one persistent task-backed conversation per agent and company, with ordinary company task visibility. The shared task composer, transcript, tools, files, and document panel remain the interaction surface. Agents clarify goals and hand substantial execution to linked, assigned tasks; a reply ends a turn without completing the conversation. `/new` starts fresh provider context in the same conversation while preserving visible history and artifacts. Healthy idle conversations wait for a message and do not count as unfinished execution work. See `doc/plans/2026-09-10-agent-chat.md` for the implementation contract.
Coordinator is the standard workspace for everyone. Settings → Experimental offers a personal **Classic workspace** opt-in, off by default. Historical Coordinator experiment settings do not enable Classic. Agent Chat is available to everyone on new and existing instances. The retired `enableAgentChat` key remains accepted for compatibility; stored and managed off values are ignored. Each person has one persistent task-backed conversation per agent and company, with ordinary company task visibility. The shared task composer, transcript, tools, files, and document panel remain the interaction surface. Agents clarify goals and hand substantial execution to linked, assigned tasks; a reply ends a turn without completing the conversation. `/new` starts fresh provider context in the same conversation while preserving visible history and artifacts. Healthy idle conversations wait for a message and do not count as unfinished execution work. See `doc/plans/2026-09-10-agent-chat.md` for the implementation contract.
### Agent chat project handoff (2026-09-11)
@@ -417,13 +444,27 @@ with the task list's filters and a sort control. The agent's artifacts are a
second card stack available from the panel's **+** menu. Both open in a new tab
so the conversation stays open.
### Coordinator Inbox and personal task views
Coordinator navigation uses **Chat**, **Tasks**, and **Inbox**. Inbox uses the
inbox icon and keeps the existing decision feed, its count, and decision history
at `/decisions`. The previous Inbox destination under More is removed.
Tasks defaults to **All tasks**, with a saved view per person and company. Mine uses
the previous Inbox's personal participation rules, mixed attention items,
read/unread title weights, keyboard shortcuts, archive and undo, mobile
swipe-to-archive, grouping, and saved columns. Its existing preferences carry
over. Unread, Recent, Blocked, and Everything are also task views. Organization
views retain the task table and board. The table saves column selection and
order per person and company. Old `/inbox/*` links redirect to their Tasks view.
### Combined Inbox + Task List
An opt-in experimental setting (`enableCombinedInboxTasks`, off by default)
In the Classic workspace, an opt-in setting (`enableCombinedInboxTasks`, off by default)
folds Inbox into Tasks. The Inbox nav row goes away and its unread badge moves
to Tasks. A Views menu on the task list covers the inbox views (Mine, Unread,
Blocked, Recent, Everything) and the task-status views (All, Active, Backlog,
Done). Bare `/issues` opens the last-used view, defaulting to Mine; links that
Done). Bare `/issues` opens the last-used view, defaulting to All tasks; links that
carry a task filter open All. Old `/inbox` links redirect to the matching view.
### CSV file previews
+11 -2
View File
@@ -221,9 +221,18 @@ If `@cryppadotta` is not the right reviewer identity in the public repo, change
## 8.1. Let the Lockfile Bot Merge Lockfile Refreshes
`.github/workflows/refresh-lockfile.yml` regenerates `pnpm-lock.yaml` from the manifests on `master` and merges the result without CI or review. Contributors never commit the lockfile, so the bot is the only path for lockfile changes.
`.github/workflows/refresh-lockfile.yml` regenerates `pnpm-lock.yaml` from the manifests on `master` and merges the result without CI or review. It also regenerates the sandbox provider lockfiles, `scripts/typecheck-sandbox-providers/pnpm-lock.yaml` and `packages/plugins/sandbox-providers/daytona/pnpm-lock.yaml`, in the same pull request. `LOCKFILES` in `.github/scripts/lockfile-trust.mjs` and in the workflow lists all three. Only trusted environments write lock content, and contributors never commit it. Two writers can change these lockfiles:
Resolution keeps locked versions, but it picks the newest matching version for an added or widened dependency. A release-age gate (`.github/scripts/check-lockfile-release-age.mjs`) therefore checks every package version that the refresh adds. The bot merges only when each one has been on npm for at least 3 days. If any version is newer, or the gate cannot verify it, the bot leaves a comment on the PR and does not merge. A maintainer can review and merge it, or the daily scheduled refresh merges it after the versions are old enough. A held run saves its lockfile, and the hash of the `master` lockfile it started from, as an artifact. The next run resolves from that lockfile only while `master` still has the same lockfile. Held versions therefore stay put while their manifests are unchanged, even when newer releases appear, and a lockfile change on `master` replaces them. The gate also holds any added entry whose resolution is not a plain npm registry tarball. Change the age with `--min-age-days` in the workflow.
- this workflow, on its `chore/refresh-lockfile` branch
- Dependabot, but only when the lockfile in its pull request matches a verified Dependabot commit
The reporting-lock proof (`tests/runner-e2e/report-lock.json` and `report-lock.patch`) controls the lock that paid evaluation jobs install, so it counts as lock content too. Only this workflow writes it.
`.github/scripts/lockfile-trust.mjs` enforces these rules in the `pr-trusted.yml` policy job. The job runs the copy of the script from the current tip of the base branch, which is the first parent of GitHub's merge commit. A pull request therefore cannot change the check that judges it. The job does not use `pull_request.base.sha`, because GitHub does not update it on every push. To update a Dependabot pull request that is behind `master`, comment `@dependabot recreate`. Do not merge `master` into its branch, because a merged lockfile no longer matches Dependabot's commit.
After a Dependabot lockfile merges, this workflow renews the proof. It first confirms three things: GitHub signed the merge commit, Dependabot opened the pull request, and the lockfile at the pull request head matches a verified Dependabot commit. It then records the merged lockfile in the proof and merges the refresh like any other. Paid evaluation jobs refuse the new lockfile until that refresh merges.
Resolution keeps locked versions, but it picks the newest matching version for an added or widened dependency. A release-age gate (`.github/scripts/check-lockfile-release-age.mjs`) therefore checks every package version that the refresh adds to any of the lockfiles. The bot merges only when each one has been on npm for at least 3 days. If any version is newer, or the gate cannot verify it, the bot leaves a comment on the PR and does not merge. A maintainer can review and merge it, or the daily scheduled refresh merges it after the versions are old enough. A held run saves its lockfiles, and the hashes of the `master` lockfiles it started from, as an artifact. The next run resolves from those lockfiles only while `master` still has every one of them unchanged. Held versions therefore stay put while their manifests are unchanged, even when newer releases appear, and a lockfile change on `master` replaces them. The gate also holds any added entry whose resolution is not a plain npm registry tarball. Change the age with `--min-age-days` in the workflow.
The merge uses a dedicated GitHub App. Do not reuse `commitperclip` or bypass the built-in `github-actions` app. Those identities are available to other workflows, including release jobs that run dependency install scripts, so a bypass for them would let any of those workflows land code on `master`.
+96 -23
View File
@@ -39,6 +39,7 @@ These decisions close open questions from `SPEC.md` for V1.
| Org graph | Strict tree (`reports_to` nullable root); no multi-manager reporting |
| Visibility | Issues remain company-open by default. Opt-in `private` issues use task ACLs; deployment exposure flags remain independent of work-object privacy. |
| Communication | Tasks + comments only (no separate chat system) |
| Composer shortcuts | Task replies and new tasks submit with Enter; Shift+Enter adds a new line. The off-by-default `enableLegacyComposerSubmit` preference in Experimental settings restores Command/Ctrl+Enter submission and Enter for a new line. |
| Experimental voice transport | Speko browser voice can create or resume a task through the existing chat-connection delivery/publication pipeline. The selected Paperclip agent retains execution authority. Phone support remains gated on live continuation qualification. |
| Task ownership | Single assignee; atomic checkout required for `in_progress` transition |
| Task watchdogs | A task watchdog is an explicitly configured, issue-subtree-scoped verification and recovery capacity. It may restore live task paths inside the watched subtree; for issue-thread interaction resolution it is an ordinary agent subject to the same audience and containment checks, not board authority, active-run output monitoring, or general liveness recovery. |
@@ -428,6 +429,7 @@ retain their titles and default to no generation request.
- `visibility` enum-like text: `open | private`, default `open`
- `privacy_root_issue_id` uuid fk `issues.id` null; private-boundary metadata
- `privacy_parent_issue_id` uuid fk `issues.id` null; downward access inheritance, including standalone run-created handoffs
- `origin_issue_id` uuid fk `issues.id` null, on delete set null; audit-only creation provenance (see below), never hierarchy or authorization
- `title` text not null
- `description` text null
- `status` enum: `backlog | todo | in_progress | in_review | done | blocked | cancelled`
@@ -441,9 +443,11 @@ retain their titles and default to no generation request.
- `created_by_user_id` uuid fk `users.id` null
- identifier fields: `issue_number`, `identifier`
- origin fields: `origin_kind`, `origin_id`, `origin_run_id`, `origin_fingerprint`
- Creation stores the actor run in `origin_run_id` unless an explicit origin run is supplied. `GET /api/companies/:companyId/issues?createdFromIssueId=<uuid>` selects tasks created by runs bound to that source task, using native run issue identity or persisted legacy task context. Historical rows without an origin run may use their recorded creation activity; comments and shared creators do not establish provenance. Source, run, activity and result are company-scoped.
- Creation stores the actor run in `origin_run_id` unless an explicit origin run is supplied. `GET /api/companies/:companyId/issues?createdFromIssueId=<uuid>` selects tasks whose `origin_issue_id` is that source task. Only for rows without a recorded `origin_issue_id`, it falls back to tasks created by runs bound to the source task, using native run issue identity or persisted legacy task context; a recorded origin suppresses that fallback, so the list agrees with `createdFrom`. Historical rows without an origin run may use their recorded creation activity; comments and shared creators do not establish provenance. Source, run, activity and result are company-scoped.
- Delegated follow-ups keep their structural parent. When an agent run bound to an ordinary execution task creates an issue and omits `parentId`, the server defaults `parentId` to that run's task and records `parentDefaultedFromRunIssue: true` on the `issue.created` activity. An explicit `parentId: null` keeps the task standalone. The default never applies to conversation runs (chat handoffs stay top-level), to watchdog follow-ups, to board/user creates, to a run task the agent may not mutate, when the default parent would create a delegation cycle with the requested assignee, or when the agent may not create a child under the default parent (for example a protected assignment policy without a grant); those requests stay standalone instead of failing. An explicitly requested parent is still subject to the ordinary assignment check. Structural parentage, creation provenance and blockers stay distinct: the default adds no blocker.
- `GET /api/issues/:id` returns `createdFrom` (`{ issue: { id, identifier, title, status }, run: { id, agentId }, agent: { id, name } | null } | null`): the task the origin run was executing when it created this issue, resolved from `origin_run_id` or, for historical rows, the run on the recorded creation activity. It is present whether or not `parentId` is set, and only when the viewer may read the source task and its run; a private, hidden-from-viewer or cross-company source yields `null`, indistinguishable from missing provenance.
- Agent-created task trees are at most two levels (`MAX_AGENT_TASK_HIERARCHY_DEPTH`). When an agent, or automation acting for one (runner and project tools, chat suggestions, native delegation, accepted-plan decomposition, decision effects, plugins, external agents), would create or move a task to a third level, the server does not reject it: the task is placed under the requested parent's ancestor at the allowed depth, a sibling of the requester, and the create/update response and activity carry `parentAdjusted`. An agent move of a task that has its own children skips only the parent change (`reason: "has_children"`). The requester keeps blocking (`blockParentUntilDone` blocks the requester), workspace inheritance and privacy inheritance; provenance follows the `origin_issue_id` rule below. Placement never moves a task above a low-trust boundary root that contains the requester. The ancestor is written only when the caller authorized it and confirms it with `expectedParentId` (the issue routes, the runner `create_task` tool, and nested chat suggestions do); other callers get a standalone task instead (an unconfirmed move skips only the parent change), so no edge lands under an unchecked ancestor. Board users, watchdog and recovery machinery, channel-boundary placement and human-configured routines may nest to any depth; existing deeper trees stay valid and fully readable.
- `origin_issue_id` and `origin_run_id` are server-set only: the task the creating run was bound to (a chat included), otherwise the chat that handed the task off, otherwise the requested parent (before placement); and that run. Privacy still follows the requested parent. Because `origin_issue_id` is returned with the task, it is recorded only when it reveals nothing new: the task's own parent or privacy parent (already exposed on the task), or a fully public origin (open, outside private projects and subtrees, not hidden); otherwise it stays null and `createdFrom` resolves the source through the run with its access checks. They never change after create.
- `GET /api/issues/:id` returns `createdFrom` (`{ issue: { id, identifier, title, status }, run: { id, agentId } | null, agent: { id, name } | null } | null`): the task that created this issue, resolved from `origin_issue_id`, otherwise from the task the origin run was executing (`origin_run_id`, or for historical rows the run on the recorded creation activity). It is present whether or not `parentId` is set and requires that the viewer may read the source task. A recorded `origin_issue_id` is returned with `run` and `agent` null when no run created the task or the viewer may not see that run, so a null `run` is not missing provenance; a source resolved only through a run additionally requires that run to be visible. An unreadable, hidden-from-viewer or cross-company source yields `null`, indistinguishable from missing provenance.
- Relation lists can use `sortField=id&sortDir=asc&afterId=<uuid>` for stable pagination. The cursor excludes earlier IDs and cannot be combined with an offset or activity-based order.
- The streamlined task page's Tasks tab keeps two independent memberships: the existing subtask tree, and created tasks grouped by their current project (or No project). A created subtask appears in both. Only Subtasks has completion progress; groups collapse independently and unfinished tasks sort above finished tasks.
- `request_depth` int not null default 0
@@ -451,6 +455,7 @@ retain their titles and default to no generation request.
- `standard`: normal autonomous execution. Agents may investigate, edit files, create artifacts, and complete the task.
- `ask`: answer-only execution. Agents may use tools for investigation or temporary scratch work, but the deliverable is an issue-thread answer; they must not write implementation code or produce an implementation plan.
- `planning`: plan-only execution. Agents create or revise the plan without implementation work. Accepting a fresh confirmation for the issue's current `plan` revision atomically changes this mode to `standard`, so the continuation may implement the approved plan on the source issue.
- Harness-proposed plans: a native run's own proposed-plan surface (Codex plan collaboration mode `plan` item or `<proposed_plan>` block, Claude Code ExitPlanMode) emits the canonical `plan.proposed` event. A completed proposal is saved as the issue's `plan` document and gated by a `request_confirmation` bound to that revision, in `planning` and `standard` work mode alike. A `planning` run starts the harness in its native plan mode. Synchronization never changes the issue status mid-run; the run finalizer turns the pending confirmation into the review wait. An agent-initiated proposal on a `standard` issue leaves the work mode unchanged.
- Work mode is explicit persisted state. Titles, descriptions, and requested deliverables never select or change it. A `standard` task may deliver a requested plan, including a canonical `plan` document, and complete without entering `planning` mode. Existing explicit approval requirements still apply.
- `billing_code` text null
- `assignee_adapter_overrides` jsonb null
@@ -467,6 +472,7 @@ Invariants:
- single assignee only
- task must trace to company goal chain via `goal_id`, `parent_id`, or project-goal linkage
- `in_progress` requires assignee
- agent task presentation reserves **In Progress** for a task-linked queued or running run; inactive tasks project recovery as **Blocked**, pending responses/review as **In Review**, scheduled checks as **Monitoring**, or remaining work as **Todo** without changing lifecycle/admission state
- an `in_review -> done | cancelled` verdict is authorized against the current review policy while the issue row is locked; a policy change in the same request or a concurrent request cannot relax that verdict gate
- accepting or rejecting the review-confirmation interaction locks the issue row before resolving the interaction and reauthorizes against the current review policy in that transaction
- accepting a fresh `request_confirmation` for the current issue's `plan` revision changes `work_mode = planning` to `work_mode = standard` in the same transaction as the accepted interaction; the existing agent-return transition also moves an eligible `in_review` issue to `todo` without changing its agent owner
@@ -1757,7 +1763,20 @@ publication is fenced separately from settlement. Inputs arriving or committing
later stay pending; no global sequence watermark consumes them. Limited-budget
admission refuses relevant unevaluated inputs older than fifteen seconds.
Unchanged idle decisions do not expire; unknown accounting retains its separate
conservative admission restriction. Provider reporting and cancellation delays
conservative admission restriction. Runs that ended without succeeding and
await accounting hold new work only: they never open a hard-stop incident, pause
a scope, or stop running work. Only spend at the limit, or unpriced usage under
a blocking policy, does that. A succeeded run with only its usage report
outstanding, whose provider already reported observed usage, does not hold new
work: its provider work is over, so it is bounded like a running run. A
succeeded run with no observed cost still holds new work. Its observed cost counts toward spend and reservation
capacity, and its settled charge can still reach the hard stop. A run that
failed, was cancelled, timed out or was interrupted still holds new work,
because its provider may not have stopped. Temporary holds (accounting that awaits
settlement, recovering runs, budget inputs that await evaluation) gate new work
only. They never refuse an admitted run's own tool mutations or an owned
recovery; a hard stop or pause still does, through the normal pause and
cancellation path. Provider reporting and cancellation delays
remain additional to evaluation lag. Cancellation delivery is asynchronous and
scoped; committed per-run Stop survives policy changes and restart until existing
stop proof succeeds. Budget resume cannot clear a manual hold.
@@ -1872,6 +1891,23 @@ Native runs retain final usage receipts during a bounded accounting-only drain
after a governed wait cancels provider work. This does not accept late messages,
tool calls, or new completion proposals. Missing or incomplete receipts continue
to block budget admission.
After native recovery reaches terminal failure, an incomplete receipt holds new
work as recovering accounting; it does not open a hard-stop incident. When the
run no longer records a provider process (recovery clears it only after a
verified stop), no producer can deliver the receipt. The same holds after the
run's result was accepted. A remote run reaches the
same point when every sandbox allocation it used carries a provider
termination receipt (stopped or destroyed); a sandbox that may still run keeps
the debt pending. Accounting then records
its known part as an unpriced charge, so the unpriced-usage policy governs
admission. A later receipt still posts through late-receipt reconciliation.
When a lost receipt's observed usage has a known model and token counts, the
rate card prices it as an estimate whose provenance marks the usage as a lower
bound. A charge settled from a lost receipt never trips the blocking
unpriced-usage policy by itself, and neither does an unpriced charge with a
known partial cost or with no observed tokens on metered billing. A task's own
continuation and finalization are never held by that task's pending
accounting.
A controller that detaches for server restart loses checkpoint and completion
write authority. A closing event stream is not proof that the governed run
finished; the replacement controller must adopt and settle the original run.
@@ -1922,8 +1958,26 @@ prices require no cleanup and must not be presented as zero-cost receipts.
board cost reader with a non-viewer membership, and uses the existing audited,
idempotent accounting service. It never executes an agent. The UI offers retry
only when the saved run is eligible to record costs. Missing final usage changes
its display label after fifteen minutes; elapsed time never settles the debt or
discards a late receipt. Raw provider errors and credentials are excluded from
its display label after fifteen minutes. Elapsed time never discards a late
receipt. It settles the debt only for a native run whose recovery has ended
(terminal failure or an accepted result) and whose closure evidence has not
arrived after ten minutes (the remote runner reconnect grace plus a margin): the known usage is recorded as unpriced with
provenance saying closure evidence never arrived, an activity entry names the
run, and the blocking unpriced-usage policy keeps counting that charge unless
a control-plane restart caused it. A restart-derived receipt (the run replaced
its receipt source, recovered from a restart, or lost a provider turn on
restore) never hard-stops or pauses a company under the blocking policy,
whatever its token count; its activity entry and provenance remain the notice. A lost
receipt that already carries a provider-reported cost settles at that cost as
an estimate marked as a lower bound, not as unpriced. A failed native run that
never started provider work (no usage receipt, no replaced receipt source, and a
local process that never launched or was verified stopped) settles at once
with no charge and never holds admission; the empty receipt-source fence map
that every usage recorder binds is not evidence that provider work began. A native
turn that the restored Runner reports as lost on restore can never report its
usage. It does not hold the run's receipt open: the receipt closes when the
run's other turns do, and its provenance says the observed usage is a lower
bound. Raw provider errors and credentials are excluded from
cost details. ACP usage retains the runtime-selected model, including defaults;
conflicting before/after model IDs do not authorize a single-model estimate.
Remote OpenCode billing uses only credentials forwarded to the execution target.
@@ -1969,6 +2023,12 @@ Dismissal never creates an answer, decision, execution, or continuation wake.
Raw runtime requests retain browser-local dismissal; reopening restores the
original request and draft. A failed save leaves the request open with an error.
Sending a separate message closes the current composer request immediately,
before the send finishes. After a successful send, earlier requests stay in the
feed and can be reopened manually; a newer request can still open normally.
If sending fails, restore the previous form unless the person has opened a
different request. This presentation change does not answer or approve a request.
A saved ordinary question becomes historical after a newer human task message
or explicit human dismissal. It remains answerable after completion without
reopening work or waking the agent. Dismissed ordinary requests, including native
@@ -2154,7 +2214,7 @@ Agent appearances are stable, versioned ClipLab end-cap personas, separate from
behavioral instructions. Compact surfaces use on-demand cached PNG URLs; larger
placements may use a lazy live character. See [agent-personas.md](agent-personas.md)
for persistence, migration, rendering, and integration contracts.
### Experimental task-backed agent chat (2026-09-10)
### Task-backed agent chat (2026-09-10)
Ordinary task and routine runs in standard mode may publish with
`send_chat_message({body,idempotencyKey})` or `POST /api/agents/me/chat/messages`.
@@ -2187,10 +2247,10 @@ Chat list/detail responses use per-user read state before human participation;
the foreground chat marks rendered messages read through an optional comment
cutoff, retaining unseen concurrent arrivals. Existing navigation ordering is
preserved with accessible unread dots and an aggregate Chat indicator.
`enableAgentChat` gates delivery; external channels, push/email and private-chat
Agent Chat is available to every person; external channels, push/email and private-chat
redesign are outside this contract.
`enableAgentChat` is an instance experimental flag, default false. Conversation containers remain issues, unique by `(company_id, conversation_agent_id, conversation_user_id)`. The authenticated board actor supplies ownership; local trusted mode uses `local-board`. Ordinary company task access applies. A conversation's agent assignment and identity are immutable through ordinary updates; terminal status mutations are rejected.
`enableAgentChat` is a retired compatibility key. Agent Chat is always available for Cloud and self-hosted instances, including existing people and instances with stored or managed off values. Conversation containers remain issues, unique by `(company_id, conversation_agent_id, conversation_user_id)`. The authenticated board actor supplies ownership; local trusted mode uses `local-board`. Ordinary company task access applies. A conversation's agent assignment and identity are immutable through ordinary updates; terminal status mutations are rejected.
`GET /api/companies/:companyId/chats/:agentRef` reads an existing conversation or null. `POST` atomically resolves its issue when adding a chat or on first send/upload. `GET /api/companies/:companyId/chats` lists only the current board user’s conversations in that company, subject to ordinary issue read access. The Chat navigation opens a searchable secondary sidebar with agent avatars and a picker for starting or reopening the same per-agent conversation. Existing issue comment, attachment, document, interaction, and run APIs apply thereafter. User chat comments require an idempotent UUID `clientRequestId`. Conversation delivery preserves comment order through the existing issue execution queue; the durable comment outbox repairs the commit-to-enqueue crash window.
@@ -2206,7 +2266,7 @@ in decision history.
Standalone `/new` is an ordered queue command with no model response. It advances a durable session generation and boundary comment, resets only this issue's provider context, and preserves the issue ID and history. Generation checks reject stale context writes and replies. Fresh replay excludes earlier messages and summaries. The shared transcript renders a session divider.
Chat prompts retain agent instructions and tools while directing clarification and task creation. Substantial execution belongs to linked, assigned ordinary issues. Ask mode remains non-mutating. Feature disablement prevents new turns and resets while retaining data and lifecycle protection; already-running turns may settle normally.
Chat prompts retain agent instructions and tools while directing clarification and task creation. Substantial execution belongs to linked, assigned ordinary issues. Ask mode remains non-mutating. The retired feature flag cannot prevent turns, resets, or discovery. Ordinary authentication, company and conversation ownership, privacy, run authority, and execution admission checks still apply.
### Agent chat project handoff (2026-09-11)
@@ -2842,22 +2902,35 @@ connection. Settings → Connections is permanent; valid model verdicts take
precedence and dependency failures use the original built-in detector.
### Personal experimental workspace preference
### Personal workspace preference
Settings → Experimental can opt the current human into the Coordinator workspace.
The choice defaults off and is saved per user across companies on an instance in
`user_workspace_preferences`; it is not an instance feature or a company capability.
Turning it off restores the classic shell without modifying canonical tasks, agents,
artifacts, permissions, or runs. Local-trusted development uses the existing shared
`local-board` identity, explicitly identified in Settings. Anonymous and agent actors
cannot read or update this personal choice.
Tasks defaults to All tasks when the viewer has no saved task view. Explicit
view URLs and existing saved task views retain precedence.
Task tables default to Last updated descending without an active sort header;
an explicit column sort selects the header and shows its direction.
Coordinator is the standard workspace for every person. It is not an experiment.
Settings → Experimental offers **Classic workspace**, a personal opt-in that
defaults off. Existing Coordinator experiment values, including historical off
values, do not opt anyone into Classic. The choice is saved per user across
companies on an instance in `user_workspace_preferences.classic_workspace_enabled`;
it is not an instance feature or a company capability. Turning Classic off returns
to Coordinator without modifying canonical tasks, agents, artifacts, permissions,
or runs. Local-trusted development uses the existing shared `local-board` identity,
explicitly identified in Settings. Anonymous and agent actors cannot read or update
this personal choice.
`GET /api/workspace-preferences/me` reads only the caller's choice. For client
compatibility, `coordinatorWorkspaceEnabled` reports the inverse of the Classic
opt-in. The old stored Coordinator column is retained but no longer selects the
workspace. `PUT /api/companies/{companyId}/workspace-preferences/me` saves the
Classic choice through that same inverse property with authorized company audit
context; active viewers may update their own presentation. The preference and
sparse audit event commit atomically. Client caches are keyed by human identity,
and switching waits for successful persistence; unavailable preferences use the
standard Coordinator workspace. This preference does not replace
`enableClassicTaskInterface`.
`GET /api/workspace-preferences/me` reads only the caller's choice.
`PUT /api/companies/{companyId}/workspace-preferences/me` updates it with authorized
company audit context; active viewers may update their own presentation. The preference
and sparse audit event commit atomically. Client caches are keyed by human identity,
and switching waits for successful persistence; unavailable preferences fall back to
the classic shell. This preference does not replace `enableClassicTaskInterface`.
## Agent connection administration (2026-10-13)
Standard agents can configure connections through the same domain APIs used by the app, bounded by their grants and their responsible user's current authority. Fresh standard agents for default Operators can manage app/tool connections, chat/email channels, AI accounts and pools, profiles and gateways without additional grants. Existing grants are not backfilled; explicit restrictions persist. Gateway administration retains the responsible human's administrative permission and does not grant agents blanket `tools:admin`.
+4
View File
@@ -691,6 +691,10 @@ The `create_project` runtime tool uses the normal project API with durable idemp
Confirmed project creation appears as a durable card in the shared task transcript, including selected repository links. Tasks are linked inline. Failed creation never produces a success card. Tool evals cover planning/handoff, project/repository selection, retries, permission and mode denials, and ordinary delegation regressions using the production chat directive.
Task replies and new tasks submit with Enter by default; Shift+Enter adds a new
line. The **Command/Ctrl+Enter to Submit** experiment restores the previous
keyboard behavior.
### Paused task messages
A paused task takes over the composer with an amber notice and a Resume action.
+14 -5
View File
@@ -151,6 +151,12 @@ provider; restart recovery preserves files until retirement is proven.
the host. Retry an unstable checkpoint up to three times; if it cannot be
validated, close the owned provider and perform the stopped collector. Other
execution paths retain their stopped-provider collection boundary.
File preparation uses the same admitted lifecycle policy as native execution.
An inherited environment policy can use the agent's warm default. For a new
admission, an explicit per-turn environment policy still wins. A persisted
admission keeps its pinned policy. A policy change does not mark an old
pending copy saved or grant access to a stopped or replaced lease.
4. Recheck the responsible user's current authorization. Under the same agent
lock used by editor writes, apply only files changed or deleted relative to
the last acknowledged baseline. For a competing edit or deletion of the same
@@ -333,12 +339,12 @@ unit or matcher results; a passing matcher does not prove a live run.
Run `server/src/maintenance/agent-files.ts` with the instance's database,
storage, secrets and original `PAPERCLIP_HOME`. The default migration phase is
read-only. Before applying, stop ingress and execution writers on that instance
and set `PAPERCLIP_DURABLE_AGENT_FILES=true`:
read-only. Set `PAPERCLIP_DURABLE_AGENT_FILES=true` before applying on the original
host. Migration uses the same agent row lock as file edits and admission:
```sh
pnpm exec tsx server/src/maintenance/agent-files.ts --phase=migration
pnpm exec tsx server/src/maintenance/agent-files.ts --phase=migration --apply --writers-stopped
pnpm exec tsx server/src/maintenance/agent-files.ts --phase=migration --apply
```
The production runtime build includes the same command without a TypeScript
@@ -349,8 +355,11 @@ Eligibility is a point-in-time check; keep writers stopped through volume remova
Follow each returned `next` cursor with `--after=<cursor>`. All pages must report
`pageReady: true` before removing the volume. A missing legacy directory, external
instructions or active execution prevents migration; resolve that condition on
the original host. Repeating apply verifies/restores the committed projection.
instructions, active execution without a durable head, or an unsettled private
local working copy prevents first adoption. An existing durable head needs no
local rewrite while its agent is active. Migration keeps copy recovery states
and retained provider references. The live eligibility gate separately refuses
unsettled local instruction copies, even after canonical agent files are durable.
This migrates agent files only; other storage families have their own eligibility
checks. Keep the original volume until the deployment migration is qualified.
+17 -3
View File
@@ -23,13 +23,27 @@ Metrics, raw API bodies and Codex Guardian assessments are disabled.
Native OpenCode 1.18.34 uses a runner-owned HTTP/JSON exporter. Each completed,
failed or cancelled turn produces a trace with Paperclip company, agent, run and
turn IDs. Tool outcomes and message-level token/cost receipts are child spans;
turn IDs. Authenticated Paperclip tool dispatch outcomes, native tool outcomes and
message-level token/cost receipts are child spans;
repeated usage updates replace the same message receipt. Missing prices remain
unknown. This projection excludes hidden reasoning, raw provider payloads and
system instructions. Prompt, response, tool-detail and tool-content switches
apply before export. Content is bounded to 8 KiB per field and 64 tools plus 64
usage records per turn; truncation is marked. Tool inputs and full model request
bodies are not captured. OpenCode's performance-only OTLP sink stays separate.
usage records per turn; truncation is marked. With both tool-detail and tool-content
capture enabled, Paperclip tool spans include `braintrust.input` diagnostics
(`paperclip.opencode.tool-diagnostics.v1`): the upstream `tools/call` method,
argument JSON type and types for a fixed list of envelope fields. These are
shape diagnostics, not a copy of the arguments. Arbitrary field names, argument
values, nested payloads and full model request bodies are not captured.
Authenticated schema rejections also produce failed tool spans without invoking
the controller handler. Conflicting request IDs produce separate failed items,
so a rejection cannot overwrite a previous dispatch outcome.
Paperclip tool spans use authenticated dispatch IDs and the name `tool <name>`.
Provider SSE records for those declared tools use `opencode tool <name>` and
remain available when failure occurs before dispatch, such as an oversized
request. These are separate observations, not correlated calls. The
`paperclip.tool.source` attribute distinguishes dispatch and provider records.
Native tools retain their provider spans without submitted-argument diagnostics. OpenCode's performance-only OTLP sink stays separate.
The exporter sends once at turn completion with a three-second deadline, at most
four requests in flight and no idle polling or persistent queue. Closing drains
+3 -1
View File
@@ -18,7 +18,9 @@ terminal tasks. Resume/restore retain the optional wake-agents checkbox.
## Resume and execution recovery
Resume releases a hold. Waking agents is optional and only applies to tasks in
Resume releases a hold. Work the pause interrupted is re-dispatched once
automatically, under current admission gates (see `doc/execution-semantics.md`).
Waking other agents is optional and only applies to tasks in
`todo`, `in_progress`, or `in_review`; parked and terminal tasks stay untouched.
The current execution-recovery policy requires verified outcomes before a stopped
provider can restart. If any affected task still needs that reconciliation,
+9 -3
View File
@@ -37,10 +37,10 @@ The manually configured Paperclip MCP server also advertises `connections_search
- Company, agent, run, task, and responsible user come only from the signed legacy runtime token or the native server-owned binding and stored execution identity.
- Tokens are scoped to connection intents, expire after one hour, and are rejected when the heartbeat is no longer running.
- The thread payload contains only service identity, requesting-agent identity, and a safe phase. It never contains credentials or authorization URLs.
- The thread payload contains service identity, requesting-agent identity, a safe phase, and an optional human-selected audience bound to an OAuth draft. It never contains credentials or authorization URLs.
- OAuth state is linked to the interaction. The same-origin callback finalizes the existing connection pipeline, posts only interaction ID/outcome to its opener, and redirects back to the task if there is no opener.
- Personal OAuth defaults to the addressed user and creates an explicit delegation to the requesting agent. Reuse and installs are additive.
- Task-hosted setup locks install reach to the requesting agent; the store host retains its normal broader access choices.
- New tool setup uses the same all-agents default and audience controls in Apps and task dialogs. Broadening the audience requires current connection-management authority. AI authentication and inbox setup remain agent-scoped.
- The intent resolves only after the connection, grant/delegation, profile access, and install succeed. Failures remain pending with `needs_retry`.
- Closing or reassigning the task expires pending intents and deletes linked OAuth state. Ordinary comments and later runs preserve the pending card. Requests reuse the same task, requester, addressed user and service; a different addressed user supersedes an older request for that service.
- Success and explicit decline atomically persist a continuation delivery with resolution. A leased startup/periodic worker dispatches through heartbeat with a unique `connection-intent:<interaction>:<outcome>` wake key. It checks assignment, status, membership and current executable access, retries paused/suppressed delivery, and recovers a crash after enqueue without creating a second wake. The continuation forces a fresh provider session; heartbeat queues it behind active execution.
@@ -71,7 +71,13 @@ Catalog slugs remain stable. Search also returns `connection:<uuid>` for configu
`ready` requires an installed, enabled, healthy executable connection, permitted catalog tools, and a usable runtime identity. An installed connection with denied actions is administrative denial rather than a request to reauthenticate. Runtime calls continue enforcing access after a historical card resolves. If access is ready but the native provider's pinned tool snapshot is older, `connection_request` queues a fresh session without another authorization card.
Task setup defaults to personal identity when supported and the requesting agent's install reach. Existing installs are additive. An OAuth callback from a task prepares the catalog without adding access. Intent completion validates the current task and identity, then adds the requesting agent’s binding and install in the resolution transaction. Callback messages do not establish authorization: the card reloads the durable server result. A blocked popup offers a new-tab fallback; closing or declining provider sign-in keeps the request retryable. Only the card's **Not now** action declines the request. Decline continuations are told to pursue alternatives and cannot immediately request the same service again.
New tool setup uses the normal Apps identity and audience choices, including access for all agents. Before OAuth navigation, the addressed human saves the selected audience bound to the draft connection. This save grants no access. The callback prepares the catalog, and intent completion rechecks task ownership, membership and sharing authority before applying the audience and resolving the request atomically. Older clients without a saved audience retain requester-only completion. Reusing an existing account keeps access additive. Callback messages do not establish authorization: the card reloads the durable server result. A blocked popup offers a new-tab fallback; closing or declining provider sign-in keeps the request retryable. Only the card's **Not now** action declines the request. Decline continuations are told to pursue alternatives and cannot immediately request the same service again.
Saving a tool connection's agent audience updates its permission profile and installations in the same transaction. This includes GitHub credential admission and newly hired agents. Explicitly narrowing or clearing the audience removes the corresponding installations. An access request without exact tool names can review an indexed catalog of up to 500 tools; explicit model-selected tool lists remain limited to 20. Existing action blocks still apply, and write tools granted by an access card require approval.
New task setup must include the requesting agent, even when the human chooses a broader audience. The form and authorization phase save reject an omitted requester before provider sign-in. Save & exit persists the audience on the interaction without granting access; an incomplete draft can still have no agents selected. Resuming OAuth with hidden audience controls retains the saved audience rather than submitting form defaults; gateway draft setup restores the saved selection into its visible controls.
Agent installation changes made by finishing setup use the same agent-edit checks as direct installation changes. Explicit task audiences are checked when saved and again at OAuth completion. Additive completion retains existing installations, including agents using a separate narrow profile; retaining an installation does not add those agents to the app's broader permission profile.
## Verification
+34
View File
@@ -9,12 +9,46 @@ Onboarding and new-agent setup retain upstream's `SavedProviderKeySelect` and
homes. Managed default/shared accounts are additional choices in that same
selector. Selecting “Sign in to another account” survives background refreshes;
Claude authorization paste keeps upstream's immediate Connecting feedback.
The onboarding sign-in link and footer use the same action. Either opens the
provider in a new tab and disables the footer with Waiting for auth… for OpenAI
or Waiting for code… for Claude. The Claude code field stays available. A Claude code entered without a paste event can be sent
with Submit code in the footer or Enter in the field. These actions submit to
the current login session and do not open another provider window.
Failed or expired sign-ins show a short message and a Retry sign-in footer
button. Retry releases the old session before starting a fresh sign-in. It keeps
the onboarding setup and clears the old code.
Returning from Review to Connect can change the existing agent's connection.
Connect verifies and saves the current selection before returning to Review;
it does not create another agent. A new API account selected in onboarding becomes
the user's provider default, so a previous subscription does not override that
choice. A failed default selection can retry the saved account without creating
another connection. Failed verification or saving keeps the user on Connect.
Switching back to subscription explicitly selects the completed subscription
account again. Browser sign-in uses its saved session result; local sign-in uses
its saved account result. Reusing the entered API key selects that account again.
Default changes for the same company and provider run in order. Connect waits
for earlier changes before deciding whether the selected account is already the
default. A failed or lost response cannot count as a confirmed default selection.
The unchanged-setup shortcut expires when another default selection is attempted,
including one that finishes after Back.
An agent that already uses a connected account cannot switch to a legacy secret
binding. Selecting one stays on Connect and directs the user to choose a connected
API account or connect a new key. Unmanaged setup can still reuse saved secrets.
New-agent Connect offers three persistent tiles: the provider's subscription,
the provider's API key, and Advanced. All three stay visible while the selected
mode's form is shown below. Advanced opens the existing compatible-connection
picker and provider setup. Execution environment selection lives in Configure.
Back from subscription sign-in cancels that attempt and pending prompt or status
reads. A session returned after Back is released instead of reopening sign-in. If an
aborted read hides the session ID, a separate owner lookup releases the attempt
before a replacement can start. Cleanup checks the abandoned environment and
account intent, so a conflicting settings sign-in stays active.
Selecting a subscription again starts a fresh attempt. Reloading the page can
still resume a sign-in that was not cancelled.
Storybook's simulated controllers and page annotations do not run in the app.
## Compatibility and selection
+5 -2
View File
@@ -24,8 +24,11 @@ to an internal issue tracker.
Inline task connection cards must use the same provider setup controller and
fields as Apps: URL guidance, authentication options, validation, and recovery.
Keep dialogs bounded to the form width on wide screens and scrollable on narrow
screens. A task request locks agent access to its requester and returns to the
card after completion; reusing an account must preserve its existing access.
screens. New tool connections default to all agents in both hosts, subject to
the human's connection-management authority. The human can select fewer agents.
Task OAuth saves that audience before sign-in and applies it atomically at
completion. AI authentication and inbox setup remain agent-scoped. Reusing an
account must preserve its existing access.
OAuth popups need a normal sign-in link fallback, and callback messages must be
verified against durable server state before accepting the request.
+34
View File
@@ -130,6 +130,40 @@ installation-health failure, not as token expiry.
## Repository access
GitHub can return `403 Resource not accessible by integration` inside an MCP tool
error even when the connection itself is healthy. A failed tool approval shows
**GitHub access needed** and **Manage GitHub access**, which opens that connection's
Permissions page. Check the connected account and the App's access to the intended
account and repository there. The original provider error remains in the action's
error details and diagnostic record. Paperclip does not replay the write or hide
other working tools. Saved GitHub approval errors receive the same presentation.
The agent's continuation wake carries server-written next steps and tells it
not to retry until access is repaired. It must never automatically replay a
stored approval.
Deferred historical errors receive the same guidance as their saved cards.
Direct MCP calls and connection tests receive the same method-specific repair
steps. A GitHub provider denial is an MCP tool error result, not an HTTP
authentication failure for the gateway. Invalid gateway credentials still fail
authentication before any provider call.
Repair steps are attached after provider output passes content checks, so model
checks inspect the provider's text without treating Paperclip's own instructions
as untrusted output. Unsafe provider responses still fail those checks.
For an App connection's `create_repository`, the agent checks the intended
repository owner first. The authenticated GitHub username and the accounts
where the App is installed are separate. An organization installation does not
grant access to the user's personal repositories. Personal repository creation
needs App access to that personal account; organization creation needs App access
to that organization and the tool's `organization` argument. The agent must
keep the owner requested by the user. The user can choose the intended account
from **Add More Repos on GitHub**, then select **Refresh access**. If creation remains
unavailable, the user can create the repository on GitHub and give the App access,
then the agent continues with that repository. Token connections ask
the user to check token permissions and repository selection, then update or
replace that token. If the method is unavailable, the agent asks the user to
check the connection's authentication method. GitHub's text stays in the
untrusted result.
OAuth completion verifies `/user`, every page of `/user/installations`, and every
page of each installation's accessible repositories. Setup remains incomplete
until at least one installation and repository are available. Paperclip stores
+8
View File
@@ -6,6 +6,14 @@ Docs, Sheets, and Slides), and Calendar. All three start enabled with read and
write access. Users can disable a group or choose read-only access. New setup
does not offer Chat, Contacts/directory, or cross-service Search.
The built-in connector is behind the default-off `enableGoogleWorkspaceConnector`
experimental setting while Paperclip's Google app verification is pending. When
the setting is off, the Apps page hides the built-in connector, and the server
blocks its setup, discovery, and execution. Google apps from MCP aggregators
(for example Composio Gmail, Google Calendar, and Google Drive) then appear in
the catalog instead. Saved per-service Google connections stay visible and usable.
Turn the setting on in **Settings → Experimental** to test the built-in connector.
The combined connection has one credential grant, capability catalog, policy
profile, audit trail and reconnect lifecycle. It requests only the selected
groups' scopes (nine scopes for the default selection), not the legacy full
+10
View File
@@ -102,6 +102,16 @@ subscribes to the Slack thread, and starts the agent; later replies in that thre
continue the same task without another mention. Normal account and channel access
rules still apply, and repeated Slack deliveries do not create duplicate tasks.
A native Slack thread keeps the same task in channels and DMs, including after
the task is Done or Cancelled. A new reply reopens that task with its saved
comments. DM access settings and task controls still apply to threads inside DMs.
An explicit `/new` or `/close` starts the next task generation only after its
control response is delivered. Task completion alone does not release the binding.
Top-level DMs keep their linear lifecycle and can start a new task after completion.
For a thread that was split before this fix, later replies continue its latest
saved task. Earlier tasks remain separate; this change does not merge history
or copy messages from the surrounding DM into a new thread.
Agents can ask interactive questions in Slack through Paperclip's saved
`ask_user_questions` interactions. Native runners use `request_human_input`;
legacy adapters use the issue-interactions API documented in the injected
+98
View File
@@ -179,3 +179,101 @@ An explicit managed reconnect reinstalls the saved app and verifies the replacem
bot credentials. Its request ID is saved before dispatch, so a duplicate completed
request does not reinstall again. Credential-configuration recovery still resumes
the saved token after a downstream failure. No reconnect creates another Slack app.
## Durable Cloud Events API delivery
A broker that advertises `slackEvents.version=1` can accept a signed Events API
registration after ordinary configuration settles. The producer uses the saved
child app ID, current workspace, signing key, endpoint/connection and runtime
generation. It retains the existing public callback URL. Missing verified app
identity or broker support leaves the existing setup in place; it does not guess
an identity or substitute the Slack manager's signing key.
Cloud stores the child signing key through its existing encrypted stack vault.
Registration metadata and receipts contain no plaintext credentials. An unchanged
credential/endpoint identity reuses its immutable vault ref across generations;
rotation and current registration authority remain separately fenced. Removal
retires exact queue membership and prevents an old generation from reenrolling.
The local configuration transaction first saves nonsecret enrollment or
retirement intent and signals the existing connection-events owner. Broker work
runs through that owner after commit; configuration APIs do not await an optional
Cloud request. Configure, pause and removal can complete locally while Cloud is unavailable. The owner
retries only retained due intent and reconstructs it after restart, including
removal for archived endpoints after credential cleanup. Confirmation replaces
only the same current intent and generation; a completed remote call with a
lost response remains pending. Current authorization errors retain visible
attention debt instead of claiming registration succeeded. Event draining and
acknowledgments run before enrollment reconciliation. The owner then admits one
due enrollment operation and schedules the earliest retained responsibility.
A busy credential lease or enrollment failure retains a bounded retry without
aborting that event drain. Rollback leaves no committed intent to run.
Retirement sends the original responsible subject and the exact instance,
company, endpoint, public ID, connection and monotonic generation. Cloud can
publish a nonreceiving generation floor without a signing key, live descriptor
or previously returned binding ID. This fences delayed enrollment after local
removal. Normal current broker authority still applies; the subject alone does
not authorize an operation. An instance that never enrolled with Cloud creates
no new intent or background retry when Cloud is absent. Previously configured
retirement responsibility retains its original instance identity as visible
attention if that identity becomes unavailable. Unsupported capability or disabled
new enrollment retains bounded retry responsibility; a closed generation or
identity conflict requires attention. Current authorized confirmation is required
before clearing the exact pending intent.
Ordinary redeploys retain the same stack identity. To move enrollment to another
stack, first confirm retirement under the old stack's authority, with no pending
old intent. Then explicitly reconfigure under the new authorized stack at a newer
runtime generation. The old stored identity remains until the new broker receipt
matches the current intent and generation. An environment change cannot transfer
live responsibility. Unavailable old authority remains visible attention.
A missing or disabled signing credential also requires attention. Enrollment does
not retry it as a broker outage. Incoming receipts retain typed attention before
SDK admission; uncertain authentication does not acknowledge the receipt.
Only authenticated `event_callback` messages use this durable lane. The sealed
queue retains original bytes, signature, timestamp and acceptance time. The app
checks current endpoint/generation authority before SDK handling. It verifies
original freshness at Cloud acceptance; ordinary external requests still use
current time. The credential snapshot lease releases before normal lifecycle
callbacks reacquire their own leases.
Private replay provenance prevents internal replay URLs from replacing saved
external callback verification evidence. The normal transactional chat delivery
ledger remains the message admission authority. An ambiguous callback or ACK
failure retains its receipt; malformed receipts rejected before useful admission
can be explicitly ignored with bounded diagnostics. Credential-authentication
uncertainty requires attention and is not silently acknowledged. The broker's
existing retention horizon remains the disclosed recovery boundary.
This does not qualify a complete Slack endpoint for sleep. Slash commands,
interactive actions, modal validation and challenges still need their full
synchronous response semantics. The endpoint blocker remains. Qualification
must cover normal signed enrollment, durable retry after removal, sealed drain,
the actual event worker and SDK, lost-ACK redelivery and one domain effect,
generation changes, and callback evidence. Synthetic protocol coverage does not
prove live Slack, complete configuration UI or a deployed stop/restart/wake journey.
### Reproduce the paired Cloud acceptance
Use the repository's supported Node version and installed dependencies. Compile
the companion Cloud checkout first. Set `PAPERCLIP_SLACK_TEST_DATABASE_URL` and
`SLACK_EVENT_TEST_DATABASE_URL` to two distinct, owned loopback PostgreSQL
`*_test` databases. Set `SLACK_TEST_CLOUD_DIST` to its absolute compiled `dist`
directory. This test applies actual migrations and uses only those two databases.
It does not create a PostgreSQL cluster or use a live Slack provider.
From the app repository root, run:
```sh
node server/scripts/test-slack-cloud-postgres.mjs
```
The launcher uses the current Node executable and one Vitest worker. It loads the
checked-in prewire guard in that actual worker before client construction. The
controlled process child explicitly loads the same guard before its script.
The guard rejects off-host and unowned socket targets before transport. Caught
unexpected attempts still fail the fixture. No private preload path or manual
`SLACK_GATE_GUARD_PID` setting is needed. The fixture remains opt-in during the
normal test suite; its default owner and guard regressions run without databases.
+7
View File
@@ -13,6 +13,13 @@ unattributed operation in the same shared workspace does not remove its wait.
Normal readiness, duplicate-wake, interaction, pause, and execution gates still
apply. A board transition into blocked keeps the existing recovery behavior.
A dependency completion can arrive while the assignee answers a question about
the blocked task. Its saved wake waits behind that turn. Deferred-receipt recovery
reconstructs these dependency wakes after the turn and its cleanup finish, even
when they contain no comment or interaction response. It uses the existing
release and dispatch checks; task cancellation, agent pause, and pending cleanup
still prevent execution.
Cancelling a run or pausing its agent suppresses immediate stranded-work recovery.
A queued user message remains a separate continuation request. The release path
still preserves independently authorized deferred input; stopping the old run
+26 -2
View File
@@ -12,6 +12,18 @@ Subset runs do not qualify the full plan or the historical 355-row snapshot.
Catalog and projection tests are not live qualification; retain the fresh
TestOps launch and GitHub run when reporting acceptance.
The sibling **Reliability — staging soak** TestOps plan uses the same lane for
the seven staging-soak reliability cases. Six run once on native OpenCode with
DeepSeek 1017 through OpenRouter: on Daytona for `three-turn-revise`,
`long-silent-command` and `queued-across-restart`, and locally for the others.
`queue-steer` needs steering, which OpenCode does not support, so it runs on
native Codex on Daytona.
A launch selects cases from exactly one plan, so plan 2 keeps its 20-case
denominator. The plan is launched from TestOps before a release, or bootstrapped
with `testops_plan=reliability`. It has no schedule and does not run per PR.
Selection fails closed until TestOps-assigned identities are committed; see the
[registration steps](../tests/runner-e2e/EVERYDAY-WORKFLOWS.md#testops-reliability-plan).
The explicit deployment-targeted [saved agent instructions journey](../tests/runner-e2e/product-journeys/AGENT-SETTINGS.md) checks an owned agent’s ordinary UI save, persisted instruction bytes, one current-run output, and restoration. The task request omits its instruction-only reference. It reuses the existing Product E2E target and report path; local controls do not establish a staging pass.
The explicit-only [verified API artifact transfer](../tests/runner-e2e/README.md#verified-api-artifact-transfer)
@@ -137,6 +149,13 @@ It requires a fresh owned agent with no prior work runs and uses three provider
Cloud fleet operations and trusted CI remain private. Local eval gateway qualification
does not qualify Cloud routes, and these selections stay outside `--all`.
The explicit [onboarding approval-history journey](../tests/runner-e2e/README.md#first-task-onboarding)
continues an interview through partial plan acceptance, approved hiring, and a
separate execution confirmation. It requires the assigned child's real execution,
saved output, unchanged approved scope, and reload persistence. Deterministic
PostgreSQL producer/consumer tests run in ordinary CI; native provider cells
require an explicit paid selection and a recorded live result.
The deployment-targeted [saved skill journey](../tests/runner-e2e/product-journeys/saved-skill-use.md) checks one company skill assigned by key, its exact admitted immutable version, a bound native source read/load, and a run-authored saved result from skill-only inputs. It uses the reusable Product journey target and remains unqualified until a real target run passes.
The explicit-only [case handoff workflow](../tests/runner-e2e/README.md#persistent-case-handoffs-explicit-only)
@@ -371,7 +390,10 @@ Use the selectors documented in the [runner E2E README](../tests/runner-e2e/READ
for a suite, profile, case, group, or environment. Daytona needs the immutable
image digest and `DAYTONA_API_KEY`; follow the README and fixture security guide.
`--all` excludes manual suites such as `everyday-workflows`. Select that suite
explicitly; use a narrow selector while developing a fixture.
explicitly; use a narrow selector while developing a fixture. Its staging-soak
reliability cases need `--group reliability` as well; see
[Everyday workflows](../tests/runner-e2e/EVERYDAY-WORKFLOWS.md#staging-soak-reliability-group)
for the command, the TestOps Reliability plan and the before-release cadence.
For Runner Evals, the narrowest useful local validation is the report program's
help/validation path and the deterministic Runner checks documented in
@@ -608,7 +630,9 @@ verified worker process loss, and multi-turn answers grounded in actual task
records. See the [workflow and qualification limits](../tests/runner-e2e/README.md#remaining-native-agent-chat-qualification).
The 26 native `first-task` cells inspect the production wizard's saved native
default on qualified targets, without an API runner switch. The 26 Legacy cells
select Legacy explicitly in Advanced. Live results and semantic answer reviews must accompany
use an API-configured inactive setup record and resume it in the wizard, as
described in the [first-task setup contract](../tests/runner-e2e/README.md#first-task-onboarding).
Live results and semantic answer reviews must accompany
any qualification claim; catalog presence alone is not a pass.
The explicit-only [native question/resume qualification](../tests/runner-e2e/README.md#task-continuation)
+1070 -18
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -9,7 +9,7 @@ inside the six owning services. No table or migration is added.
| Duty | Trigger and durable source | Remaining scheduled work | Quiet condition |
| --- | --- | --- | --- |
| Abandoned finalization | Committed control-deadline writes on `heartbeat_runs` and `native_run_finalizations` | Earliest non-null control deadline; a due row still present after recovery retries after 15 seconds | No control deadlines |
| Abandoned finalization | Committed control-deadline writes on `heartbeat_runs` and `native_run_finalizations`; committed run transitions that leave a result-less `observed` owner on a failed, timed-out, or interrupted native run | Earliest non-null control deadline, or earliest lease expiry of such an owner whose environment leases are settled; a due row still present after recovery retries after 15 seconds | No control deadlines and no such owner |
| Safe native replacement | Committed run transitions and reconciliation decisions; existing failed run/coordinator rows | Retry while the existing replacement predicate selects a candidate, including one awaiting provider termination or sandbox release | No replacement candidate, or a recorded successor/denial |
| Reconciled continuation | Committed recovery-action marking/resolution; `evidence.continuationDelivery` | Retry pending delivery after 15 seconds | No resolved action awaiting continuation delivery |
| Execution status | Committed run status writes; `execution_status_delivery_id` | Retry while a delivery marker remains | All markers acknowledged |
+7
View File
@@ -12,6 +12,13 @@ already-known monitor deadline can fire earlier than the batching window.
After all work settles, no periodic monitor scan remains. Pending claims and
failed or deferred admissions retain their bounded deadlines.
A monitor deadline is the earliest time for a check. An active native run or an
unfinished finalization holds the check until the prior work settles. The task
banner explains the active run wait instead of promising the next scheduler tick
or showing it as overdue. Once the run clears, the banner resumes its countdown
and offers Check now. Finalization notifications restore monitor scheduling from
the saved rows; they do not bypass execution admission.
Both idle drains and ordinary maintenance drains pause monitor dispatch. The
worker retains its pending request without repeatedly reading monitor tables.
After an explicit drain stop or TTL expiry, a bounded in-memory retry resumes
+61
View File
@@ -386,6 +386,11 @@ settles, including after the raw socket closes. Detached startup reconciliation
for built-in agents, managed homes and persisted runtime services also remains
counted after readiness until its writes settle.
Retained native response presentation also continues after web readiness. Its
fair scheduler and persisted retry deadlines remain in effect; in-flight work
stays counted for idle and is joined at shutdown. Web readiness does not require
the retained reply queue to become idle.
Scheduler work already in flight remains counted until its promise settles.
Idle holds pause new scheduler admissions. Database backup promises remain
counted through success or failure. Configured periodic backups block sleep by
@@ -594,6 +599,19 @@ No plugin approval or configuration bypass is supported. Scheduled and
externally triggered work needs a durable host wake mechanism before it can
permit sleep.
The host's agent-runtime lifecycle notification listener has separate ownership
from its SDK worker. A normal owned safety read retires its exclusive listener
client only after admitted delivery and journal work settle. Failed or uncertain
retirement remains a blocker; diagnostics never perform this retirement. Closing
a subscription alone does not prove that its PostgreSQL backend closed. The
client belongs only to lifecycle delivery, so retirement preserves the main
pool and other features' notification channels. Releasing the hold or its existing shared expiry deadline
recreates the listener and reconstructs delivery from the durable journal before
normal dispatch, including events whose notification arrived while it was closed.
This preserves at-least-once delivery and existing company filtering; it does not
promise exactly-once callback invocation or add a wake guarantee for arbitrary
external SQL writers while compute is stopped.
This is a single-process admission protocol. A restart clears the owner. The
host must serialize sleep with deploy/wake operations and bind its final check
to the instance it stops. An unexpected restart after the final check can still
@@ -654,3 +672,46 @@ Tenant authorization still runs after wake. Personal GitHub credential continuit
and unsupported transports retain their blockers. See [authenticated incoming
wake proof](idle-incoming-wake.md) for scope, HTTP versus durable-event guarantees,
rate/availability bounds, compatibility and remaining transport dependencies.
## Registered owner deadline coverage
The negotiated domain deadline catalog also inspects the registered status-card,
external-object, skill-sync, watchdog, retry and quota-monitor owners. `index.ts`
passes the actual services and worker reconciliation callbacks to the report.
A route-created service or an arbitrary in-memory `nextWakeAt` cannot establish
this coverage. Missing owner callbacks preserve the original durable work checks.
Each owner reads the supplied bounded, repeatable-read snapshot. Its normalized
state and future deadlines join the existing generation fingerprint. Startup,
dirty reconstruction, failed reconciliation, concurrent revision changes and
stopped workers cannot relax a blocker. The report checks owner revisions again
after inspection and backup preparation. Owner reads share the catalog's query
and elapsed-time budget; exhaustion refuses sleep.
Scheduled retry coverage is limited to a future legacy assignee-task retry and
its exact same-company, same-agent queued wake pointing back to that run. Other
runs and wakes retain their checks, as do accounting, budget-stop, resources and
cleanup. Native retries, review retries, coalesced wakes, claimed cancellation
and tasks with a separate monitor remain conservative. After wake, ordinary
run-dispatch admission and claims still decide whether execution may start.
The reserved legacy quota monitor validates its saved source run and current
assignee or agent reviewer target separately from ordinary issue monitors.
Claimed, due, blocked, malformed, native-source or mismatched-source monitors
remain work. Watchdog inspection reuses its subtree classifier; stopped or
incomplete subtrees remain work. First-run grace remains exactly 15 seconds
from issue creation. Cloud may refuse sleep when a real deadline is too near;
inspection never extends that deadline to make sleep eligible.
A completed or cancelled legacy quota task can retain its reserved monitor policy
and timestamp as history. The scheduler's terminal-status exclusion disarms that
schedule; inspection does not clear or postpone it. The quota owner additionally
requires a valid settled legacy source, idle execution state, no monitor claim or
execution lock, and no linked unfinished run or wake before treating it as
passive. A live task's due monitor remains work. Source uncertainty and unrelated
accounting/resource obligations retain their independent checks.
Pending `budget_evaluation_work` rows also block sleep. The budget owner removes
only the input IDs it has consumed. The queue has no completed-history rows.
The durable check still applies when a worker is absent or has not reconstructed
its work; a process-local idle count does not replace this check.
+34 -23
View File
@@ -24,7 +24,7 @@ aliases are not registered. The exact registry is in
`tests/runner-e2e/manual-private-retention.ts`.
These IDs require the reviewed AWS Fleet route. Broad chat suites, mixed
unsupported IDs, reruns and missing approvals fail closed. All chat-feature IDs
unsupported IDs, reruns and runs without dispatch authority fail closed. All chat-feature IDs
are classified as restricted by trusted code. A target descriptor cannot opt out.
Other manual selections keep the existing Actions route only after a trusted
per-cell check confirms that the actual selected execution is not restricted.
@@ -38,8 +38,8 @@ credentials. Generic nonprivate catalog selection retains its existing semantics
A registered ID is not a claim of feature readiness or permission to run it.
Each target still needs the exact reviewed producer contracts, matching helper
bytes, declared private inputs, fresh source fingerprints, an available original
attempt and exact operation approval. Earlier accepted target branches do not
acquire these bytes or approval automatically. Running jobs keep their pinned
attempt and an allowlisted manual dispatch. Earlier accepted target branches do not
acquire these bytes automatically. Running jobs keep their pinned
sources. No retained grade or historical qualification transfers to a new target.
The private cell report contract binds the installed pinned Playwright producer
@@ -58,30 +58,41 @@ or run. Before a supported run, the operator must:
1. Deploy this workflow and its helpers together on the protected default branch.
2. Keep the existing numeric actor allowlist and protected default-branch checks.
3. Keep `runner-e2e-private-paid` and `runner-e2e-private-history` restricted to
the default branch. Establish administrator-controlled approval for the exact
source and original run. A reviewer UI may supply this control but is not
mandatory. Prove the actual control and runtime contract before admission.
the default branch.
4. Provide the existing private-history role ARN, AWS region and bucket variables
in those environments. The existing role must support the existing bounded
prefix and bucket-policy, ownership, public-access and retention reads.
5. Provide `RUNNER_E2E_MANUAL_PRIVATE_APPROVALS_JSON` through administrator-controlled
configuration available to both protected jobs. Approve the exact resolved
workflow SHA, target SHA, original run ID,
attempt `1`, selected execution IDs and launcher SHA-256.
6. Use a reviewed compatible target. Its private handoff and policy modules must
match the workflow implementation byte for byte. Its launcher digest must
match the protected approval. Earlier target branches do not gain this
protocol automatically.
5. Use a compatible target. Its private handoff and policy modules must match
the workflow implementation byte for byte. Earlier target branches do not
gain this protocol automatically.
Classification happens before the protected job. It grants no archive authority.
The administrator-controlled record must bind the actual original run ID before
target dependency setup or archive credentials. Prove who can change that record
and that each job consumes the exact approved record. An environment reviewer
gate is one way to govern authorization; its UI is not required. A missing or
stale row still fails closed before target dependency setup, archive credentials,
model credentials or fixture secrets. There is no wildcard approval.
### Dispatch authority
The variable has this closed shape (all values below are placeholders):
In `runner-full-stack-e2e.yml`, the manual run itself authorizes its private
evidence. There is no separate per-run approval record. Trusted code in
`tests/runner-e2e/manual-dispatch-authority.ts` admits a cell only when all of
these are true:
- The event is `workflow_dispatch` of this workflow on the default branch, with
the `manual` cadence. Scheduled and bounded-cadence runs never qualify.
- The run is on attempt `1`. A rerun does not inherit authority.
- The dispatching actor is in `RUNNER_E2E_ALLOWED_ACTOR_IDS`, the event sender is
that actor, and the triggering actor is the same person.
- The trusted cell selection equals the dispatch's own `id` input.
The authority binds the workflow SHA, the target SHA resolved by the authorize
job, the run ID and the cells. The dispatcher chooses and vouches for the target
branch, so its launcher digest is recorded in the private identity instead of
compared with a separately approved digest. Classification still happens before
the protected job, and a failed check still stops before target dependency
setup, archive credentials, model credentials or fixture secrets. The per-run
limit of eight cells is unchanged.
### Stored approval records
The TestOps catalog case workflow still passes a protected approval record as
`MANUAL_PRIVATE_APPROVALS_JSON`. That record has this closed shape (all values
below are placeholders):
```json
{
@@ -105,7 +116,7 @@ It prepares pinned publication dependencies with `--ignore-scripts` and a
separate store before archive credentials. Transfers use that checkout's
absolute helper, tsx and archive-script paths. They do not load the target's
node_modules or an ambient Node loader. The target executes only after the
protected approval and private-storage preflight pass.
evidence authority and private-storage preflight pass.
The AWS action returns session credentials as step outputs, not job-wide
environment exports. Only trusted preflight and transfer steps receive those
+83 -3
View File
@@ -1,5 +1,32 @@
# Native workspace finalization ownership and recovery
Online legacy workspace import may preserve an unresolved terminal source while
unrelated object-backed work continues. This is a file-authority handoff. It
does not finish the old result, clear its recovery action, stop a provider, or
start another turn. Keep every descriptor, retained allocation, and finalization
checkpoint for the recovery owner.
The migration owner must hold the existing per-run physical finalizer advisory
locks across capture and publication. It must reject a non-null coordinator
owner, a durable physical copyback owner, a live controller lease, or an active
adapter control. An expired owner is still an owner. Historical agent PID fields
remain unchanged; they do not certify that the provider stopped. Recheck the
exact source proof under publication row locks. A changed source or checkpoint
keeps local authority. Provider stop verification and any later file export
remain the recovery owner's separate obligations.
The same local file handoff can preserve a blocked prepared source after a
native session is interrupted with ambiguous state, or after its file export
recovery is exhausted. The interruption may precede a provider session. Its
recorded session flag must be a boolean; it does not prove remote recovery.
The interrupted source must retain its exact task failure block and declared
recovery allocations. The exhausted source must retain its accepted
result, bound original allocation, exhausted system recovery and blocked file
recovery. An empty list of additional recovery allocations does not remove the
original prepared allocation binding. The migration checks these records again
under the same physical and task locks before publication. It keeps the failed
run, accepted result, recovery action and remote file obligation unchanged.
Periodic heartbeat recovery runs one pass at a time in each server process.
A tick that arrives during a slow pass reports its age and starts no duplicate.
A later tick can start a fresh pass after the current work settles. Shutdown
@@ -159,8 +186,12 @@ checks under short coordinator, issue and run row locks, atomically with the
owner claim. The task must still be open, assigned to the run or its authoritative
review participant, and bound to that running native execution and accepted
result. A different checkout, newer execution or queued/running legacy execution
prevents takeover. Publication checks repeat this validation; row locks are never
held across physical copyback I/O.
prevents takeover. A later wake for the same assignee that has never started,
such as the answer to the run's question, does not. Its claim waits for this
run's execution lock, so it waits for this copyback. Counting it would block both
runs. Another agent's run and a recovery-action wake still prevent takeover.
Publication checks repeat this validation, so a wake that starts during copyback
stops publication. Row locks are never held across physical copyback I/O.
The harness endpoint is `POST /v1/tenant/workspace-worker-stop`. The fleet-wide
server token authorizes a read request, never stop evidence: responses must be
@@ -203,6 +234,23 @@ copyback children after an abrupt parent death before the success barrier. It su
automatic provider wake. This is an intentional limit: elapsed time or a missing
database connection never proves the old copyback process stopped.
**Same-host restart (automatic).** A control-plane restart that kills the
controller mid-copyback leaves a receipt whose owner can never confirm its own
stop. Each receipt records the controller's process group (`processGroupId`).
Copyback children (tar and Git) are spawned without `detached`, so they keep that
group after their parent dies. A successor on the same host releases the
receipt itself only when all of these hold: the receipt names another
controller boot, the recorded PID is dead or recycled (process start time), and
the recorded process group is empty, or its only remaining members predate the
old controller or belong to the successor's own process tree. It logs
`issue.workspace_owner_stop_verified` with that local proof, marks the recovery
`resuming`, and the ordinary finalizer resumes copyback from the accepted
result with no provider turn. A still-live group member keeps the fence and is
rechecked automatically (`local_stop_pending`). A receipt without a process
group, from another host, or on a platform without `ps` still needs the board
confirmation below. The copyback advisory lock and the coordinator lease are
unchanged.
An instance operator must first verify through the deployment platform that the
exact prior controller **and its copyback subprocesses** have stopped. Retain the
sandbox, accepted native result, and workspace descriptor. Do not run a new
@@ -210,7 +258,11 @@ provider turn, delete workspace contents, or relax archive confinement.
For legacy receipts, an authorized company owner, company administrator or
instance administrator records independently verified platform evidence through
the operator API. The task has no shutdown form or resume control. The API is
the operator API. The task shows this as its one action: its interruption is
`waiting_for_user` with `userAction: "confirm_owner_stop"`, and the chat offers
**Confirm the previous server stopped**, which asks for the stop evidence and
calls the same API. The control is shown only to roles that hold
`runtime:confirm_owner_stop`. The API is
`POST /api/issues/:id/recovery-actions/resume-workspace-finalization` with:
```json
@@ -306,6 +358,12 @@ extracted. No host path or link target is copied into the informational run even
## Repairing a failed workspace export without rerunning the agent
The background workspace worker runs up to four copy-backs at once, one per
task, so a slow sandbox export does not hold every other task's retry. Due work
runs oldest first. Work without a saved retry time, such as a copy-back that a
restart interrupted, is ordered by when it became pending; it does not go ahead
of older retries that are already due.
Transient export failures retry three times, then produce
`native_workspace_sync_out_retry_exhausted`. The accepted result stays saved,
and the exact sandbox is stopped and retained for export-only recovery. The
@@ -401,6 +459,10 @@ and startup cancellation. Before dispatch, the controller stores a separate
and original plugin. The initial cleanup and restart sweep both require that
plugin to advertise `environmentStopLease`. Missing capability or failed stop
leaves pending cleanup; ordinary release and destroy are never fallback methods.
A typed terminal stop failure (`PluginEnvironmentStopError` with reason
`resource_unrecoverable` or `recovery_failed`) escalates the lease for operator
attention on its first attempt and uses the long retry interval. The lease stays
in pending cleanup.
A matching stopped receipt clears the pending intent, leaves the lease released
and resumable, and saves `sandboxStopAndRetainReceipt`. Its request identity,
@@ -573,3 +635,21 @@ task's queue through current admission checks. Each saved request still advances
its recovery cursor. Rejected attempts, changed source runs, and updated source evidence remain eligible,
and the next pass checks current state again. Legacy per-request handling stays
unchanged. Pending work is awaited before a source is marked checked.
Retained saved-work recovery for a finished run uses the same owner receipt.
When the receipt names a controller that a restart killed and no automatic proof
can release it, the recovery worker does not spend an attempt on each recheck.
It records the exact receipt on the retained recovery action as
`workspaceOwnerRecovery.state = "operator_required"` and keeps rechecking within
the bounded recovery window. A task Retry waiting on that saved result reports
`workspace_owner_stop_required` and does not offer another Retry, because another
click cannot release the receipt. The board confirms the stop through the same
`resume-workspace-finalization` route. The task chat shows the same single
**Confirm the previous server stopped** control in place of the retained file
recovery controls while this hold is active, with the recorded receipt token, to
roles that hold `runtime:confirm_owner_stop`. The confirmation must name the receipt that
is still on the run. It clears only that receipt and returns the existing recovery
to the worker (a spent budget gets one new generation). It accepts no result and
starts no agent turn. The waiting Retry then continues through its normal path.
If the recovery window ends first, the board handoff names the same confirmation
instead of advising another Retry.
+33 -10
View File
@@ -359,7 +359,7 @@ attempt or owning phase:
| `authorization.base`, `.responsible_user`, `.authorizing_user`, `.task_privacy`, `.project_privacy` | The existing base decision, responsible/authorizing-user intersections, and applicable app-record privacy decisions beneath `workspace.access.task_authorization` or `workspace.access.authorization`. These details are scoped to workspace authorization. |
| `workspace.notification`, `workspace.write` | Notification statements and record writes. |
| `workspace.materialize.*`, `workspace.checkout.*` | Private source-directory setup and identity verification, existing checkout lookup, Git credential resolution, clone, optional pristine-object cache lookup/publication (`cache_lookup`, `cache_publish`), requested-ref checkout, and atomic publication. The enclosing `heartbeat.prepare.workspace_source_materialize` includes the complete source-materialization path. |
| `runtime_selection.*` | Completion contracts, session/recovery reads, profiles, interaction materialization, backend discovery, bootstrap, run/coordinator writes, transaction entry/settlement, and workspace synchronization. |
| `runtime_selection.*` | Completion contracts, session/recovery reads, profiles, interaction materialization, backend discovery, dynamic skill package staging (`skill_assets`), bootstrap, run/coordinator writes, transaction entry/settlement, and workspace synchronization. |
| `runtime_context.instructions`, `.skills`, `.mcp` | The three concurrent runtime-context branches. Their durations overlap. |
| `runtime_context.asset_*` | Source reads, manifest hashing, cache lookup, cold materialization, and exact file verification on cold and warm paths. |
| `skills.inventory`, `.wait`, `.bundled`, `.releases`, `.folders`, `.local_sources` | Company skill reconciliation, waiting for an existing company refresh, and its bundled imports, release verification, folder, and local-source work beneath runtime skill preparation. Inventory children overlap their parent. |
@@ -1134,10 +1134,12 @@ untyped capacity text, and Codex CLI transient upstream failures remain reportab
The run keeps its failed state and its terminal diagnostic.
A native Codex model capacity failure stays local only while its automatic
retry owns recovery. The run must carry `native_provider_overloaded`, the closed
`codex`/`server_overloaded` diagnostic, and a committed finalization whose
reason is `native_provider_overloaded`. The report also requires the
`native_provider_overloaded` retry run that the same status transaction created.
retry owns recovery. The run must carry `native_provider_overloaded`. The proof
of the retry is the `native_provider_overloaded` successor run for the same
task, which only the committed capacity status decision creates, and only for
the typed `codex`/`server_overloaded` diagnostic. The run's own `resultJson` is
not required: the live heartbeat's failed-status write replaces it with the
adapter's copy, which lacks the finalizer's diagnostic and finalization fields.
Exhausted or held capacity failures, other retry lanes, setup failures, signals,
and unreadable retry evidence remain reportable. A failed retry reports its own
outcome.
@@ -1312,7 +1314,9 @@ executor's identity redaction. These observations do not change close, quarantin
or recovery behavior.
Remote runner reattachment failures also record `remoteRunnerRecoveryStage`
and `remoteRunnerRecoveryReason` from the saved run. When reattachment fails
and `remoteRunnerRecoveryReason` from the saved run. A confirmed missing process
also records the closed `remoteRunnerLifecycle` and boolean `remoteRunnerSettled`
from the exited runner's saved state. When reattachment fails
before the coordinator saves its result, the report reads the same labels from
the caught recovery error instead. These fields contain
closed labels for marker reads and process verification. They distinguish
@@ -1546,9 +1550,14 @@ without one, the report has no synthetic reporting stack.
Credential patterns, the current user's home path, registered run-secret values,
and known host/runtime environment credentials are removed before truncation.
Declared environment secret bindings are included even when their key has no
credential-like name. Runtime values are used only for redaction and are never
copied into an event. Stack fields are limited to 8,192 characters,
Environment values stay private unless their names and values identify an ordinary
process setting. Flags and counts require both a setting-name match and a boolean
or numeric value. Mode labels use a closed value list. Paths, regions, and key
selectors such as `SORT_KEY` use explicit ordinary-setting names. Unknown opaque
values remain private, including credentials under arbitrary names. Declared
secret bindings and recognizable credential contents override ordinary-setting
exemptions, including short values. Runtime values are used only for redaction
and are never copied into an event. Stack fields are limited to 8,192 characters,
exception messages to 2,048, provider titles to 4,096, and provider details to
12,288. Other diagnostic strings are limited to 200 characters (adapter cause
messages: 2,048). Truncation is marked in the text and in
@@ -1638,6 +1647,20 @@ These fields contain build identifiers; they add no tenant or user identity.
`errorCode`, and `agentAdapter`. The server redacts the error message and
the error code before it sends the event.
A server event whose original error has the code
`ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING` gets bounded provenance. The tag
`dynamic_import_inspector` holds `open`, `closed`, or `unavailable`. The
`dynamic_import_provenance` context holds the same state, the process id, the
parent process id, the main-thread flag, the uptime in seconds, and the names
of inspector-related Node flags from `execArgv` and `NODE_OPTIONS`. The event
never holds the inspector URL, because that URL contains a session secret. It
also never holds a flag value or any other flag. This error has reached
Sentry with no first-party frame. The state shows whether an inspector
session was open when the server reported the event. It does not prove which
code caused the error: code compiled without a filename fails the same way
while a debugger is attached. A `closed` state rules out an expression that a
still-open session evaluated. Other events do not change.
Native runner identity and harness failures retain their existing error prefixes.
Their terminal messages now include a bounded guard reason, such as
`session_scope_mismatch`, `durable_identity_unreadable`, or
@@ -2198,7 +2221,7 @@ expired and cross-invocation context cannot select another call's parent.
| --- | --- | --- | --- |
| Dispatch / receipt / return | `execution.plugin_dispatch` → `plugin.worker` | Host invocation; worker monotonic durations anchored to its epoch start | Outer minus worker time is an unattributed dispatch/scheduling remainder. It is not network latency. |
| Repeated host reads | `plugin.host.identity.read`, `config.read`, `progress.read`, `progress.write` | Worker invocation, one span per awaited call, bounded by receipt budget | Round trip and host service together. Span count is call count; overlapping reads are not additive. |
| Plugin coordination | `plugin.storage.queue`, `storage.execute`, `runtime.queue`, `runtime.prepare`, `runtime.reconcile`, `runtime.verify`, `workspace.prepare` | Invoking plugin, monotonic durations | The plugin keeps its locking and authorization owners. `plugin.reused` is boolean. |
| Plugin coordination | `plugin.storage.queue`, `storage.execute`, `runtime.queue`, `runtime.prepare`, `runtime.reconcile`, `runtime.verify`, `workspace.prepare`, `workspace.failure` | Invoking plugin, monotonic durations | The plugin keeps its locking and authorization owners. `plugin.reused` is boolean. `plugin.failure_reason` on `workspace.failure` is a closed workspace operation failure reason or `unknown`. |
| Completed remote lifecycle | `plugin.remote.lifecycle`, linked to the host-owned observer | Bounded prior monotonic durations, up to ten minutes old | Independent lifecycle trace; never a child of a later polling request. The worker cannot supply link IDs. |
| Remote operation receipt | `plugin.remote.request` → `plugin.remote.operation` | Remote monotonic offsets, checked against local round trip and parent bounds | Closed operation classes cover mount refresh, guest setup, process execution, scripts and cleanup. No remote wall-clock subtraction. |
| Retained/cold transport | `runner.transport.attach`, `resume`, `cold_start`, `prepare`, `registration`, `provider_identity` | Controller process, current active context on every operation | A retained transport must not capture the preceding run's trace. These parents overlap their children. |
+206
View File
@@ -0,0 +1,206 @@
# OpenCode launch artifacts and cold recovery
OpenCode records the absolute paths and hashes of its Node executable, proxy,
and native executable in its durable provider state. An image upgrade can change
these files. A saved session must keep its admitted files. The Runner still
rejects a different launch profile.
For a new managed remote OpenCode session with durable object storage, Core puts
these three qualified files in the runtime account's private, content-addressed
launch directory. Core reads the home and UID from the runtime OS account
database. It does not infer ownership from `HOME`, a workspace path, or a provider
response. The launch receipt records this UID, the absolute directory, the pack
manifest, and the encrypted artifact identity.
Core saves the files before it releases the first provider turn. Artifact capture
uses the current native coordinator claim and the recorded Core admission. A
brief admission lock waits for at most one second and checks the current owner
after it acquires the lock. A stale, revoked, or unknown owner cannot publish.
The artifact operation does not grant execution or tool authority.
The archive is limited to 512 MiB of launch files. Core streams files and checks
hashes and file identity before and after capture. Pack-internal package links
can resolve to regular source files. Links outside the selected pack are refused.
The published launch directory contains regular files and private, owned
parents. Node and OpenCode must be qualified native ELF executables. A script
wrapper or a changed account fails closed. The selected runtime must still
supply the qualified native executables' platform and system dependencies.
The artifact object uses authenticated encryption and a company-bound key
wrapper. A company can reuse a ready object with the same file descriptor.
Canonical run and harness-backup references keep the object alive. Large launch
files are excluded from each later harness archive. A pending upload records its
exact encrypted object reference before upload, so an interrupted reply can be
retired without guessing an object key.
A ready company closure is reused. Concurrent first admissions may independently
upload that closure: each admission retains its own cancellation, ownership fence,
and pending receipt rather than depending on another controller's live transfer.
Each capture has the 512 MiB limit and five-minute deadline; a run can retain at
most three pending uploads. Publication selects one verified ready object under
the closure lock. A loser pins that winner before deleting only its own object.
This bounds each admitted run, not total simultaneous company egress. A burst can
therefore transfer duplicate bytes; distributed upload election is not included.
On cold recovery, Core restores the canonical Runner checkpoint, workspace
snapshot, and native harness data through their normal paths. It also verifies
and decrypts the recorded launch files. The runtime must have the same owned
account UID and absolute home. Core stages the exact files at the recorded
content-addressed paths. It does not replace files in the current image.
Published workspaces retain their recorded directory basename across lease
changes. A replacement allocation with the same provider base therefore keeps
the native session's absolute cwd. The workspace service can restore files to a
different base, but that alone does not authorize changing a native session's
cwd. This change adds no directory aliases and makes no change to the Rust
profile guard.
An explicitly governed continuity break archives the controller launch cache
with the prior session state, receipt, and backups. The cache is not a new
execution authority and must not remain active for the fresh provider session.
Live Runner reattachment does not download or replace the launch files.
Historical sessions that recorded image-rooted paths keep those paths. A governed fresh-session
transition can use a new pack after existing effects and receipts are reconciled.
It must not replay a previous turn or treat a missing result as proof that no
effect occurred.
## Historical provider-pack code sources
An older session can retain its exact pack manifest while the current server
image contains a different pack. When neither the staged nor the preinstalled
pack verifies, the normal uploader resolves the selected historical code from
`ghcr.io/paperclipai/paperclip`. It uses the manifest's recorded source revision
and Linux architecture. The full source-SHA image tag is a lookup hint; a tag
name alone does not prove the code identity.
The resolver verifies the OCI index, selected platform manifest, image config,
source revision, and dedicated provider-pack transport layer. It then verifies
the selected canonical pack manifest, compressed layer hashes, tar paths and
links, and each file layer's plaintext bytes. The final installed pack still
passes the normal verification against the original selected manifest. The
OpenCode version alone cannot admit a different proxy or contract. This source
selection also serves other providers that use the full provider-pack uploader;
it does not change their compatibility or qualification rules.
The code source is downloaded and verified before the uploader can clear or
transfer anything to the target. A historical source requires the existing
qualified archive transport. An unavailable transport leaves the target intact.
Missing images, changed bytes, invalid source identity, and unsupported packs
fail closed. The resolver never resets provider state, substitutes the current
pack, changes the stored launch paths, or starts an old application image.
The temporary cache contains public code and source provenance only. It uses
private, process-account-owned directories and exact manifest keys. An empty
host can reconstruct the cache from the registry. A cached source is checked
again before upload. Imports stream data with a four-GiB code limit and a
three-minute deadline, allow two active imports or cache verifications, and bound queued distinct
sources to 32. Concurrent callers share verification of the same cached pack.
Import waits and downloads respond to cancellation; one cancelled
caller does not cancel a shared import still needed by another caller. Inactive
instances perform no registry reads or cache scans. Each import requires disk
headroom before writing; completed cached code remains until temporary storage
is removed.
The normal per-turn startup owns the source wait, including OpenCode starts
without a warm execution context. Stop, execution-lease loss, and startup
completion cancel that wait. An existing execution context can cancel it sooner.
The source deadline applies before Runner admission. It does not increase the
separate cold-admission or provider-turn time limits.
The resolver uses only an anonymous token scoped to reading this public code
repository. It does not use database, sandbox, model, or object-store credentials.
Its cache and registry dependency are separate from the encrypted S3 backup of
provider conversation state and workspace files.
The Docker publication workflow publishes full source-SHA tags. There is no
image-deletion workflow in this repository, but this is not a permanent registry
retention guarantee. GitHub package administrators can
[delete package versions](https://docs.github.com/en/packages/learn-github-packages/deleting-and-restoring-a-package).
Historical continuation therefore depends on retaining the corresponding code
image in GHCR, or retaining a verified source cache. Deleting both keeps the
session blocked with its original state intact; an S3 provider-state backup alone
does not reconstruct legacy image-owned executable code.
Artifact cleanup uses the existing bounded checkpoint and backup expiration
owners. It requires closed work, settled native ownership, and exact stopped
remote execution receipts. Backup retirement releases only that backup reference.
The separate run reference lasts until fenced checkpoint expiration, including
when an interrupted backup upload is retired while a resume handle still exists.
A remaining backup or run reference keeps the object. A failed external delete keeps its receipt and reference for a later normal
expiration attempt. There is no new idle scan or timer.
Checkpoint expiration checks current resume handles under its existing locks.
It then fences old generations and atomically replaces the expired header with
a versioned, company/run-scoped retirement marker. An earlier handle keeps the
original checkpoint and files. After retirement, readers and header writers
refuse the old checkpoint. Source consumers also check retirement in their
existing ownership query, so a cached old snapshot cannot bypass the barrier.
The marker stays in the existing header index until
external artifact deletion succeeds, including after a server restart. No
transaction waits for a network delete. Families with more than 100 related
runs keep every pin and checkpoint. Missing object storage keeps the marker and
pins for a later normal cleanup attempt.
The capture wait responds to Stop, startup completion, and the execution
deadline. Native bulk transfer does not offer a cancellation signal. Core keeps
that actual transfer registered as pending startup work after it stops waiting.
The late operation can write only its private local scratch files. It cannot
publish after the context closes. It removes its scratch files and releases its
worker responsibility only after actual settlement. Stop does not imply that
this transport has finished.
## Local qualification
Run the focused server suites with the repository's supported pnpm version:
```sh
pnpm --filter @paperclipai/server exec vitest run --config vitest.config.ts \
src/services/native-runtime/native-harness-backup-storage.integration.test.ts \
src/services/native-runtime/native-harness-backup-archive.test.ts \
src/services/native-runtime/remote-opencode-artifacts.test.ts \
src/services/native-runtime/native-provider-pack-admission.test.ts \
src/services/native-runtime/native-provider-artifact-capture.test.ts \
src/services/native-runtime/native-runner-checkpoint-retirement.test.ts \
src/services/native-runtime/native-session-executor.test.ts \
src/__tests__/native-harness-backup-expiration.test.ts \
src/__tests__/native-harness-backup-expiration-config.test.ts
```
The opt-in Linux qualification requires two locally cached Paperclip images with
full immutable image IDs and different OpenCode proxy hashes. Both images must
have the same Linux architecture and the standard qualified provider pack. The
new image must include the packaged Runner binary at
`/app/server/dist/vendor/paperclip-runner/bin/paperclip-runnerd`.
Build the current checkout's Runner TypeScript SDK first. Use the local Docker
context that has the cached images:
```sh
pnpm --filter @paperclipai/paperclip-runner build:typescript
pnpm --filter @paperclipai/server exec tsx scripts/qualify-opencode-cold-recovery.mts \
--context desktop-linux \
--old-image "$OLD_IMAGE" \
--new-image "$NEW_IMAGE" \
--output /tmp/opencode-cold-recovery-result.json
```
The command accepts only full `sha256:` image IDs and a local Unix Docker
endpoint. It never pulls an image. It creates isolated PostgreSQL data and two
owned containers. The containers have no network, a read-only image filesystem,
resource limits, and a private fixture OS account. The synthetic model runs on
container loopback. No external model credential or API is used.
The command uses production workspace save/restore, encrypted checkpoint and
harness storage, artifact publication, admission, SDK transport, Rust Runner,
and actual OpenCode. It destroys the original allocation's account and controller
files, changes the image and allocation lease, and restores the same provider
session. It requires the useful workspace file and earlier model context to
survive. Exactly one model request is allowed in each turn. The result records
image IDs, pack digests, checkout revision and dirty state, lease identities,
byte counts, session continuity, and useful output. The command removes only its
own containers and PostgreSQL server. It retains its temporary evidence directory.
The independent object-store fixture uses the real StorageService and encrypted
streams with an owned local directory outside both destroyed allocations. This
qualifies the cold restore contract. It does not qualify a hosted S3 service,
a task's HTTP/actor workflow, or completion of an existing hosted task.
+9
View File
@@ -143,3 +143,12 @@ the wiring is covered by tests.
fetched to find out, and a front that moves on to another picture gets a fresh chance.
- Token count grows by about thirty `--artifact-*` entries; they are namespaced and
documented in the token block.
## Update 2026-11-04: on for every instance
Artifacts V1 now renders for every instance and the Experimental toggle is gone. `enableArtifactsV1`
stays in the schema and catalog with a default of on. `resolveArtifactsV1Enabled` ignores a stored
false, because every experimental write persists the whole normalized object, so a stored false only
means an instance saved some other experiment. A value from `PAPERCLIP_MANAGED_CONFIG` is still
honoured, which leaves operators an off switch outside Cloud staging. The previous cards remain in
the code until that switch is retired.
@@ -143,3 +143,24 @@ then execute exactly one company-scoped five-page pass. Twenty quiet minutes add
no reads or provider calls. A scheduler control proves an exact object hint and a
250ms saved deadline still preempt the broad reconstruction. All 76 affected
external-object, route, and scheduler tests and server types pass.
### Read-only idle deadline inspection
The registered external-object service exposes `readIdleSchedule(tx, now, owner)`.
Its bounded read reuses that service's actual built-in/injected resolver registry
and current plugin, company and retained-reference eligibility. Shared sleep
code does not interpret provider terminal states or plugin manifests. Settled
terminal/unsupported/orphan/never-refresh history is passive; eligible future
refreshes retain their exact saved deadline. Active or ambiguous refresh claims
remain blockers even after eligibility removal. Inspection performs no resolver
call and never rewrites a timestamp.
The supplied registered worker snapshot must be reconciled before and after the
read with the same revision. Startup, dirty hints, partial reconstruction,
scoped failures, active execution and stop invalidate that fence. Successfully
loaded feature state preserves the actual settings owner's overlays; an
uninitialized gate blocks. Future entries reconstructed from durable rows alone
do not make the owner dirty during drain. The resulting domain snapshot requires
shared exact-hold proof/alarm integration before it can authorize host sleep;
it is not a universal certificate derived from the in-process scheduler.
@@ -190,3 +190,25 @@ retain their existing effects.
Every reactive summary writeback records one immediate card-owned refresh deadline in the same transaction that clears its generation. This preserves changes whose committed source hint was consumed while the old generation still owned the card, including changes after the summary's final watched-source read. The existing card commit hint restores that one deadline; it does not fan out to other companies or force generation. A fresh unchanged search disarms it. A relevant diff follows the existing policy, assignment, result and quiet path. Manual and interval writeback cadence stays unchanged. Rollback removes both the summary and its recheck obligation. Real PostgreSQL controls hold the actual summary search, commit an ordinary issue write, observe the worker consume that hint while generation is still owned, then verify exactly one needed generation or one unchanged recheck followed by SQL quiet.
### Read-only idle deadline inspection
The actual evaluation service exposes `readIdleSchedule(tx, now, owner)` for an
owned sleep inspection. The supplied owner is the registered deadline worker's
synchronous reconciliation snapshot. Startup, a running sweep, dirty source
hints, failure and stopped ownership cannot certify settled work. A busy card
advisory lock can leave a company-scoped retry only in service memory even after
the helper sweep succeeds; retained reconstruction/change/retry state therefore
also blocks inspection until evaluated. Disabled features retain this state for
reenablement without owing an automatic evaluation while disabled.
The bounded reader leaves policy and membership deadlines unchanged, includes
row revisions in its generation state, and blocks active generation pointers,
unfinished update ledgers, due work and unsupported states. It rechecks the owner
revision after reading. The feature gate comes from successful owner
reconciliation, preserving settings overlays without creating a settings row
inside a read-only inspection. This domain result is not stop authorization:
the shared exact-hold proof and durable Cloud alarm must compose and revalidate
it before replacing a conservative blocker. Independent SQL writers and other
active server processes still need explicit wake ingress.
@@ -35,6 +35,7 @@ Remedies below mean completing the owning protocol or supplying a separately tes
| `agent_instruction_working_copies` | [agent_instruction_working_copies](../../packages/db/src/schema/agent_instruction_revisions.ts); [agent-instruction-working-copies.ts](../../server/src/services/agent-instruction-working-copies.ts). cleanup. | Requires known settled state, stopped process, no next attempt, no baseline or cleanup-pending receipt. Fixture: Stopped saved copy versus deferred cleanup. | Domain cleanup; preserve the owning evidence and records. |
| `sandbox_creation_operations` | [sandbox_creation_operations](../../packages/db/src/schema/sandbox_creation_operations.ts); [sandbox-creation-operations.ts](../../server/src/services/sandbox-creation-operations.ts). recovery. | Pending creation operation retains uncertain/provider work. Fixture: Pending operation versus settled operation. | Domain settlement; preserve the owning evidence and records. |
| `budget_reservations` | [budget_reservations](../../packages/db/src/schema/accounting.ts); [budget-reservations.ts](../../server/src/services/budget-reservations.ts). accounting. | Anything outside settled/released remains owed. Fixture: Unsettled reservation versus settled charge. | Domain accounting; preserve the owning evidence and records. |
| `budget_evaluation_work` | [budget_evaluation_work](../../packages/db/src/schema/budget_evaluation_work.ts); [budget-evaluation-work.ts](../../server/src/services/budget-evaluation-work.ts). accounting input queue. | Every retained row is an unfinished evaluation input; normal settlement deletes exact IDs. Fixture: Empty versus pending input, then actual owner consumption. | Preserve accounting policy and the existing settlement owner. |
| `budget_policies` | [budget_policies](../../packages/db/src/schema/budget_policies.ts); [budgets.ts](../../server/src/services/budgets.ts). accounting. | Enforcement generation exceeds delivered generation. Fixture: Committed enforcement version2/delivered1 versus delivered2. | Domain delivery; preserve the owning evidence and records. |
| `agents.lifecycle` | [agents](../../packages/db/src/schema/agents.ts); [../modules/agent-lifecycle/index.ts](../../server/src/modules/agent-lifecycle/index.ts). active / recovery. | Preparing/verifying/pausing/resuming/terminating/cleaning-up phases. Fixture: Pausing agent versus stable paused state. | Domain settlement; preserve the owning evidence and records. |
| `decision_invocations` | [decision_invocations](../../packages/db/src/schema/decision_models.ts); [decision-models.ts](../../server/src/services/decision-models.ts). active. | Running decision invocation. This existing predicate is narrower than a universal unknown-state guard. Fixture: Running invocation versus completed invocation. | Domain completion; preserve the owning evidence and records. |
@@ -93,6 +94,8 @@ Every row below currently blocks on **any retained row**, including completed-lo
| `browser_use_runs` | [browser_use_runs](../../packages/db/src/schema/browser_use.ts); [browser-use.ts](../../server/src/services/browser-use.ts) | Any retained row, with no terminal exception. [browser-use.test.ts](../../server/src/__tests__/browser-use.test.ts) supplies domain fixtures. | History/resource: verify exact completion/authority/resource and dependent effects before narrowing. |
| `browser_use_browsers` | [browser_use_browsers](../../packages/db/src/schema/browser_use.ts); [browser-use.ts](../../server/src/services/browser-use.ts) | Any retained row, with no terminal exception. [browser-use-work.test.ts](../../server/src/__tests__/browser-use-work.test.ts) supplies domain fixtures. | History/resource: verify exact completion/authority/resource and dependent effects before narrowing. |
| `company_secret_proposals` | [company_secret_proposals](../../packages/db/src/schema/company_secret_proposals.ts); [secret-proposals.ts](../../server/src/services/secret-proposals.ts) | Any retained row, with no terminal exception. [secret-proposals-routes.test.ts](../../server/src/__tests__/secret-proposals-routes.test.ts) supplies domain fixtures. | History/deadline: verify exact completion/authority/resource and dependent effects before narrowing. |
| `mcp_file_tickets` | [public-mcp/file-transfers.ts](../../server/src/services/public-mcp/file-transfers.ts); [idle-transfer-schedule.ts](../../server/src/services/public-mcp/idle-transfer-schedule.ts) | Unexpired reusable file tickets hold unfinished transfers; expiry history is passive. | Incoming-work/deadline: preserve current grant, resource and exact expiry. |
| `mcp_authorization_handoffs` | [public-mcp/oauth.ts](../../server/src/services/public-mcp/oauth.ts); [idle-authorization-handoffs.ts](../../server/src/services/public-mcp/idle-authorization-handoffs.ts) | Approved unconsumed code/device handoffs remain work through actual expiry; undecided anonymous requests do not hold sleep. | Incoming-work/deadline: preserve current owner, code consumption and actual expiry. |
| `mcp_oauth_grants` | [mcp_oauth_grants](../../packages/db/src/schema/public_mcp.ts); [public-mcp/oauth.ts](../../server/src/services/public-mcp/oauth.ts) | Any retained row, with no terminal exception. [public-mcp.test.ts](../../server/src/__tests__/public-mcp.test.ts) supplies domain fixtures. | Incoming-work: verify exact completion/authority/resource and dependent effects before narrowing. |
| `mcp_mutation_receipts` | [mcp_mutation_receipts](../../packages/db/src/schema/public_mcp.ts); [public-mcp/capabilities.ts](../../server/src/services/public-mcp/capabilities.ts) | Any retained row, with no terminal exception. [public-mcp.test.ts](../../server/src/__tests__/public-mcp.test.ts) supplies domain fixtures. | Completed-history: verify exact completion/authority/resource and dependent effects before narrowing. |
| `mcp_event_subscriptions` | [mcp_event_subscriptions](../../packages/db/src/schema/public_mcp.ts); [public-mcp/events.ts](../../server/src/services/public-mcp/events.ts) | Any retained row, with no terminal exception. [public-mcp.test.ts](../../server/src/__tests__/public-mcp.test.ts) supplies domain fixtures. | Incoming-work/deadline: verify exact completion/authority/resource and dependent effects before narrowing. |
@@ -5,6 +5,42 @@ rolling deployment while an agent works and new work is admitted. Enrollment mus
preserve existing task files and historical recovery sources. It must not reset
task workspaces, manufacture successful copyback, or weaken migration guards.
## Online enrollment preparation
`POST /api/instance/ephemeral-enrollment/prepare` accepts only a fresh, stack-bound
Cloud control assertion naming `ephemeral-enrollment:prepare`. Tenant sessions,
including instance admins, cannot export the original decision signing key.
The strict request contains only an `attemptId` UUID. The owner first checks
durable-data eligibility, validates the replacement's ephemeral configuration,
and audits the attempt without storing its secret. It never creates a missing
signing key or changes admission, process configuration, or workspace bindings.
Preparation uses startup durability checks, so ordinary admission before skill
pins are saved does not impose a global-idle requirement. Retention and migration
checks keep their stronger pin requirements. Actual admitted local skill assets
still require their owning preservation proof before volume removal.
The existing audit owner accepts a caller-bound receipt UUID. Preparation derives
it from the verified signed request ID and stack under its own namespace. The
database primary key atomically consumes that assertion before returning the key,
including concurrent replicas and process restarts. The process-local assertion
fence remains an early refusal. Expired assertions cannot return a secret.
The private, non-cacheable response binds the original key and its digest to the
attempt, stack, stamped Core commit, Railway deployment, observation time, and
current static ephemeral-mode setting. Cloud must deliver the key directly to
its existing secret vault; do not print, persist in registry metadata, or return
this response through an admin/browser API. HTTP diagnostics treat the entire
enrollment namespace as secret-sensitive.
This is an advisory preparation receipt, always `enrollmentReady: false`. It is
not a writer fence, ownership-transfer receipt, or permission to detach a volume.
The remaining Cloud transition must arm static ephemeral mode while retaining
the original volume, verify that deployment and cessation of earlier local
writers, repeat durable eligibility checks on the armed deployment, and use the
qualified native handoff before detachment. Retain the original volume. Ordinary
release selection must remain unpinned. No step may substitute a global-idle
requirement for these ownership and durability checks.
## Evidence and remaining work
| Required outcome | Current evidence | Remaining gap | Owner / next action |
@@ -23,6 +59,35 @@ encrypted task-directory restoration, Git restoration recipes, and legacy
branch-deletion receipts. PostgreSQL and real-file tests prove cold restoration
of saved bytes, index state, and unpushed commits, including after pruning the
old Git object store. Invalid or unavailable retained data still blocks import.
Administrative migration can recover a missing empty task-directory payload only
when storage confirms that its object is absent. The existing encrypted key
envelope must authenticate the exact empty payload hash and length, company,
workspace, restore-point ID, and generation. Metadata counters alone never
establish emptiness. This path leaves the original reference and missing object
unchanged and publishes the recovered bytes through the normal verified remote
snapshot owner. Ordinary restoration remains unchanged. Expired checkpoints,
nonempty or forged payloads, unreadable objects, and unknown object absence still
block migration.
An archived isolated checkout can be gone while its recorded deleted commit
survives in the company/project Git cache. After validating the exact successful
branch-deletion receipt, the importer can read immutable objects from that
namespace- and origin-checked cache. It reconstructs only the recorded commit,
never today's working tree. Ordinary private-checkout and restore-point
validation stays strict. Missing/revoked receipts, foreign caches and changed
origins still block publication.
Archived reconstruction of a saved nested linked checkout uses a private
standalone repository at the new path. It reads only the saved commit and
bundle prerequisites from the verified shared cache, and imports authenticated
restore-point history into private refs. Other tasks' branch refs and unrelated
objects do not enter the archive. Missing prerequisites use the approved remote
or block restoration. Root and nested private copies retain all saved extra
commits after portable capture and garbage collection. Ordinary linked recovery
keeps its path-owned branch check and its existing worktree history owner.
Verified local ignore rules preserve the saved classification of recovery files;
unsafe or changed exclude files block import without changing the source cache.
The next slice adds leaf-first offline migration of overlapping folders, verified
nested snapshot dependencies, interruption recovery, and dependency retention.
Recovery-obligation reconciliation remains a separate gap; these slices do not
@@ -117,6 +182,14 @@ recovery source validation and company boundaries.
## Queued turns during offline enrollment
Offline workspace import preserves a pending repository request when preparation
has never published a target directory, commit, branch or failure. The request
stays pending in the database. It is excluded from the saved snapshot's materialized
repository descriptors, so the next authorized sandbox admission can acquire it
normally. Existing targets, preparation in progress, repair states, unreadable
paths and live imports remain blocked. Publication rechecks the request and
materialized source before transferring file authority.
The task drain stops new executions. It does not consume queued turns. After
Cloud has stopped every application deployment, offline maintenance preserves
never-started queued run and wake rows unchanged. Workspace, instruction-file,
@@ -157,6 +230,22 @@ creation time. A queued follow-up can predate the prior turn completion. A remot
stop can also predate a later logical failure caused by recovery timeout. These
proofs use the recorded execution start and never rewrite lifecycle timestamps.
A missing managed task checkout can still have its runtime-owned branch in the
verified company and project Git cache. Preserve that exact commit when the
recorded private path matches, no other workspace owns the branch, and Git has
no surviving worktree registration or index for it. Pin and recheck the commit
before publication. This saves the surviving committed tree; it does not
certify recovery of files that were already absent. An active record requires
the existing offline provider fence and keeps its original status. Invalid
restore recipes, live registrations and competing branch owners stay blocked.
For a user-owned retained branch, require its exact successful unforced worktree
removal receipt after archival. Forced removal or a revoked receipt does not
grant this authority. Preserve the branch and its ownership metadata unchanged.
For a deleted private branch, prefer its verified recorded private object source
while it still exists. A missing project cache or a later project-source change
must not hide that original retained commit.
### Existing typed checkpoint authority
When a retained source already has a valid typed database checkpoint, derive its
@@ -170,3 +259,143 @@ keeps its supervisor and ownership requirements.
For a retired reusable sandbox lease, the existing remote-termination resolver
may prove destruction through its exact successor handoff chain. A stopped
successor, broken chain or competing tip does not grant archival authority.
### Bounded offline harness transfers
The full bundled cold rehearsal reached only 699 of 1,953 local harness backups
before the 25-minute maintenance deadline. Keep every source and its existing
ownership and hash checks. Migrate up to four sources together. Reserve space for
both each tar archive and its encrypted copy before starting a group, with the
same total scratch limit as one maximum-size serial transfer. Use the verified
manifest sizes for a closed directory set; give sources with extra files an
exclusive maximum-size reservation so those files are preserved. Enforce the
reservation while streaming the archive. Ordinary runtime backups keep their
existing maximum archive limit.
Derive tar overhead from the maximum entry count and both maximum path lengths.
PAX can encode a path and symlink target for every entry. The difference between
the global object and content limits is not a safe per-source overhead estimate.
Check the bound against real maximum-path PAX encoding at the full entry budget,
and check streaming at an exact byte limit and one byte beyond it.
On failure, cancel and await every transfer in the group before returning. A
completed publication remains valid for the next attempt. Tests must exercise
real PostgreSQL publication and byte restoration, the four-transfer bound,
failure while another storage call is unresolved, retry, extra-file preservation,
and rejection at the archive limit before any object or row is published. This
finite offline operation adds no idle background work. A resumed rehearsal can
measure the fix but cannot replace fresh full-image migration and empty-home
recovery proof.
During offline migration, known task-only assignment, configuration, deliberate-wait and
disposition warnings can remain unchanged while the owner saves local files.
They do not certify file recovery and are not resolved by migration. An active
system recovery, native execution failure marker, declared workspace recovery
source, export-failure cause or unknown recovery kind retains the existing
blocker unless the importer already records that exact legacy recovery source.
Live migration keeps its recovery exclusion. Recheck recovery evidence before
publishing the saved workspace.
### Online preparation for large provider histories
Parallel offline transfer alone did not fit the copied staging dataset in the
maintenance window. A resumed attempt reached 1,389 of 1,953 sources. Keep the
offline deadline and move successful released history before draining the server.
The inventory contains 1,802 such backups, approximately 48.5 GB of the 54.8 GB
declared source bytes. This is preparatory publication under recorded runtime
authority, not permission to detach a volume.
Run the finite operator entry from a normal released image:
```sh
PAPERCLIP_NATIVE_HARNESS_PRESTAGE=true node --max-old-space-size=512 server/dist-runtime/native-harness-prestage.mjs
```
The entry loads the stored Cloud configuration and requires shared object storage.
It processes one source at a time, has a 90-minute deadline, and stops on shutdown.
It starts no scheduler, does not drain tasks, and always reports
`enrollmentReady: false`. Check serving memory headroom before running a separate
process on a tenant. Keep the deployment on normal releases.
A successful, finished source with a released committed coordinator and an
accepted result publishes through its normal runtime owner. At both database fences, hold the
task share lock and recheck its checkout, related queued/running executions,
recorded session and Runner, exact backup-stamp lease, and unchanged local
manifest and suspended state. A concurrent turn, moved source, revoked stamp or
changed owner defers that source. Unrelated executions continue unchanged.
No lease, result, task or lifecycle state is rewritten to make a source eligible.
The same entry can preserve sealed failed or cancelled backup files through a
separate source owner. The source must retain its exact selected task/session,
released committed result or terminal-failure coordinator, and recorded backup
stamp. Every publication checks the physical finalization lock, task checkout,
source run and coordinator, source lease, manifest, and payload hashes. A remaining
coordinator or physical owner blocks the copy even after its deadline expires.
Historical PID fields are preserved and are not termination proof. A valid
`previous` tree cannot authenticate a changed `current` tree. Failed publication
retains the pending encrypted object receipt and every original file. The copy
does not change the failed outcome, warnings, result, allocation or session.
It does not prove that an absent backup was recovered or authorize detachment.
Run the controller-only phase separately so it does not re-read provider
archives that were already verified:
```sh
PAPERCLIP_NATIVE_HARNESS_PRESTAGE=true node --max-old-space-size=512 server/dist-runtime/native-harness-prestage.mjs --controllers-only
```
This finite entry adopts legacy controller checkpoints for successful
retained sources whose finalizer is committed, has an accepted result, and has
released its ownership. It locks only the source task and coordinator while
reading and publishing the original controller state. Related queued/running
work, checkout, failed sources, recovery state, or any remaining coordinator
owner (including an expired owner) defers that source. Local controller and warm
supervisor bytes are rechecked before commit; a changed source rolls back.
Unrelated runs remain active. Existing controller checkpoint identity, artifact
provenance, configuration and snapshot checks still apply. The `paperclip.native-controller-prestage.v1` counts describe this phase; neither successful prestaging
nor zero deferred controller sources authorizes volume detachment.
The same finite controller-only entry also preserves settled failed sources.
A committed failed result may import its exact checkpoint under the normal
owner checks without changing the failed outcome. A terminal failed controller
that cannot earn executable recovery authority instead gets an encrypted
evidence archive through the existing native harness object owner.
Online archival verifies the exact retained session pointer, company, agent,
task, admitted source binding, terminal run and released coordinator. It rejects
related queued/running work, checkout, expired-but-retained ownership and a busy
physical finalizer. Source/profile/coordinator fingerprints and original file
bytes are checked at publication, including after the upload. Pending object
records commit before upload; a raced source cannot publish a current archive.
The operation leaves original files, failed status, leases and recovery history
unchanged. The `archivedFailed` count records preservation, never successful
execution or permission to take over an agent.
The live Cloud path uses online preparation and static ephemeral arming on the
retained volume. Replacement startup must recheck the original bytes after the
old deployment stops. That final check, not an archive copy or process-local
busy map, establishes the cutover boundary. The offline fallback below is for
an independently quiet instance; it is not the live staging delivery path.
The final offline migration still stops all writers, verifies every durable
object, imports deferred sources and all other data, and exports only its normal
complete eligibility receipt. Qualification starts from the saved pristine
database, records the separate preparation phase, and then proves the normal
bundled offline entry and empty-home restoration. Preparation alone cannot pass
enrollment or active-agent release acceptance.
The online preparation command loads the configured app key before copying. It
requires a valid existing key and retains that key in its process environment
so a missing file cannot create a replacement during publication. Its database
connections have five-second lock waits and 30-second statement and idle
transaction limits. Cancellation closes the command's clients and has a
ten-second process-exit fallback for a dependency that does not settle.
Workspace archives preserve a resolved Git merge that still awaits its merge
commit. The archive retains the merge heads, original head, message, mode,
automatic merge tree and selected index, and verifies their objects and unchanged
metadata before publication. A restored copy can continue the merge after the
source disappears. Git still protects later unstaged edits during abort.
Conflicted indexes, merge autostash, rerere state, unfinished rebase or sequencer
operations, invalid metadata and concurrent changes block capture and retain the
original workspace. Migration never commits or aborts a user's Git operation.
@@ -0,0 +1,342 @@
# Two-level task hierarchy for agent-created work
Date: 2026-11-05
Status: Implemented with the recommended answers to the open decisions; see the
implementation notes below for where the code differs from this plan.
## Implementation notes
- **Exempt system machinery.** Watchdog review issues and their serialized
follow-ups, monitor recovery, email-channel boundary placement, and
human-configured routines pass `allow_nested`. These subsystems locate their
work by `parentId`, or place it inside a trust boundary, so flattening them
would break them. They are not agent-authored decomposition. Agent-authored
routines (`createdByAgentId`) still flatten.
- **Low-trust guard.** Placement never moves a task above a low-trust boundary
root (`executionPolicy.authorizationPolicy.trustBoundary.rootIssueId`) that
contains the requester. In that case the requested parent is kept.
- **Origin tracking reuses `createdFrom`.** The existing "Created from" detail
field and the `createdFromIssueId` list filter now read `origin_issue_id`
first and fall back to the run-derived source. `createdFrom.run` is nullable.
`origin_run_id` was already harness-only: the create schema does not accept
it.
- **`create_task` takes an optional `parentTaskId`.** A local test drive showed
that chat agents could not build a parent and subtasks through the tool, so
the runner contract gained this input (regenerated manifest and semantic
contracts). The run must be able to read and change the named parent; a
conversation is never a parent; depth placement still applies. Project tools
map it to `parentId` through the issues route. The tool output is unchanged:
it reports the effective `parentId`, and the activity log records
`parentAdjusted`.
- **Chat parent scope (Open decision 5).** Not restricted to tasks from the
same conversation. Chat agents may parent under any task they may write, the
same as other agents, and the depth limit still applies.
## Goal
Agents (and the automation acting for them) may only build **parent → child**
trees. When an agent asks for a grandchild, the request **does not error**: the
new task becomes a **sibling** of the requesting child (parented to the root of
the tree), and the response says so.
Humans are not restricted. A board user can create or move tasks to any depth
from the UI, CLI or API.
Separately, every task records **where it came from**, both the originating task
and the originating run. That record is an audit trail, not hierarchy, and only
the harness sets it.
Kept exactly as-is:
- **Existing data.** Legacy grandchildren and deeper trees stay valid. There is
no backfill, and every read path (tree views, subtree pause/cancel, recovery,
watchdog subtree scans, privacy propagation, cost rollups) keeps walking
arbitrary depth.
- **Hierarchy data model.** `issues.parent_id` stays a plain self-FK with no
depth constraint, so deeper nesting can come back for agents by changing one
constant. The only schema change is the additive `origin_issue_id` column.
- **Goals and folders** (they are not tasks), and **`requestDepth`** (the
delegation counter).
## 1. Who the rule applies to
The depth rule is an **actor policy**, not a data rule.
| Creator | Policy |
|---|---|
| Board user (UI, CLI with user auth, API with board session) | `allow_nested`. No change from today |
| Agent via HTTP API, CLI with agent key, runner `create_task`, project tools `create_task` | `flatten` |
| Agent-authored chat suggestions (`suggest_tasks`), even when a user clicks accept | `flatten`, because the agent authored the shape. See Open decision 2 |
| Harness/system creators: native delegation, accepted-plan decomposition, decisions effects, recovery, watchdog, routines | `flatten` |
| Plugins (`issues.create`) | `flatten` (automation). See Open decision 3 |
| `importIssues` / company import | exempt; preserves trees verbatim |
Implementation: add `parentDepthPolicy?: "flatten" | "allow_nested"` to the
`create` / `createChild` / `update` inputs in `server/src/services/issues.ts`.
The **default is `flatten`**, so any internal caller is safe by default. Only
the board-actor branches of the routes pass `allow_nested`
(`req.actor.type === "board"`).
## 2. The flatten rule
Add to `packages/shared`:
```ts
export const MAX_AGENT_TASK_HIERARCHY_DEPTH = 2; // root + one level of children
```
Add one resolver next to `assertExecutionTaskParent`
(`server/src/services/issues.ts:2047`):
```ts
resolveNewTaskParent(db, companyId, requestedParentId, policy)
-> { effectiveParentId, requestedParentId, flattened: boolean }
```
- `allow_nested` or requested parent is top-level → unchanged.
- Requested parent has a parent → use the **root** of that tree. In a 2-level
tree that is exactly "sibling of the requester". For a legacy deep tree it
still yields a 2-level edge.
- `assertExecutionTaskParent` (conversations cannot be parents) still runs on
the effective parent. If the root is a conversation (only possible with legacy
data), fall back to top-level instead of erroring.
**Never an error for agents.** Flattening is silent at the data level and
visible in the response:
```jsonc
// create / createChild / update response
"parentAdjusted": {
"reason": "max_agent_depth",
"requestedParent": { "id": "…", "identifier": "ENG-34" },
"effectiveParent": { "id": "…", "identifier": "ENG-12" }
}
```
The same object goes into the `issue.created` / `issue.child_created` /
`issue.updated` activity-log details. Runner and project-tool results surface it
in the tool result text ("Created ENG-40 under ENG-12 as a sibling of ENG-34;
agents can nest only one level"), so the model learns in-loop.
**What the requester keeps when flattened:**
1. **Waiting.** `blockParentUntilDone: true` adds a `blocks` relation *new task →
requester* instead of blocking the root. Blocker resolution
(`issue-dependency-wakeups.ts`) wakes the requester.
2. **Workspace.** `inheritExecutionWorkspaceFromIssueId` = the requester, so the
sibling runs where the grandchild would have.
3. **Privacy.** `privacyParentIssueId` = the requester, not the root
(`issues.ts:~10621`). Otherwise a task spawned from a private child could end
up more visible than its creator.
4. **Origin.** `originIssueId` = the requester (§3).
**Re-parenting by an agent** (`PATCH parentId`):
- The target is a child → flatten to its root with a `parentAdjusted` notice.
- The moved issue itself has children (moving it under anything would make
grandchildren) → **do not error**. Skip only the parent change, apply the rest
of the patch, and return `parentAdjusted: { reason: "has_children", applied:
false }`. See Open decision 1.
## 3. Originating task and originating run
Both are nullable. **Only the harness sets them.** They are never agent tool
parameters or request-body fields, and they never change after create.
### `origin_issue_id` (new column)
`issues.origin_issue_id uuid references issues(id) on delete set null`, indexed
on `(company_id, origin_issue_id)`. It is never read for hierarchy,
authorization or wakes.
The harness derives it in this order:
1. the **requested** parent, before flattening (so for unflattened children it
equals `parentId`; that duplication is fine and keeps queries simple)
2. otherwise the run-bound task of the creating agent run, **including a
conversation**, so chat-created tasks record their chat
3. otherwise the source task of system creators: recovery and watchdog source,
decision target, native delegation source, plan-decomposition source, routine
`parentIssueId`, chat-suggestion interaction issue
4. otherwise null (plain board/UI creates)
### `origin_run_id` (existing column, tighten it)
`issues.origin_run_id` already exists (`packages/db/src/schema/issues.ts:67`),
and the create route already defaults it to `actor.runId`
(`routes/issues.ts:12838`). To make it harness-only and reliable:
- Stop honouring a client-supplied `originRunId` on create
(`routes/issues.ts:12757-12838` reads `createBody.originRunId`). Derive it only
from the authenticated actor's run, or from the internal caller. Confirm the
shared `createIssueSchema` does not accept it, and keep it out of the CLI,
plugin SDK and tool schemas.
- Set it in every harness path that has a run: runner tools (already
`binding.runId`), project tools, native delegation, accepted-plan
decomposition, chat suggestions (`interaction.sourceRunId`), recovery and
watchdog creators where a run triggered them.
- **Overload to resolve:** for `originKind = "routine_execution"` the column
holds a **routine-run** id (`routines.ts:1925`), not a heartbeat run, and
`task-creation-provenance.ts` depends on that. Options are in Open decision 4.
### Contracts and surfacing
- `packages/db` schema plus a `pnpm db:generate` migration (additive nullable
column + index).
- `packages/shared` `Issue` type (read-only fields `originIssueId`, plus
`originIssue: { id, identifier, title } | null` on detail reads); not present
in any create/update validator.
- Server: set both fields in `create()` / `createChild()`; include them in
serializers and the activity log. `task-creation-provenance.ts` follows
`originIssueId` as an edge, so routine/shareability provenance survives
flattening.
- UI: "Created from ENG-34 · run abc123" in issue properties, linking to the
task and the run.
- `importIssues`: carry the fields only if the bundle has them, else null.
## 4. Chat with an agent
Chat agents may create **one top-level task, a top-level task with subtasks, or
several independent top-level tasks**, whichever fits. The code allows all
three, and the prompts guide the choice. The agent depth rule still applies, so
nesting beyond one level is flattened.
What exists today, and what changes:
- **The conversation itself never becomes a parent.** Keep this
(`assertExecutionTaskParent`). Chat-created work hangs off its *own* top-level
task, not the chat. `originIssueId` = the conversation records the link.
- **Runner `create_task` in a conversation** always creates top-level and has no
parent input (`paperclip-runner-tool-authority.ts:1706/1803`). To allow
"top-level task + subtasks" in one chat, let the conversation-bound tool accept
an optional parent **that is a task created from this same conversation**
(`originIssueId` = this conversation). Anything else is ignored (created
top-level, with a notice). This keeps chat agents from attaching work to
arbitrary existing tasks while still allowing their own small trees. See Open
decision 5.
- **Project tools `create_task`** (`project-tools.ts:50`) hard-codes
`parentId: null` for conversations. Same change as the runner tool.
- **HTTP API from a chat run** (`POST /companies/:id/issues`): explicit
`parentId` is already allowed and goes through flatten. No change beyond §2.
- **Chat suggestions (`suggest_tasks`)** already support `parentClientKey`
nesting and default to top-level for conversations
(`issue-thread-interactions.ts:~4325`). This stays open to both shapes. Nesting
deeper than one level is flattened on accept (agent-authored → `flatten`).
- **Prompts.** `server/src/services/agent-conversations.ts:108` currently says
"Create ordinary assigned tasks, never subtasks of this conversation … omit
parentId", and `:119` says "Omit parentId: the accepted task is standalone".
Rewrite both to:
- never use the conversation as a parent;
- for one coherent outcome, create one top-level task;
- for a large outcome with parts one owner coordinates, create a top-level
task and child tasks under it;
- for unrelated outcomes, create separate top-level tasks;
- only one level of children; deeper requests become siblings;
- use `blockedByIssueIds` for ordering, not nesting.
## 5. Choke points
Enforcement lives in `server/src/services/issues.ts`, with routes resolving
**before** authorization so permission checks run against the parent actually
written:
| Method | Line | Change |
|---|---|---|
| `create()` | ~10247 | resolve parent per policy; set `originIssueId` / `originRunId`; apply "requester keeps" rules |
| `createChild()` | ~9644 | resolve parent before idempotency/child-count/inheritance; inheritance and `blockParentUntilDone` stay pointed at the requester |
| `update()` | ~11169 | agent policy: flatten target, or skip the move if the issue has children |
| `importIssues()` | ~10911 | exempt (comment why) |
Routes (`server/src/routes/issues.ts`):
- `POST /companies/:id/issues` (~12630-12860). Board → `allow_nested`. Agent →
resolve the effective parent first, including the **run-delegation default**
(`resolveRunDelegationParentDefault`, :3830), which is the main source of
agent grandchildren today. Run `assertIssueWriteInfluenceAllowed` /
`assertTaskBridgeCreateAllowed` / `assertTaskWatchdogCreateIssueAllowed`
against the effective parent, and keep the read check on the requested parent.
For the implicit default, any denial falls back to standalone, as today.
- `POST /issues/:id/children` (~13086-13300). Same treatment.
- `PATCH /issues/:id`. Same treatment.
## 6. Every agent/harness creator (covered by the choke points; one test each)
- Runner `create_task`: `paperclip-runner-tool-authority.ts:1803` always calls
`createChild(binding.issueId)`. Update its description in
`packages/paperclip-runner/src/protocol-actions/create-task.ts`.
- Project tools `create_task`: `project-tools.ts:50`.
- Native `create_delegated_issue`: `status-decision-committer.ts:729`.
- Accepted-plan decomposition: `issues.ts:~10021`. Its cursor tracks explicit
child IDs, not `parentId`, so it is unaffected.
- Decisions `create_issue`: `decisions.ts:466/530`.
- Routines: `routines.ts:1913` (`routine.parentIssueId`).
- Chat suggestions: `issue-thread-interactions.ts:~4325-4372`, plus the scope
resolver at `routes/issues.ts:~6200`.
- Muse `task.create`: `modules/external-agents/muse.ts:547-561`. Dot:
`modules/external-agents/dot.ts:353` (verify).
- Plugin SDK: `plugin-host-services.ts:2134/3161`.
- Recovery: `heartbeat/scheduling.ts:329` (its lookup keys on `originId`, so it
is safe). Watchdog: `task-watchdogs.ts:1445`. Audit `services/recovery/*` for
lookups keyed on `parentId` before flattening.
## 7. UI, CLI, docs
- **UI.** No new restrictions. "Add subtask", the parent picker and
`NewIssueDialog` keep allowing any depth for users. Add: "Created from …"
provenance, and a toast/inline note when an agent-facing response carries
`parentAdjusted` (for example in task-chat created-task cards,
`ui/src/components/task-chat/task-created-items.ts`).
- **CLI** (`cli/src/commands/client/issue.ts:289-352`). With an agent key, print
the `parentAdjusted` notice. With user auth, nesting is unrestricted.
- **Skills/docs.** Update `skills/paperclip/SKILL.md`,
`skills/paperclip/references/api-reference.md`,
`skills/paperclip-board/SKILL.md` and
`packages/skills-catalog/catalog/bundled/paperclip-operations/issue-triage/SKILL.md`:
agents nest one level, deeper requests become siblings, use
`blockedByIssueIds` for ordering. Do the same for the external
`paperclip-converting-plans-to-tasks` skill. Add the rule to
`doc/SPEC-implementation.md` (additive).
## 8. Tests
1. Agent create under a child → sibling, with `parentAdjusted` in the response
and the activity log, and `originIssueId` = the requester.
2. Board create under a child → real grandchild, no notice.
3. Agent `createChild` on a child with `blockParentUntilDone` → blocks and later
wakes the requester; the workspace is inherited from the requester; privacy
follows the requester.
4. Run-delegation default from a run on a child → sibling. A protected root
falls back to standalone (implicit) or 403 (explicit).
5. Runner `create_task` from a child binding → sibling, with the notice in the
tool result.
6. Agent PATCH onto a child → flattened; agent PATCH of an issue with children →
parent change skipped with a notice and the rest of the patch applied; board
PATCH → unrestricted.
7. Chat: runner/project-tool creates top-level with `originIssueId` = the
conversation; a child of a task created in the same chat is allowed; a parent
outside the chat → top-level with a notice; the conversation as parent →
top-level.
8. `originRunId` from the request body is ignored, and the harness value wins.
9. Legacy deep trees: reads, subtree pause/cancel and privacy are unchanged; an
agent create under a depth-3 node lands at the root; import preserves depth-3
trees.
About 26 existing test files mention grandchildren. Most seed with direct
inserts or board actors, which stays valid. Fix only the real failures (likely
`issue-thread-interaction-routes.test.ts` and `issues-service.test.ts`).
## Open decisions
1. **Agent moves a task that has children.** Skip the parent change with a
notice (recommended), or also lift its children to siblings (this mutates
other tasks).
2. **User-accepted agent suggestions.** Flatten (recommended; the agent
authored the shape), or treat acceptance as a user action and allow nesting.
3. **Plugins.** Flatten (recommended), or allow nesting when the plugin acts in
a user-initiated UI action.
4. **`origin_run_id` overload for routines.** Keep the routine-run id there and
document that `origin_run_id` means "the run record that created it"
(recommended; no churn), or add `origin_heartbeat_run_id` and keep routine
runs separate.
5. **Chat tool parent scope.** Allow only parents created from the same
conversation (recommended), or any task the agent may write.
@@ -0,0 +1,59 @@
# Chat contacts and pinned navigation
Restore the full agent contact directory in Chat, with avatars, names, roles,
search, unread counts and a shortcut to the existing create-agent dialog.
The chat header spans the directory and conversation. It uses the standard
CHAT section heading and retains the shared properties control.
Agent pins are available in the contact directory and Agents list. A pin opens
the existing direct-chat route. Personal page and agent pins share one ordered
Pinned section. Pointer and keyboard sorting preserve the stored order without
removing pins hidden by a company switch. The drag grip sits after unpin.
Hover and focus controls share the highlighted row surface. The fade masks only
the text behind the control.
Recents uses agent avatars and names for direct chats. It retains the full task
history and fills the available navigation height above the account control.
The heading stays outside its scroll area. Only clipped edges fade. Task-summary
refreshes retain the server's small request limit and publish a complete result,
so a failed request cannot prune unrelated history.
Inbox task rows use bold unread titles without blue dots in both retained
layouts. The All tasks default and saved view behavior already exist on the
current base branch and remain in place.
Group chat stays in a separate follow-up. This change contains no group-chat
schema, routes, discussion worker, approval flow, picker or conversation page.
## Verification
Use the directory, sidebar, breadcrumb, task-row and history tests. Run the UI
typecheck, build and token gates. Verify mouse and keyboard pin sorting,
create-agent dialog access, full-width search and header, and overflow at both
Recents edges. Use both long and short viewports. Preserve existing unread-chat
counts and account/company boundaries.
An early row-wide drag allowed native link navigation after sorting. A dedicated
grip and prevention of native link dragging fixed it. The regression failed
before the fix and passed afterward. Long-history tests also check that a failed
summary chunk retains all snapshots.
## Review corrections
Reserve separate space for unread badges and pin actions, including touch input.
Unread rows do not fade text that the pin no longer covers. Build pin sets and
item maps once per list, keep their inputs stable, and share pin state across
agent rows. Large-list regressions count input reads and pin parsing for five
hundred agents and pins. Reordering retains hidden-company slots. Task history
also uses indexed summary lookups now that it is no longer capped at five rows.
## Browser journey alignment
CI found two session tests that still used the removed header switcher,
Configure link and Add chat picker. Their journeys now use contact links and
the full-width Search chats field. They retain checks for per-agent drafts,
reload persistence, background history, terminated agents and unique
conversations. Opening or reopening a contact stays read-only; the first sent
message creates the conversation. Both corrected journeys passed against a
fresh disposable PostgreSQL database after the host's embedded-cluster startup
limit prevented the initial local launch.
@@ -0,0 +1,55 @@
# Restart chaos ledger
Companion to `2026-11-06-control-plane-restart-chaos.md`. One section per wave.
Classes: P product · Q agent quality · T test/harness · E unrelated environment.
## Phase 0 — 2026-11-06
Master: `6cafcaf89b`. Host: macOS, local runner (Daytona image pending a CI build).
| Check | Result |
| --- | --- |
| `controller-replacement-smoke.ts` (no agent) | PASS, 39 s, Postgres survived replacement |
| Local restart cells, first run | 0/7 — **T**: worktree `node_modules` older than lockfile (UI missing `mdast-util-from-markdown`). Not counted. |
| Local restart cells, rerun | **2/7** (below) |
| Daytona runner image | Blocked. Local build failed: stale Dockerfile lock pin, then the Docker VM ran out of disk. Moved to CI (`runner-full-stack-e2e.yml`, manual dispatch) after PR #19467. |
### Local baseline rerun (`local-2026-11-06T14-27-42-229Z`)
| Cell | Result | First error | Fixer |
| --- | --- | --- | --- |
| stock-harness · Codex · continuity-restart | PASS | — | — |
| stock-harness · ACPX Claude · continuity-restart | PASS | — | — |
| everyday-workflows · Codex · recover-controller | FAIL (cleanup_failure) | `kill EPERM` during owned-tree cleanup at the restart | subagent: EPERM |
| everyday-workflows · ACPX Claude · recover-controller | FAIL | Timed out waiting for an active run with saved source | subagent: Claude recover |
| stock-harness · OpenCode · continuity-restart | FAIL | Timed out waiting for the chat turn to settle to waiting | subagent: OpenCode |
| local-session-integrity · OpenCode · question-restart-resume | FAIL | Timed out waiting for the pending user question | subagent: OpenCode |
| completion-updates · Codex · handoff-completion-restart | FAIL | Timed out waiting for the worker to wait while the originating chat is idle | subagent: handoff |
#### Signatures found
| ID | Class | Signature | Status |
| --- | --- | --- | --- |
| RC-01 | T | Harness looked for saved source only at the workspace root; ACPX Claude saved `slugify-tool/slugify.py`, so the restart never fired | PR #19471 (open) |
| RC-02 | **P** | ACPX turn active at restart: runner closes it with `acpx_active_turn_recovery_closed` and no error, the control plane reports a generic "Provider turn failed", no continuation, queued follow-up unconsumed (fails R1). Codex has had this handled since #15297; ACPX is excluded by `provider.kind === "codex"` and `driverKind === "codex_app_server"` gates | fixer running |
| RC-03 | T | macOS `killpg` returns EPERM (not ESRCH) on a zombie-only group; the supervisor rethrew it and aborted the restart. Fixed cell passed 2/2, and native Codex recovery itself works | PR #19470 (open) |
| RC-04 | P (latency) | Graceful shutdown always waits the full 30 s heartbeat finalizer drain when a native run is restart-suspended (`server/src/index.ts` ~L2032, `drainActiveRunExecutions`). Every graceful restart (K2) or deploy takes ≥30 s and, under the harness, escalates to SIGKILL | queued for a fixer |
| RC-05 | **P** | Dead Retry after restart (fails R3): Retry accepted but no run for >15 min, stuck at `retryWait.reason=execution_recovery`. After the next restart the Retry vanished (`waiting_for_user`, `canRetry=false`). OpenCode t01, Claude t02 | fixer running (with RC-06) |
| RC-06 | **P** | False over-budget stop after restarts: a new issue was blocked "original recovery target is over budget" at 22¢ of 500¢, then a company `hard_stop` with `pendingRunCount=1`, `reservationCents=0`, `unpricedUsagePolicy=block` (`recovery/service.ts` ~L4826). Codex t03 never ran | fixer running (with RC-05) |
| RC-02b | **P** | OpenCode mid-turn kill also ends as a provider failure (t01). Sent to the RC-02 fixer | with RC-02 |
### Harness smoke `w-20261106T144559-s7` (seed 7, K1 ×3, local, concurrency 2)
0/3 pass. The harness worked: every failure is product-side (RC-02b, RC-05, RC-06). Branch `worktree-agent-a826af5179b88689b` @ `e864139cec`, not yet a PR.
Setup note: fresh worktrees need `pnpm install --frozen-lockfile` and a built `paperclip-runnerd` (`pnpm --filter @paperclipai/paperclip-runner build`) before native cells can run. The chaos harness must check this before starting a wave.
Classification of the remaining cells is pending each fixer's diagnosis. A timeout before the restart point counts against setup or the agent, not restart robustness.
### Side findings
- Nightly Runner Full-Stack E2E never builds a Daytona image. Its operating-overhead allowance (`RUNNER_E2E_OPERATING_OVERHEAD_JSON`) expired 2026-11-03, and the nightly sets `needs_daytona=false` anyway. PR #19467 (merged, 7e6d882558) names the error and removes the stale Dockerfile lock default. The nightly stays red until the allowance is renewed (Dotta's call).
- Image build moved to CI: manual dispatch run 42240434011 (`core-compatibility.runner-codex.daytona.message-marker`), 2026-11-06 15:09Z.
- `tests/runner-e2e/workflow-security.test.ts` has 5 local failures in `pr-trusted.yml` and `chat-catalog-testops-case.yml`. Not investigated.
@@ -0,0 +1,282 @@
# Control-plane restart chaos loop: make tasks survive server restarts
Date: 2026-11-06
Target: a local, isolated Paperclip control plane with agent runs in Daytona sandboxes
Fix workflow: `fix-failed-paperclip-run` (one session per failure signature)
Predecessor: `2026-11-04-staging-soak-loop.md` (staging soak campaign, PR worktree `paperclip-staging-test-plan-24ca37`)
## 1. Why this plan exists
The staging soak campaign (2026-11-04 to 2026-11-06) ran about 430 graded tasks
in waves of about 10. It used a fixed task library (sections A to H), mechanical
oracles (U1 to U9), and a dedupe-by-signature ledger. Each new failure signature
went to its own `fix-failed-paperclip-run` session. The campaign merged 42 fixes,
and the hard-failure rate on test tasks fell from 43% to about 0%.
The largest remaining source of failures was restarts. Staging restarted up to
about five times an hour. The campaign summary says "frequent restarts stall
in-flight work". Three fixes in this area merged late in the campaign:
- #19399: lost runner after a restart continues automatically, and Retry is offered.
- #19426: a declined Retry no longer swallows later clicks (the "dead Retry" bug).
- #19431: queued runs start promptly after a restart, and the wait shows a reason.
The campaign could only observe restarts that staging happened to perform. We
could not choose when the restart came, what kind it was, or what each run was
doing at that moment. This plan reverses that: **we cause the restarts on
purpose**, against a local control plane whose runs execute in Daytona. We run
many tasks at once and repeat until nothing breaks.
## 2. Success rule (the oracle)
A run is **restart-crossing** if any part of it overlaps a control-plane fault:
its queued wake, preparation, provider turn, finalization, or a follow-up. Every
restart-crossing task must meet all of the rules below.
| ID | Rule | Failure means |
| --- | --- | --- |
| **R1** | **Automatic continuation.** If continuing is safe, the task resumes after the server is back, with no user action, and reaches its expected resting state within the task's time budget plus the outage time. | Not robust to restart |
| **R2** | **Durable workspace.** No workspace, durability, or recovery error stays visible at rest. This includes restore, snapshot, finalize, writer-pending, lease mismatch, and stranded lock errors. If such an error appears during recovery, it clears by itself. Files the agent wrote before the fault are present afterwards, and nothing is silently rolled back. | Durability bug |
| **R3** | **Working Retry.** If the task cannot safely continue on its own, it shows exactly one Retry or continue affordance. One click (`POST /api/issues/:id/retry`, and the button in the UI) starts a run that completes the task. "Send a message to continue", a Retry that does nothing, a missing Retry, or a Retry that needs several clicks all fail this rule. | Dead or missing Retry |
| R4 | No duplicate execution. There is at most one active run per issue, no side effect is repeated, and no user message is consumed twice. This is soak oracle U4 and U5. | Replay or duplicate run |
| R5 | No silent stall. A task is never `in_progress` or `queued` with no live run, no queued wake, and no named wait reason for more than 3 minutes after `/api/liveness` reports ready. | Stall |
| R6 | Clean rest. At the end of the wave there is no pending workspace operation, recovery action, orphaned lease, or orphaned Daytona sandbox. | Leak |
The paid "kill at an arbitrary point" probes (`recover-runner*`, see
`tests/runner-recovery/README.md`) were retired because they demanded
**automatic completion** after any kill. For a turn whose outcome is unknown,
automatic completion is not always safe. R1 and R3 resolve this: when automatic
continuation is not provably safe, the correct result is a Retry that works on
the first click. A random kill therefore always has a defined pass condition. It
is never graded "environment, ignore".
## 3. Topology
```
chaos harness (Node script, no LLM)
├─ supervisor: owns the server process (start, SIGKILL, SIGTERM, hot restart, outage hold)
├─ launcher: creates tasks through the board API
├─ observer: polls issues, runs and run events; grades R1–R6; captures evidence
└─ ledger: one JSONL line per task execution, plus the fault schedule seed
local control plane (isolated PAPERCLIP_HOME, embedded Postgres, one fixed port)
└─ dials out to runnerd in each Daytona sandbox (runner ingress, port 43127)
Daytona: one sandbox per run or lease, holding the real runner and provider (Claude, Codex)
```
- **Use an isolated instance**, not `~/.paperclip/instances/default`. Chaos kills
and the leftover Postgres state must never touch Dotta's working data.
- **Own the server process.** Reuse the existing supervisor in
`tests/runner-e2e/server.ts` and `controller-outage.ts`
(`replaceController`: stop → wait for unhealthy → hold → start → ready, with a
receipt). It already handles process groups, SIGKILL escalation, and
embedded-Postgres ownership. Do not use `pnpm dev` (watch mode): when its child
dies, the dev runner exits too.
- **Readiness.** Use `/api/liveness` and the startup phases
(`starting → recovering → ready`) to time the outage and the recovery window.
`startup-recovery-timing.ts` logs each phase.
- **Keep across restarts:** the Postgres data and the instance secret key. A
restart that loses either one is a different fault (K11) and is graded on its
own.
- **Concurrency.** Start with 6 concurrent tasks and raise to 12, then 20. The
limits are the Daytona sandbox quota and provider spend. Settle the quota in
Phase 0.
## 4. Fault menu
### 4a. What the fault does (K)
| ID | Fault | How |
| --- | --- | --- |
| K1 | Hard kill, fast return | SIGKILL the server, restart immediately. Hot-restart reconciliation reports `not_requested`. |
| K2 | Graceful stop | SIGTERM (shutdown drain and finalizers run), then restart. |
| K3 | Hot restart | `scripts/request-hot-restart.ts`, then SIGTERM. Runs should be adopted. |
| K4 | Medium outage | Hard kill and hold for 2–5 minutes. This exceeds the 60-second controller lease but stays within the 7-minute runner reconnect grace. |
| K5 | Long outage | Hard kill and hold for 8–20 minutes. This exceeds the reconnect grace, and past 15 minutes Daytona auto-stops idle sandboxes. The runner gives up, so recovery must go through saved work. |
| K6 | Crash loop | Kill again during `recovering`, then a third time just after `ready`. |
| K7 | Kill during the automatic retry | Kill again while the automatic retry or continuation started by R1 is running. |
| K8 | Kill during a user's Retry | Kill between the Retry click (receipt committed) and the start of the new run. |
| K9 | Postgres dies too | SIGKILL embedded Postgres along with the server. |
| K10 | Network partition, no restart | Block the server's outbound traffic to Daytona for 30 seconds to 5 minutes. |
| K11 | Lost local state | Restart with the controller cache and scoped state directory deleted, as on a new container (`remote_reattach_root_missing`). The database is kept. |
| K12 | Rolling deploy | Start a second server on the same database before stopping the first, as in `rolling-deployment-supervisor.ts`. |
| K13 | Restart storm | Restart every 2–4 minutes for an hour with 20 tasks running. |
### 4b. When the fault hits (B, run boundary)
Some faults fire at a **random time**: a seeded random generator chooses the
time, so a failure can be replayed. Others are **boundary-triggered**: the
observer watches run events and status and fires the fault at a chosen
transition. Each boundary below lists the run event or state it fires on.
| ID | Boundary | Fires on |
| --- | --- | --- |
| B1 | Queued, not yet dispatched | Run `queued` |
| B2 | Sandbox acquire or provision | Lease acquire started, sandbox not ready |
| B3 | Workspace restore or prepare | Restore or prepare operation in progress |
| B4 | Runner bootstrap or session open | Native bootstrap events |
| B5 | Mid model turn, streaming | First assistant output event |
| B6 | Mid long tool call | Tool-start event without a tool-end, for example `bench.sh` running about 4 minutes |
| B7 | Waiting on a question or approval | Pending interaction |
| B8 | User message or steering queued during the run | Queued comment exists |
| B9 | Turn done, finalization or sync-out in progress | `workspace_finalize` or sync operation running |
| B10 | Snapshot or archive upload | Snapshot upload begun |
| B11 | Done, before publication | Durable done, reply not yet published (as in the chat-restart cell) |
| B12 | Scheduled-retry backoff | Run `scheduled_retry` |
| B13 | Stop or interrupt settling | Cancel requested, not settled |
| B14 | Parent waiting on delegated children | Parent blocked on children, children running |
| B15 | Routine fire or timer wake | Routine run being created |
| B16 | Reassignment in flight | Assignee changed, old run not ended |
The `finalization-crash-boundary.ts` and `controller-recovery-observer.ts`
modules in `tests/runner-e2e/` already bind kills to some of these boundaries.
Reuse them; do not write them again.
### 4c. Workload (W)
Use a smaller subset of the soak library, chosen so the boundaries above occur
naturally:
- **A1:** brief written to an issue document. A short run with no repository.
- **A3:** CSV analysis whose answer can be checked exactly.
- **B1:** Python CLI with tests. Workspace files must survive the fault. The
harness runs the tests itself.
- **B2:** fix a failing test, plus a revision follow-up. This exercises workspace
reuse across the fault.
- **B5:** a long `bench.sh` run, which reaches boundary B6.
- **C1:** a comment sent while the run is active (B8).
- **C2:** Stop, then a new direction (B13).
- **A5:** the agent asks a clarifying question (B7).
- **D1:** CEO delegates to children (B14).
- **E1** or **H10:** a routine (B15).
- **C6:** reassignment (B16).
Each task has a deterministic T-oracle, so a restart cannot hide wrong output.
### 4d. Adapter (A)
- **Primary:** native Claude and native Codex on Daytona.
- **Control:** the same tasks with a local runner. This tells apart failures
that are Daytona-specific from failures in control-plane recovery.
- **Later:** other adapters, once the primary pair is green.
The full cross product is about 13 × 16 × 11 × 2. That is too large to run
exhaustively. Waves therefore sample **pairwise coverage**: every fault paired
with every boundary, and every boundary on every adapter, at least N times. The
remaining slots go to open signatures and random K1 and K13 storms.
## 5. Harness (`tests/restart-chaos/`)
The harness is a script, not an LLM, for the same reasons as the soak harness:
deterministic grading and cheap polling. It reuses the runner-e2e process code.
It does not copy that code.
1. **`config`:** isolated instance path, port, `DAYTONA_API_KEY`, company,
agents, budget, and concurrency.
2. **`seed`:** creates the "Chaos Lab" company, Claude and Codex agents on
Daytona, and the `chaos-sandbox` project with a fixture repository. This
includes `bench.sh` and one failing test. It sets hard budgets.
3. **`supervisor`:** wraps `server.ts` and `controller-outage.ts`, exposing
`kill(kind)`, `hold(ms)`, `start()`, `waitReady()`, `partition(ms)` (for
example a pf or iptables rule, or a proxy in front of runner ingress), and
`wipeLocalState()`. Every action is written to the timeline.
4. **`scheduler`:** a seeded plan of faults and boundaries for each wave. The
seed and the chosen cells are saved, so `--replay <wave>/<task>` repeats the
same fault at the same boundary.
5. **`observer`:** polls every 2–5 seconds and evaluates R1–R6 and the
T-oracles. When a task ends in the R3 state, the observer acts as a user
would: it calls Retry once through the API, the same call the UI hook makes
in `useTaskRetry.ts`, and grades the result.
6. **`ui-probe`:** every few waves, a Playwright pass opens one task that is in
the R3 state, checks that the Retry button is visible, clicks it once, and
confirms the task continues. The dead-Retry bug (#19426) was in the UI path,
so checking the API alone is not enough.
7. **`evidence bundle`** (captured at failure time): run IDs and statuses, the
last 200 run events for each run, the server log around the fault, the
startup-phase timings, the recovery claim dispositions
(`claimNativeRestartRecoveries`), workspace operations, leases and their
Daytona sandbox state, the fault timeline, the seed, and the SHA.
8. **`ledger` and summary:** the soak ledger format, plus the fault, boundary
and adapter fields. A small dashboard artifact shows pass rate per cell and
the open signatures.
9. **`sweep`:** at the end of each wave, reconcile Daytona sandboxes against
leases (R6). Then destroy whatever the harness created.
## 6. Triage and the fix loop
```
wave (N tasks × sampled K/B/A cells)
→ grade R1–R6 and the T-oracles
→ classify failures; signature = component + first causal error + boundary
→ new P signature → fixer session running /fix-failed-paperclip-run with the bundle and a replay command
→ known signature → add the bundle to the existing record
→ fix PR merged → rebase the harness worktree on master, replay that signature 5× plus its neighboring cells
```
- **Classes:** the same as the soak campaign. **P** is a product bug, **Q** is
agent quality, **T** is a test or harness defect. **E** (environment) never
applies to the fault we injected. Only an unrelated provider or Daytona outage
counts as E.
- **Fixers start from the replay.** Each fixer gets `--replay`, so the first
step of `fix-failed-paperclip-run` (establish the incident) is already done.
The fixer then writes the smallest red test at the right layer. Usually that
is a server integration test on embedded Postgres, like the six in #19399, or
a `native-runner-restart-*.integration.test.ts`. The fix makes it green.
- **Merge authority:** the same as the soak campaign. Fixer sessions merge their
own PR at Greptile 5/5 with all required checks green, and run `check-pr`.
- **Concurrency:** at most 4 fixer sessions at once.
- **No deploy lag.** Fixes are retested locally as soon as they merge, which is
the main speed advantage over the staging loop. Staging stays the final
confirmation (Phase 4).
## 7. Phases
| Phase | Output | Exit |
| --- | --- | --- |
| **0. Baseline** | Isolated instance, Daytona key and quota confirmed, Chaos Lab seeded. Run the existing `everyday-workflows.runner-codex.daytona.recover-controller` cell and `controller-replacement-smoke.ts` as they are today. Also run 10 tasks by hand with a K1 kill each, to measure the starting failure rate. | The baseline failure rate is recorded and the first signatures are filed. |
| **1. Harness MVP** | Supervisor, launcher, observer with R1–R6, ledger and replay. Faults K1, K2 and K4 at random times. Workloads A1, B1, B5 and C1 on Claude and Codex. 6 concurrent tasks. | Two waves run without supervision, and the evidence bundles are good enough for a fixer to start from. |
| **2. Boundary matrix** | Boundary triggers B1–B16 and the full K list except K12 and K13. Pairwise sampling. UI probe. | Every K×B pair has been run at least 3 times. |
| **3. Stress** | Concurrency 12, then 20. K13 storms (an hour of restarts every 2–4 minutes). K12 rolling deploys. Overnight runs. | Finish line (§8). |
| **4. Lock in** | Each fixed signature has a red/green regression test in its PR. Stable cells are promoted to explicit runner-e2e cells (`recover-controller` style) for release qualification. Then one confirmation wave on staging. | Recurring regressions are caught in CI, not by chaos. |
## 8. Finish line
At one pinned master SHA:
1. **500 consecutive restart-crossing task executions** across the sampled
matrix with **zero R1–R6 failures**. At least 20 of them cross K5 (the long
outage) and at least 20 cross K6 or K7 (repeat kills).
2. Every K×B cell has at least 5 passes on both Claude and Codex.
3. Two K13 storm hours at concurrency 20 in which every task reaches its
resting state, and the R3 Retry works on the first click every time.
4. No open P signature remains in the ledger.
5. One staging wave after deploy confirms the result. There, U9 sees no restart
failure across all restarts in a 24-hour window.
## 9. Cost and safety
- Hard budget per wave (start at $30) and per night (start at $150). The harness
refuses to start a wave above 80% of the cap.
- The harness signals only processes it started, using the PID and start-time
checks in `process-tree-owner.ts`. It never touches the default instance or
any other dev server.
- The Daytona cleanup sweep runs after every wave and on harness exit. Orphaned
sandboxes are counted as R6 failures before they are deleted.
- No real credentials are placed in fixtures. The canary secret check (soak U7)
stays on, because recovery paths replay logs.
## 10. Decisions needed from Dotta
1. **Daytona quota and concurrency ceiling:** is 20 concurrent sandboxes OK, or
is the cap lower?
2. **Budget:** are $30 per wave and $150 per night acceptable caps?
3. **K5 bar (outage longer than the 7-minute reconnect grace):** is a working
Retry an acceptable outcome, or must the task continue automatically from
saved work? The plan assumes automatic continuation is required when the
last provider turn settled, and Retry is acceptable when the turn was in
flight. This matches the #19399 rule.
4. **Adapters for the finish line:** Claude and Codex native only, or also the
ACPX adapters?
5. **K12 rolling deploys:** include them now, or wait until zero-downtime web
deploys (`2026-10-31-zero-downtime-web-deploys.md`) is the production path?
@@ -0,0 +1,161 @@
# Online ordinary workspace source fences
The importer can preserve an idle legacy source while unrelated object-backed
turns continue. This change does not assert `writersStopped` and does not use the
instance-wide offline admission gate.
## Ownership
`workspace-source-lock.ts` retains the existing per-company/task source advisory
key and adds a managed-project key. The project key covers the physical project
namespace, including new issues that have no workspace row yet. Heartbeat source
selection/materialization and workspace lifecycle owners retain the same key.
Configured aliases of canonical isolated task directories retain that directory's
original task key. Cross-company aliases are rejected.
The filesystem materializer keeps its database-free API. It retains physical
keys when called inside an admission owned by the database service. The runtime
call sites that can write these controller namespaces have these owners:
| Mutation entry | Source admission |
| --- | --- |
| Heartbeat preparation and its source/repository resolvers | Task keys and dynamically retained physical roots for the full preparation |
| Workspace reopen, expiration and cleanup | Existing task/source and lifecycle gateways |
| Rehome and nested-clone conversion | Shared move gateway retains task and project keys through the move |
| Runtime commands with a workspace record | Workspace lifecycle gateway |
| Project runtime commands without a workspace record | Physical project/task keys before provisioning and command startup |
Standalone materialization and source-resolution callers retain their existing
filesystem API. The source fence is established at the database-owning mutation
entry rather than inferred as a new requirement of that helper API.
The importer derives a bounded physical alias closure rather than accepting a
caller-supplied proof. It holds every existing alias's task and lifecycle keys,
plus the project key where applicable. It checks the closure and its ordinary
writer, service, operation and repository blockers again before publication.
Future preparation waits at its first project key; acquiring another physical
project while already holding one fails instead of creating a lock cycle.
Migration admission uses nonblocking lock acquisition.
The authority update commits on the connection holding the source fence. The
activity publication follows that commit. A dropped fence connection cannot
publish through another pool connection. Preparation also refreshes a selected
local workspace if migration published remote authority while selection waited.
## Capture and recovery
The archive owner retains all stable-read, mutation, cancellation, normalization,
restore and digest checks. Scoped ordinary captures reject included Git metadata
before reading it. Bare repository shapes, detached Git administration and
object-store alternates also require the larger Git fence. The self-contained
remote script contract remains intact.
The existing nested imported-child gate remains in force.
Terminal native recovery sources use the canonical online preservation helper.
The importer holds the finalizer's existing physical advisory key, re-inspects
after acquiring it, and compares journaled fingerprints under publication row
locks. Coordinator ownership must be null, including an expired owner. Provider
allocations, finalization checkpoints and task warnings remain present. Unknown
file recovery and other aliases' unproven recovery remain blockers.
The finalizer's known failed-export watchdog can retain a prepared source. Its
company, task, run, failure cause and canonical action fingerprint must match.
The coordinator must have a terminal failure with no owner or future retry.
The board-owned action must have no wake policy or separate file-recovery marker.
The proof retains `preparedSource` explicitly and never calls it finalized. The
original action, prepared descriptor and allocations remain unchanged. Its full
action/source fingerprint is checked under the same physical finalizer lock.
The separate company-scoped `reconcileOnlineWorkspaceOperations` entry retires
only a superseded operation with an exact later successful finalization receipt,
an accepted result and a committed ownerless finalizer. It reuses the canonical
row/advisory locks and writes the audit in the same transaction. It does not run
workspace work, settle recovery or change provider/task authority. The offline
entry retains its global deployment prerequisites. Inventory remains read-only.
## Measured cohort
The read-only inventory initially found 45 ordinary aliases blocked only by
physical overlap. A second bounded source probe on
`4d554f08218b04df49ec0f2a0d5c5dfdfaf65660` at
`2026-11-06T22:14:40.198Z` checked the three exact managed `_default` roots:
| Project | Workspace aliases | Source tasks | Root `.git` | Imported nested children |
| --- | ---: | ---: | --- | ---: |
| Shared root A | 40 | 13 | absent | 0 |
| Shared root B | 3 | 1 | absent | 0 |
| Shared root C | 2 | 1 | absent | 0 |
These are observational counts, not live migration results. A full included-tree
capture still rejects embedded Git or source changes. There are no live holds,
imports, cancellations or source filesystem writes in this qualification.
## Verification and remaining work
The focused fixtures exercise the actual importer, storage/archive restore,
heartbeat preparation, PostgreSQL source/lifecycle/finalizer locks, an unrelated
running remote turn, closure mutation, lost exclusion and retained provider
obligations. Run from `server/`:
```sh
node ../cli/node_modules/tsx/dist/cli.mjs ../packages/db/src/test-postgres-command.ts -- \
node ../node_modules/vitest/vitest.mjs run \
src/services/remote-workspace-online-source.test.ts \
src/services/isolated-remote-workspace-source-lock.test.ts \
src/services/remote-workspace-migration.test.ts \
src/services/remote-workspace-migration-concurrency.test.ts \
src/services/heartbeat/workspace-preparation.test.ts --fsModuleCache
```
The full goal still includes the remaining Git/shared and recovery workspaces.
The next extension must derive the Git administrative/common-object/alternates
physical closure and make actual preparation, reopen and cleanup owners retain
its keys before admitting those sources. Exact immutable nested-child coverage
can then use the existing archive owner under that closure. Git stores are not
assumed immutable, and completed or unreferenced history is not discarded.
The runtime-control route reacquires the same source/lifecycle fence for the
filesystem restore after it publishes admission. Its visible running operation
continues to block migration between admission and restore. The later service
start retains its existing independent lifecycle fence.
`reconcileOnlineWorkspaceOperations(db, { companyId, signal })` is an explicit
operator apply entry. The source qualification console can call this owner
with one exact company before online imports. Inventory never invokes it and
never settles operation rows as a side effect. The existing offline maintenance
entry retains its independent all-writers-stopped prerequisites.
Alias inspection bounds the company cohort to 1,000 records. A separate
minimal-path scan checks foreign physical aliases in pages of 256, up to
10,240 records. This safety bound remains conservative because lexical-prefix
filtering could hide a foreign symlink alias. Foreign rows do not consume the
company cohort bound. An inaccessible path or scan limit leaves the managed
source explicitly unproven. It cannot fall back to a weaker singleton import.
No foreign metadata is returned.
Runtime service exclusion also uses the physical working directory. An active
project service can have a different project-workspace ID and no execution
workspace ID. The company-scoped service scan checks up to 1,000 unsettled
rows; local path uncertainty blocks the import. Both runtime startup owners
hold the workspace, base and configured service source keys before provision
or persistence. The final rendered service CWD must already be fenced before
workspace link updates or spawn. Runs with no services retain their previous
filesystem-free path.
A read-only source observation at `2026-11-06T23:20:49.282Z` found 254 pending
legacy records and zero unsettled local-process services. No service handoff was
required by that observation. This is not a coherent or locked migration proof.
Serial source admission exposed a run-scoped preview reuse collision: two
execution workspaces with one caller invocation ID could reuse one healthy
service after the first start completed. Run-scoped reuse identity now includes
the execution-workspace owner. The public scope ID and explicit project/agent
sharing stay unchanged. The existing exposure-pair owner regression exercises
real listeners, a disposable database and a fake broker.
Local reopen acquires its declared and physical project source keys before the
lifecycle transaction. It retains the recorded base and project-workspace source
before reconstruction. Nested restore therefore cannot wait for the project
source while holding the lifecycle lock. The actual reopen contention regression
holds the project key on another connection, checks that no lifecycle key is
held, then releases the source and checks the restored nested commit and files.
@@ -0,0 +1,26 @@
# Preserve database-only failed controller evidence
A retained failed run can already have a complete encrypted controller checkpoint
and its original inline session snapshot while both legacy controller files are
absent. This is preserved evidence. It does not become successful or executable
recovery authority merely because the instance changes storage mode.
The migration owner accepts this case only after the canonical checkpoint decoder
validates every record and its company/issue/run encryption binding. The selected
run, admitted execution and inline snapshot must match company, agent, issue, run
and normalized session. The decoded checkpoint and protocol header must also match
the run, Runner instance and normalized session. The exact controller and supervisor
paths must both be absent. A remaining file, symbolic link, unsafe path, invalid
binding or failed read keeps the obligation blocked.
The earlier failed checkpoint case with no inline snapshot keeps its existing
predicate. The new case does not replace an inline snapshot, create recovery
metadata, change a failed status or coordinator, settle an action, or move a task
pointer. Present legacy payloads still need the existing preservation path.
The focused regression uses the existing encrypted PostgreSQL checkpoint fixture.
It first reproduces the startup blocker. It then verifies the absent-files case,
remaining controller and supervisor files, foreign inline identities and foreign
checkpoint Runner/session identities. Existing null-snapshot and online/offline
archive controls run with it. The original queued turn, run, coordinator and
checkpoint rows stay unchanged, and failed evidence stays non-recoverable.
@@ -0,0 +1,203 @@
# Durable ownership and reconciliation for restart safety
Status: draft for review, 2026-11-07
Evidence: the 2026-11-06/07 control-plane restart chaos campaign (plan: `doc/plans/2026-11-06-control-plane-restart-chaos.md`). It merged about 69 PRs, #19469 through #19807. The "RC-" IDs below are that campaign's signature IDs.
## Recommendation
Stop fixing restart bugs one crash window at a time. Add one shared ownership primitive, one write-ahead intent rule for outside effects, and one reconciler that brings every in-progress record to a terminal state. Then move the existing hand-built mechanisms onto it, one at a time. Gate it in CI with a deterministic crash-point suite, not with random chaos timing.
## Why: the campaign's bugs share one shape
A control-plane restart can land between any two durable writes. It leaves an in-progress record owned by a process that is now dead. That record might be a lease, a startup fence, a coordinator claim, a workspace operation, an accounting receipt, a reservation or a deferred wake. Each subsystem decided alone whether it was safe to continue, and none could prove the owner was dead. They fail closed, so the leftover record became a Retry, a company-wide hold, or a run that stays `running` forever.
| Class | Examples (ledger IDs) | Approximate PRs |
|---|---|---|
| A dead owner's fence or lease that only a TTL releases | RC-16/16b/16c, 29d, 39, 41, 42, 47, 50, 57, 62, 64 | 12 |
| Accounting completeness gates company admission | RC-05/06 family, including regressions RC-06k and RC-06m | 8 |
| Work intents (wakes, Retries, comments) end terminally on a temporary refusal | RC-37, 45, 58, s7532 | 4 |
| Retry budgets charged per restart, not per unit of work | RC-22/22b/22c, 40, 60 | 5 |
| Events from an earlier generation break the current turn | RC-15, 20/20b/20c, 33/33b, 59, 61 | 10 |
The repeated reinvention shows up in the code:
- 40 server files reference a boot id.
- About 25 implement process liveness (`isProcessAlive`, `isProcessGroupAlive`, `controllerProcessExited`, `proveLocalWorkspaceOwnerStopped`, and others).
- 15 or more `*ownership*` / `*owner*` modules each carry their own rules.
The accounting fixes regressed each other three times. Twice the cause was an inferred-evidence check that was subtly wrong: key presence counted as proof, and an empty map was not null (RC-06m).
## Invariants the design must guarantee
These become reconciler checks and crash-suite assertions. They should also be added to `doc/SPEC-implementation.md`.
1. **Owned:** every in-progress durable record carries an owner stamp and a generation.
2. **Settled:** a record whose owner is proven gone reaches a terminal state within a bounded time (target: one reconciler pass after boot). Proof is by identity, never by age.
3. **Admission is independent of cleanup:** admitting new work never waits on another run's cleanup or accounting completeness. Only real spend reaching a hard stop, an explicit pause, or an inactive company refuses work.
4. **Exactly-once intents:** an accepted work intent (assignment, comment, Retry, recovery) is delivered exactly once, unless the task is cancelled or a hard stop applies. A temporary refusal defers it and never ends it terminally.
5. **Work-based budgets:** a restart consumes run budget only when the resumed attempt started provider work and made no progress.
6. **Generation fencing:** every runner and provider event carries its generation and turn. Events from an earlier generation are recorded as history and never fail the current turn.
7. **Visible next action:** every non-terminal task has a next action, either automatic or a working control. "Waiting" with no owner is a violation.
8. **Fast turns:** TTFT and agent boot stay within their measured budgets. Restart safety work never adds waiting or file transfer before `turn.start` (see "Performance constraint" below).
## Performance constraint: a turn must start as fast as possible
When someone messages an agent, the reply has to start as fast as possible. Restart safety must not make turns slow. A turn that spends minutes moving files between the sandbox and the host before the model starts fails this design, however correct it is.
### What we measure
These are measured for every run and recorded in the run log. With an OTLP endpoint set, they are also emitted as spans on the existing `run.startup.step` / `paperclip.sandbox.startup.*` path in `packages/adapter-utils/src/acpx-engine/startup-timing.ts`. They are never sent to Telemetry (AGENTS.md §5.7).
| Metric | From | To |
|---|---|---|
| **Time to first token (TTFT)** | user message or wake accepted | first deliverable model text. This extends the existing `task.time_to_first_token` (`native-session-executor.ts`) back to the message timestamp. |
| Agent boot | run claimed | provider session ready to accept `turn.start` |
| Critical-path breakdown | | queue wait, admission, workspace preparation and staging, environment acquire, runner launch, session open/resume, `turn.start` to the provider's first event |
| Restart recovery latency | control plane ready | recovered run's next provider event, for reattach and resume separately |
| Turn tail | provider turn completed | run terminal and task updated, including copyback and sync-out |
Each metric is reported by adapter (Codex, Claude/ACPX, OpenCode) and environment (local, Daytona), for warm and cold starts, as p50/p95.
### Rules for this design
1. **Nothing new goes on the critical path.** Intent records, owner stamps and generation stamps are small single-row writes in the transaction the path already uses. The reconciler runs only in the background. It never adds a wait before `turn.start`.
2. **File transfer must not block the next turn.** A follow-up turn on a still-valid sandbox lease uses the workspace that is already there. Sync-out, checkpoints and copyback run after the turn or alongside it, and incrementally. They never run as a full round trip before the model starts. A restart with a live runner reattaches; it does not restage.
3. **Fixed waits become event-driven.** Each one the campaign found is a target:
- RC-04: graceful drain waits the full 30 s;
- RC-09: a cold OpenCode attach takes 31–38 s against a 30 s bound;
- RC-36: `session.open` 30 s;
- RC-20b: a late Codex turn start of about 27 s;
- RC-27: a 5-minute orphan reaper.
4. **Phase 0 measures, then sets budgets.** It records a baseline for every metric above. Phase 1 sets p50/p95 budgets per adapter and environment. From then on, every phase's exit criteria include "no regression against the baseline". The on-demand crash-point suite and the chaos soak also report TTFT and restart recovery latency.
## Relationship to the zero-downtime upgrade work
Separate work moves the control plane toward zero-downtime upgrades on an ephemeral filesystem. PostgreSQL holds execution authority and durable protocol state. Object storage holds workspace archives, provider checkpoints, logs, traces and skill assets. Replacement servers reuse stable encryption and signing secrets. Most of the foundations are on master; #19830 is still open. Entry points:
- `server/src/services/ephemeral-server-contract.ts`
- `server/src/services/heartbeat/upgrade-readiness.ts`
- `server/src/services/native-runtime/native-runner-ownership.ts`
- `server/src/services/native-runtime/native-finalization-reconciler.ts`
- `server/src/services/native-runtime/native-harness-filesystem-independence.ts`
- in paperclip-cloud, `src/fleet/ephemeral-overlap.ts`
This plan builds on that work and must not fork it:
1. **The remote runner keeps running across a control-plane replacement.** The replacement server takes fenced ownership and reconciles durable checkpoints and results. It never starts a duplicate execution. So for remote runners, ownership moves by **fenced handoff** (lease and fence token), not by a process-liveness proof. A different host is never proof that an owner is dead. The `OwnerStamp` in section A records the fence token. `proveOwnerGone` applies only to local processes and to providers that give a platform proof.
2. **Graceful handoff and abrupt death are different paths.** Graceful replacement releases ownership explicitly. Abrupt death during a tool, result acceptance, finalization, or workspace export is recovered by proof or fence expiry. The crash-point suite covers both, including stale-owner fencing and duplicate result and accounting protection.
3. **Obligations stay separate.** Ownership, accepted results, workspace preservation and accounting are separate obligations. Each Phase 0 inventory row names exactly one of them, and settling one never settles another:
- preserving files never marks a failed run successful;
- preserving files never clears an unresolved export or recovery hold;
- preserving files never grants provider-resume authority.
WEB evidence receipts prove filesystem preservation, not successful remote recovery.
4. **Extend the existing pieces.** Section C extends `native-finalization-reconciler.ts`; it does not add a second loop. Section A starts from `native-runner-ownership.ts` and `controller-process-identity.ts`. Phase 0 includes the upgrade work's record kinds, and its owner reviews the inventory.
5. **External tool effects are not exactly-once.** The design guarantees exactly-once for Paperclip's own records (results, accounting, wakes). A tool's outside effect may run again after a crash. The agent checks before it repeats one; see #19804.
6. **Object storage must stay off the turn path.** Restoring an archive or checkpoint must not delay `turn.start` when a live workspace or runner exists. A cold restore that cannot be avoided counts against the time-to-first-token budget and is reported as its own step.
## Design
### A. One ownership primitive
Start from `server/src/services/controller-process-identity.ts`, which #19696 introduced. Today only `sandbox-creation-operations.ts`, `heartbeat/recovery.ts` and `environment-runtime.ts` (#19749) use it. #19713 (`native-dead-controller-result-lease.ts`) and #19717 (`native-workspace-owner-stop-proof.ts`) built their own proofs of the same fact in the same night.
- `OwnerStamp` = `{ host, pidNamespace, bootId, pid, processStartedAt, processGroupId?, generation, scope }`.
- One prover, `proveOwnerGone(stamp)`:
- A local proof requires the same host and pid namespace, plus either a dead pid or a reused pid (checked against the start time), plus an empty or older process group.
- A remote proof is delegated to the environment provider: lease termination, sandbox state, or a runner identity marker.
- The prover returns `gone | alive | unprovable`. `unprovable` never releases a record.
- Every record kind in the inventory below stores an `OwnerStamp` when it enters an in-progress state.
### B. Write-ahead intents for outside effects
The rule: record an intent before the effect, and record the outcome after it. Recovery reads the intent log, not inferred evidence. Several kinds already do this and should share one shape and one settle path:
- `sandbox_creation_operations` (#18357, #19696)
- the runner's `startupAttempt` with `beforeSpawn` (#19784)
- the controller's `turn.start` journal (#19772)
- workspace operations (#19807)
Effects still to cover:
- copyback stop
- runner release/destroy, partly done in #19749
- reservation hold and release
- workspace staging
### C. One reconciler
A registry maps each ownable record kind to `{ listInProgress, settle(record, proof), invariantChecks }`. One loop runs at startup and every 30 s:
- For each in-progress record with an owner stamp that isn't the current boot, call `proveOwnerGone`. If the owner is gone, call `settle`. If the proof is unprovable, surface an operator action.
- Run the invariant checks (I1–I7). Each violation writes a `recovery.invariant_violation` activity and a run-log event with the record and the rule. The loop repairs a violation when a safe repair is registered.
- Keep the loop sleep-aware: no work when nothing is in progress (see the `sleep-aware-background-work` skill).
This replaces the per-subsystem startup sweeps gradually, by deleting each one once its kind moves onto the registry.
### D. Separate admission from accounting
- Admission reads per-run exposure only: reserved plus observed cost. Hard stops use settled or estimated spend.
- A run with unfinished accounting adds its bounded estimate to exposure. It never adds a company-wide gate.
- This finishes the direction of #19497 → #19724 → #19826 and removes the `waitingRunCount` gate, rather than narrowing it again.
### E. Work-intent outbox
- Wake requests get a closed set of terminal reasons: delivered, consumed by a later run, task cancelled, or hard stop. Every temporary refusal becomes `deferred` with a reason and a wake trigger.
- Any event that releases a hold (accounting settled, lock released, pause lifted) promotes deferred intents through one function. The reconciler covers any trigger that is missed.
- Consumption is recorded by the comment or intent ID on the run that delivered it. That gives exactly-once delivery: no drops (RC-37) and no double delivery (RC-53).
### F. Work-based attempt budgets
Charge an attempt only on a recorded outcome that proves provider work started. An intent alone is not that proof. Use the outcome records from B:
- **Not charged:**
- a startup intent that is still marked `beforeSpawn`, because no process started;
- a startup that was spawned but never admitted;
- a `turn.start` that the provider did not accept.
- **Charged:** an attempt whose provider accepted a turn (the accepted `turn.start` outcome or `turn.started`), and which then made no progress before the next failure. Progress is defined as in #19720: completed work for that turn.
The rules from #19720 and #19747 then become one rule over these outcome records, instead of watermark heuristics. The on-demand crash-point suite asserts this at the pre-spawn and pre-accept crash points.
### G. Generation stamping
The runner stamps `generation` and `turnId` on every event. Driver and session code treat an older generation as history. #19718, #19722, #19772 and #19783 each special-cased one variant of this.
## Testing: enumerate crash points, don't sample them
- Add named crash points at each durable write boundary, for example `crashPoint("sandbox.create.after_provider_before_lease")`. In tests, an environment variable makes the process SIGKILL itself at the Nth hit. In production they compile to no-ops.
- An on-demand suite boots the stack once per crash point, runs one task per workload, restarts, and asserts invariants I1–I7. It runs locally or by manual CI dispatch, filtered to the crash points a change touches. It is not a required PR check.
- Keep the chaos harness as a soak test for timing interactions the enumeration misses. Its R1–R6 graders should map onto I1–I7.
- The random waves found 20 ms–1 s windows by luck. Enumeration finds them on purpose, and it finds the windows we haven't hit yet.
## Phases
| Phase | Scope | Exit criteria |
|---|---|---|
| 0. Inventory and baseline | List every in-progress record kind: table, column, writer, current release rule, current proof. Start from the 43 ownership, lease, sweep and fence modules. Separately, instrument and record the TTFT, boot and restart-latency baseline per adapter and environment, and list everything on the critical path before `turn.start`. | An inventory doc reviewed, with each kind mapped to A–G. A baseline report with the slowest critical-path steps ranked. |
| 1. Primitive and observe-only reconciler | `OwnerStamp`, `proveOwnerGone`, registry, reconciler running invariant checks in report-only mode. No behavior change. | The invariant violation rate is measured in chaos waves and staging. |
| 2. Move kinds over | One PR per kind or small group. Each switches release to the shared path and deletes its bespoke code, with a crash-point test. | Bespoke liveness code removed; crash-point suite green per kind. |
| 3. Admission and outbox | D and E. These change the most user-visible behavior, so they wait until Phase 1 data exists. | No company-wide gate; zero terminal skips for temporary reasons. |
| 4. Budgets and generations | F and G. | One attempt-charge rule; no per-variant stale-event code. |
| 5. On-demand suite | The crash-point suite is runnable locally and by manual CI dispatch, filtered by crash point or record kind. The chaos soak stays available too. | The suite is green on the release candidate; chaos soak 500-pass streak target. |
## Risks
- **Failing open.** A wrong "owner gone" proof releases live work and can duplicate side effects. Mitigation: `unprovable` never releases; a different host never counts as proof; every settle path is compare-and-swap; crash-point tests cover live-owner counter-cases for every kind.
- **Remote proof.** Daytona and other providers vary. Mitigation: the provider plugin contract returns the same `gone | alive | unprovable`.
- **Reconciler cost and idle sleep.** Mitigation: an indexed in-progress query per kind; skip when no kind has in-progress rows.
- **Migration churn in hot files** (`heartbeat.ts`, `native-session-executor.ts`, `budgets.ts`). Mitigation: Phase 1 adds only new code. Each Phase 2 PR is narrow and follows `rules/heartbeat-execution-boundaries.mdc`.
- **Latency regression.** New writes or proofs creep onto the turn path. Mitigation: invariant 8, a TTFT and boot budget check in every phase's exit criteria, and a rule that proofs needing I/O (process listing, provider API calls) run in the reconciler, never before `turn.start`.
- **Telemetry boundary.** Invariant violations go to the run log and activity log, not to Telemetry (AGENTS.md §5.7).
## Decisions (Dotta, 2026-11-07)
1. **Remove the company-wide accounting gate entirely.** Phase 3 deletes the gate; it does not narrow it further. Admission reads per-run exposure and hard stops only. This is a product rule and goes into `doc/SPEC-implementation.md`.
2. **The crash-point suite runs on demand and is not a required PR check.** It is available as a local command and as a manual (`workflow_dispatch`) CI run that can target selected crash points or record kinds. Phase 5's exit criterion changes to "the suite is runnable on demand and green on the release candidate".
3. **Inventory ownership:** one owning agent, with parallel per-subsystem readers (see "Phase 0 ownership" below).
4. **Land the open restart PRs first:** #19727, #19826 and #19829 merge as they are. They shrink Phase 2.
## Phase 0 ownership
One agent owns the inventory end to end. That keeps one schema, one set of definitions for "in progress", "owner" and "proof", and one reviewer of cross-subsystem cases. The owner fans out reading to a few parallel readers, one per subsystem:
- native runtime and runner
- environments and sandboxes
- workspaces
- accounting and budgets
- wake queue and recovery
Each reader fills fixed columns for its modules: table and column, writer, in-progress states, current release rule, current liveness proof, and which invariant it touches. The owner merges the rows, resolves overlaps (one record released by two paths, or two proofs of the same fact), and maps each kind to A–G. Dotta reviews the finished inventory once before Phase 1.
@@ -0,0 +1,90 @@
# Online Git source preservation
The online importer must exclude every writer of a selected checkout, its Git
administration, and all common or borrowed object stores. A checkout's task key
alone does not cover a shared project base or a private cache owned by another
task. Unrelated remote work must continue.
The source owner reads bounded, stable Git pointer, `commondir`, and alternates
metadata. It maps each physical directory to the existing company/task source
key or managed-project source key. It derives a transitive company cohort from
those owners and declared project sources. It rejects foreign physical aliases,
unknown stores, bare repositories, symlink metadata, network alternates, and
uncertain or excessive metadata. It preserves every object store; HEAD alone is
not a source closure. The bounds are 1,000 company records, 256 cohort records,
128 source keys, 32 borrowed stores, and four alternate levels. Foreign minimal
paths use the existing bounded scan of 256 records per page, at most 40 pages.
Configured directory aliases resolve through the existing canonical path owner
before Git inspection. Git pointer files and administration/object-store links
retain their strict checks. A configured symlink does not make a direct symlink
migration source admissible.
The runtime retains the existing plain-task-directory treatment of an exact
regular `.git/info/exclude` fragment. It is not an operational Git repository.
Git pointers and object stores retain their topology checks, and migration
rejects the fragment instead of treating it as an ordinary snapshot. A blocked
reopen returns its existing structured failure without attempting reconstruction.
Arbitrary self-hosted Git paths and file URLs retain the genuine offline
maintenance path; online migration requires a proven physical source owner.
Physical exclusion enumerates both `cwd` and `providerRef` through one source
path owner. Company/foreign alias checks, early admission, layout checks and
filesystem mutation owners use that set. A managed or Git source in either
locator requires a proven scope before the legacy fallback. Capture and display
keep their existing preferred-path selection. Missing archived sources retain
their existing reconstruction path.
Preparation, lifecycle, runtime-service, expiration, and reopen owners retain
the same physical keys before filesystem mutation or lifecycle exclusion. The
importer holds all cohort task/project and lifecycle keys through publication.
It also holds the existing per-run physical finalizer keys for each exact native
recovery proof. It re-inspects the complete source proof before capture and
publication. Changed descriptors, allocations, owners, aliases, or Git metadata
reject publication. Prepared sources and unresolved recovery warnings retain
their original state and evidence. They are not marked finalized or cleared.
Present nested workspaces must import before their parent. The existing owning
nested-import path downloads and restores each immutable child snapshot and
compares it with a fresh source capture before excluding that child. Missing or
changed coverage remains blocked. The archive retains its three source walks,
history normalization, Git/fsck checks, manifest ordering, and byte format. Its
self-contained remote function checks that every Git store belongs to the held
physical source roots. It preserves the original source files.
Ordinary capture does not read an enclosing host repository outside its root.
Its canonical ordinary proof remains valid there. Any `.git` marker at the
selected root still requires Git admission. A completed nested operation stays
in the parent layout guard unless the derived child proof covers that exact
operation and physical path. Missing or revoked snapshot coverage cannot turn
the leftover child into an ordinary parent directory.
Receipt path scans retain canonical resolution for aliases. A lexical SQL prefix
could hide a child reached through a symlinked ancestor. The finite receipt bound
remains in force; query optimization must preserve that alias coverage.
Imported child paths come from the same exact completed migration/snapshot
selector used by nested source verification. Their physical Git owners remain
in the parent fence after their database rows become remote. The selector bounds
company migration receipts to 10,240 and binds each child path, operation,
snapshot, digest and Git metadata proof. Current remote child execution does not
reacquire authority over those historical host files.
A source metadata observation at `2026-11-06T23:15:08.979Z` examined 160 pending
Git records and 120 unique paths. It found 92 Git pointers, 49 Git directories,
19 absent archived roots, and nine common/object stores. No alternate or uncertain
metadata was observed. Project cohort sizes were 126, 27, 4, 1, 1, and 1; the
largest contained 110 distinct task owners. These observations bound useful
cohorts. They are not a coherent snapshot, acquired lock proof, or claim that all
160 are eligible. Missing archived sources keep their existing authenticated
reconstruction path. Remaining provider, recovery, preparation, runtime, and
source-change blockers remain authoritative.
Focused verification uses the real importer and local storage owner. It covers
prepared sibling recovery, unrelated running remote work, private cache ownership,
source changes during upload, leaf-first exact child coverage, and existing Git
expiration/reopen consumers. Archive tests exercise both local calls and the
serialized remote function, compare archive bytes and digests, remove every
original source, restore uncommitted/staged work, and run fsck. No hosted import,
hold, cancellation, provider call, or shared-cache Git command is part of these
local tests.
+35
View File
@@ -48,6 +48,41 @@ Managed instances select a distribution key through the existing
compatibility, worker and health mechanisms apply. A worker or install failure
is recorded as a plugin error without taking down the application.
A managed instance can also give a selected plugin process variables through
`plugins.workerEnv`. The document holds variable names, not values. The deployment
sets the values in the server's process environment:
```json
{ "plugins": {
"autoInstall": ["example-extension"],
"workerEnv": { "example-extension": ["EXAMPLE_ORIGIN", "EXAMPLE_CAPABILITY"] }
} }
```
Each key must be in `autoInstall`. Startup fails if a name looks like a host
credential, such as one with `KEY`, `TOKEN`, `SECRET`, `PASSWORD`, `DATABASE` or
`JWT` as a word, or if it controls how the worker loads code, such as
`NODE_OPTIONS`. Give a granted variable a name for its own use, not a reused host
variable. Only the plugin with that catalog key receives the variables. A managed
instance runs a selected plugin only from the current image, so an install from
another source does not start.
A grant does not give access to any company. To let a plugin serve a company with
no saved configuration, name the company in `plugins.enabledCompanies`:
```json
{ "plugins": {
"autoInstall": ["example-extension"],
"enabledCompanies": { "example-extension": ["<company-uuid>"] }
} }
```
The plugin then receives resource lifecycle events for that company and can use
project instance secrets there. A company can still disable the plugin. This does
not give access for background worker calls. Those still need a saved company
configuration. Core reads the list from the document on each use and does not save
it, so a database restore cannot bring back access that the document removed.
The catalog alone does not auto-enable plugins on self-hosted instances.
Operators can explicitly install catalog entries through the normal plugin
CLI. The package's manifest ID and version must match the catalog.
+188 -5
View File
@@ -45,6 +45,20 @@ See [the schemas](../../packages/plugins/sdk/src/environment-tasks.ts) for all f
| `credentials` | `credentials` | Return the selected account after process termination. |
| `stop` | `accepted` | Stop the task and its descendants. Fence a delayed submission. |
| `complete` | `accepted` | Release task resources after Core saves required results. |
| `release` | `released` | End active use of an exact, settled Runner session. Requires `releaseResumeVersion: 1`. |
| `resume` | `resume` | Request the same logical session with a fresh bootstrap ticket. Requires `releaseResumeVersion: 1`. |
For a refused `submit` with `resumeFrom`, a driver can throw an error with
`status: 409`, `code: "SESSION_STATE_UNAVAILABLE"`, and `reason` set to
`"unresolved_operation"` or `"source_state_unverified"`. The SDK worker and
Core preserve only these closed diagnostic fields. They replace the provider
message and omit provider identities and text. Read the validated RPC envelope
with `readEnvironmentTaskErrorDiagnostic`.
This diagnostic does not authorize a retry, replacement, cleanup or state
change. Other task failures keep the existing generic error. An older SDK
does not carry this diagnostic. Operation and call identities, when needed,
must come from Core's own retained records.
Each result must contain the task ID from the request. Core rejects a result
with a different ID or result kind. A WSS URL must have no user information,
@@ -55,6 +69,127 @@ authorized project IDs, and optional launch limits. It can also contain a saved
task ID for session reuse and a typed Codex or Claude credential. Core does not
supply an executable command or a guest path.
## Release and resume
`release` ends Core's active use of an exact, settled Runner session. Core calls
it only after the turn, required events, journal writes, tool results and turn
cleanup have settled. The provider verifies the six-field `identity` and refuses
stale, busy or unsettled requests. `released` does not cancel work or replace
`stop`, `complete`, `archive_state` or the allocation's `environmentReleaseLease`.
Hosts should retain reusable resources when capacity allows, but can reclaim
them under retention and capacity policy. Release guarantees neither retention
nor physical termination. The same contract applies to Codex and ACPX, with no
same-process-ID guarantee.
`resume` requests that same logical session using the source `identity` and a
fresh `bootstrapTicket`. Both operations retain the outer request's authorized
company, environment, task, lease and run scope. Resume requires an authenticated
current claimant run and lease; reject a null `runId`. Verify the source, account,
workspace and current authority before installing the ticket or reserving reuse.
The ticket is bounded transient authentication input, as on submission. Never
copy its bytes into status, metadata, logs, results or durable replay records.
The `resume` result has a closed `outcome`:
- `resumed`: the same logical session is exclusively reserved for this claimant,
atomically against eviction and competing claims. Core obtains `connection`,
authenticates with the fresh ticket and performs ordinary `run.attach` with its
new identity. No new turn is permitted before attachment succeeds.
- `unavailable`: the host knows reuse is impossible, for example after eviction.
Core may select ordinary recovery, preserving its authorization, credential,
workspace and stop checks. This is not proof of physical termination.
Source mismatches, authentication failures, conflicting claimants, transport
failures and ambiguous claims remain errors. They must not become `unavailable`,
authorize replacement, or silently create a new logical session.
One release episode is allowed per exact source identity; ordinary `run.attach`
rotates authority before a later release. Release retries replay the original
result without releasing a resumed owner again. Resume retries with the same
source, claimant and ticket reconcile the same claim and original outcome.
Changed claimants or tickets conflict. A lost reply requires reconciliation,
not a replacement ticket or an assumption of eviction. Preserve replay fencing
after reclamation so delayed requests cannot change a newer owner.
Advertise `releaseResumeVersion: 1` from `describe` only when both operations are
implemented. Core must check it before use. Absence keeps the existing lifecycle;
warm or source recovery capabilities do not imply support. Older strict Core
schemas reject this description field, so coordinate advertisement with a
compatible Core version.
Core uses this pair only for explicitly supported warm tasks with a selected
account. It releases after the turn's required events, tool results, journal
writes and cleanup have settled. It keeps the exact session busy until the host
confirms release and Core records that source. Existing tasks without the
capability retain their ordinary warm lifecycle.
Before release, Core stops connection pings and reconnect work while keeping the
existing receive/ACK path open. A confirmed release closes the route and stops
its idle polling. An ambiguous release stays with the original run for bounded
reconciliation. It cannot pass to a new run's task scope. Cancellation or an
expired reconciliation budget uses ordinary host stop and physical cleanup;
it does not change an already completed business result.
Resume first checks the new run's account, credential revision and project
scope. It uses one fresh transient ticket, preserving that exact ticket through
ambiguous replies. Core authenticates the source before warm readiness, provider
preparation and ordinary `run.attach`; no new turn can pass that handover.
Generic RPC errors do not prove refusal or permit a new ticket, reconnect or
turn replay. A known `unavailable` result still requires ordinary physical
cleanup. Transparent cold fallback remains a separate qualification requirement.
A normal Core cache timeout detaches only Core's quiet local owner. The host
keeps its retained resources under its own retention policy. Core's existing
completed-session record stores only the confirmed source, physical task and a
digest of the selected account, credential revision and workspace scope. The
transport returns the exact source after the host's release receipt; Core does
not infer it from a local checkpoint marker, which may still name an older run.
An invalid source receipt or failed proof write keeps cleanup under the same
owner until physical retirement finishes. Later
cache adoption validates that Core-authored proof before claiming or
reconnecting. A stale or missing proof never licenses a claim. A changed
selection remains blocked until guarded retirement/recovery; it does not imply
that a cold launch is safe. Confirmed claim or physical cleanup clears only the
matching proof. Raw tickets are never stored in this record.
When graceful host eviction has saved a suspended session and Core has recorded
physical stop, a later allocation can use ordinary `resumeFrom` recovery. Core
checks the old task, source identity and current scope before detaching a cached
released controller. Cached and detached paths then use the same retained-state
checks. Missing state, a foreign identity or an unsettled checkpoint refuses
continuation; Core does not replace the provider session on that refusal. This
covers eviction before allocation and stopped-task inspection during `describe`.
A later loss during claim still needs reconciliation. Abrupt process death without
a suspended checkpoint remains a separate recovery path.
These Core paths do not advertise the capability or opt ordinary tasks into a
private host channel. Existing durable lease and plugin-worker idle checks still
apply; retaining a released Runner does not by itself make a tenant safe to sleep.
Provider recovery, bounded host retention and deployment qualification are still
required before activation. Recovery from a Core crash
between claim and source authentication is not qualified by this integration.
## Error codes
Core does not read the text of a task error. To report a failure that Core can
act on, throw `PluginEnvironmentTaskError` with a code from
`ENVIRONMENT_TASK_ERROR_CODES`. The SDK sends only the code as structured RPC
error data.
| Code | When to use it | Core behavior |
| --- | --- | --- |
| `runner_revision_mismatch` | The installed Runner does not match the revision that the plugin launches. Throw it from `describe` or `submit` before you prepare or start a task. | A run that has not started provider work is retried for up to 10 minutes. Agent verification checks again every minute. The task is not blocked during this wait. |
```ts
import { PluginEnvironmentTaskError } from "@paperclipai/plugin-sdk";
if (installed.revision !== expectedRevision) {
throw new PluginEnvironmentTaskError("runner_revision_mismatch");
}
```
Any other error stays an ordinary setup failure. The source refusal diagnostic above is separate: it uses its own envelope and does not authorize a retry.
## Launch preparation
A provider can return `launchPreparationVersion: 1` from `describe`.
@@ -84,6 +219,49 @@ authority as submission.
Core requires preparation version 1 for native plugin execution. It validates
and saves the result before it submits its configuration ID.
### Stopped source reconciliation
A plugin’s `describe` response advertises `codexSourceRecoveryVersion: 1` for
Codex App Server and `acpxSourceRecoveryVersion: 1` for ACPX. It can advertise
either, both, or neither. Each field means the host can verify and restore a
stopped source for that provider without starting another turn. Core requires
the field for the selected provider and verifies the run’s eligibility.
Core admission recognizes native Codex and qualified Claude through ACPX. ACPX
Codex is a different provider and cannot use either attestation. Claude admission
checks both saved and current profiles against Runner’s qualified catalog;
decodable historical profiles do not authorize recovery. Preparation, submission
and attachment retain that provider binding. Runner’s launch factory normalizes
Claude’s model, mode, permissions, effort and runtime content before Core compares
them with the saved source or current owner. Core revalidates the claim and
profiles after worker replies. This admission does not activate executor recovery.
It grants no replacement authority or exception to deterministic recovery refusals.
The authenticated request belongs to
the current run while `prepare` and `submit` carry the predecessor Runner identity.
Core derives this relationship from its saved source-consumer claim; a mismatched
run ID alone never grants recovery. Both operations must name the same `resumeFrom`, exact source Runner identity, and current
`accountIdentity`. The request and lease still belong to the current run.
Preparation must save this source binding with its new configuration. Submission
must verify the same binding before it starts the source process.
Core requires the source run to have a committed successful result, an encrypted
PRP journal, confirmed physical termination, and no other source consumer. Core
claims the source under the current native controller lease. This does not change
the source run's status or result. A stale source controller cannot write its
journal after this claim. Workspace export and settlement holds still apply.
After source submission, the same current owner can prepare its attachment with
the exact source as `priorIdentity`. This does not grant ordinary warm reuse.
The provider must verify the original account, workspace, runtime, and source
ownership before launch. The provider must reject saved active goals, pending tools, ambiguous starts, and
uncertain work before launch. Runner verifies that a saved idle provider remains
idle when restored.
Until the existing authenticated `run.attach` operation changes authority, the
Core retains semantic inputs and sends no fresh predecessor business commands
while the restored source reconciles its journal and known cleanup. Ordinary suspended recovery and live attachment keep their existing
checks. Providers that omit the capability do not support predecessor recovery.
## Launch authentication validation
Declare `projectAuthCheckVersion: 1` in `describe` for each native task launch.
@@ -111,8 +289,7 @@ change, reject submission and require new preparation and validation.
Return `project_auth` with the same configuration ID and provider.
Set `allowed: true` only after the checks pass.
Core saves this result on the task lease. Core requires the exact configuration,
provider, and project set before each submission. This also applies when the
provider uses an installed account.
provider, and project set before each submission.
A new preparation or check removes the prior validation. A failed check does not
authorize credential transfer. The dispatch update also checks that validation
has not changed while Core waited for database work.
@@ -157,6 +334,11 @@ An accepted stop does not prove termination. Set `executionStopped: true`
only after the Runner and all descendants have stopped. A terminal phase alone
does not permit Core to release execution authority.
Core binds each Runner owner to the physical task ID returned by its description.
An old owner cannot follow a changed lease to stop or complete its replacement.
Late stopped-state, retained-session and credential-handoff writes must still
match that same physical task. A new owner can clean up the replacement.
Core fences new submissions when cleanup starts. It stops the saved task,
checks termination, saves credential results, and completes the task before it
releases or destroys the environment lease. An uncertain result leaves a saved
@@ -197,9 +379,10 @@ after the probe and its descendants stop. A lost reply keeps the task available
for `stop`, `status`, and `complete`. Core checks the profile and model before
it accepts the account. Do not store the token in task metadata or logs.
For a native hello probe, select a saved AI account or use the installed account.
Core rejects local environment overrides when no saved account is selected.
This includes draft credentials. The probe uses the installed VM environment. Core does not test a different installed account in their place.
A native hello probe requires a managed AI connection and an isolated Runner home.
Without them, the probe fails the `ai_connection_required` check before Core
acquires a lease or calls a task operation. See
[Managed AI connection required](PLUGIN_RUNNER_EXECUTION.md#managed-ai-connection-required).
Only Core can supply the selected account identity and local credential home.
## Fresh attempts
+53 -2
View File
@@ -4,6 +4,23 @@ Core can run `paperclip_runner` tasks in a plugin environment.
The plugin owns the execution host and its files.
Core owns run identity, selected policy, assigned tools, and the PRP journal.
## Managed AI connection required
A task-capable environment runs only agents with a managed AI connection.
Core does not use an account installed on the plugin's execution host.
Before it acquires a lease or calls the plugin, Core fails a new run that has
no managed AI connection. A `homeMode: "preconfigured"` Runner home does not use
the agent's AI connection, so Core refuses it in the same way. The run fails as
`configuration_incomplete` with reason `ai_connection_required`. The task is
blocked with a notice that names the fix: choose an AI connection, or switch to
an isolated Runner home. Agent verification applies the same rule before it
resolves the probe target, so it acquires no lease. The agent Test route also
checks before it resolves secrets or allocates a test target. A setup probe without a
managed AI connection, or with a preconfigured home, fails the
`ai_connection_required` check and calls no task operation. Verification then
waits for the configuration to change.
A resumed native run keeps the contract it was admitted under.
## Required hooks
Set `supportsTasks: true` on the environment driver.
@@ -214,6 +231,41 @@ Provider upgrades, activation, company re-enablement, and finalization settlemen
wake cleanup transactionally. No table, independent timer, or background scan of
all remote PR refs is required.
### Workspace operation failures
Core separates a failed `workspaceOperation` call by what it knows about the
outcome:
| Failure | Run error | Core behavior |
| --- | --- | --- |
| The worker was not running, so no request was sent | `Workspace storage provider is not running; retry the same workspace` | A run that has not started provider work is retried as `plugin_worker_unavailable`. |
| The call timed out, or the worker exited with the call pending | A fixed timeout or stopped-provider message that says to retry the same workspace | The operation may have run, so the same workspace and operation identity must be retried. |
| The provider threw an error with a known reason | The fixed message Core keeps for that reason | Only transient reasons tell the user to retry. |
| The provider threw any other error | `Workspace storage provider reported a failure; retry the same workspace` | Unchanged setup failure. |
To report a reason, throw `PluginWorkspaceOperationError` from
`onWorkspaceOperation`, or any error with an own `workspaceFailureReason`
property. The reason must come from `WORKSPACE_OPERATION_FAILURE_REASONS`:
| Reason | Retrying the same workspace can help |
| --- | --- |
| `unsupported_operation`, `configuration_unavailable`, `credential_unavailable`, `storage_grant_unavailable`, `binding_mismatch`, `runtime_connection_invalid` | No |
| `storage_pending`, `runtime_unavailable`, `remote_incomplete`, `context_changed`, `deadline_exceeded` | Yes |
```ts
import { PluginWorkspaceOperationError } from "@paperclipai/plugin-sdk";
throw new PluginWorkspaceOperationError("storage_grant_unavailable", "Project mount grants unavailable");
```
The SDK sends only the reason. Core never reads the provider's message, so a
message cannot reach the run, the API, or Core logs. The run keeps its
`setup_failed` error code and records `resultJson.workspaceProviderFailure` with
the failure `kind`, `reason`, and `retryable` flag. The
`heartbeat execution setup failed` log carries the same fields. A provider may
also record a `plugin.workspace.failure` span whose `plugin.failure_reason` is a
reason from this set or `unknown`.
## Warm task retention
A driver declares `warmTaskVersion: 1`, implements `onEnvironmentResumeLease`,
@@ -224,8 +276,7 @@ plugin runs without an explicit policy prefer warm execution. There is no
provider-specific warm gate or additional setting. Existing runs retain their
admitted lifecycle policy during restart recovery. The existing
`runnerIdleTimeoutMs` controls bounded retention. Fresh warm admission requires
a Core-managed credential identity before lease acquisition. Plugin-owned installed
accounts continue per-turn until such an identity is available; the saved warm
a Core-managed credential identity before lease acquisition. The saved warm
preference does not bypass account ownership checks. Drivers without warm
capability and setup probes remain ephemeral.
+1
View File
@@ -682,6 +682,7 @@ The host provides:
- tool name (without plugin namespace prefix)
- parsed parameters matching the tool's declared schema
- run context: agent ID, run ID, company ID, project ID
(the project ID is an empty string when the run has no project)
The worker executes the tool and returns a typed result (string content, structured data, or error).
@@ -213,3 +213,5 @@ reintroduce a concurrency capability when a runtime path enforces it.
Native export recovery requires the separately discovered `environmentStopLease` RPC (`onEnvironmentStopLease`). It stops the exact allocation and preserves files regardless of its normal release policy. A failed or unconfirmed stop must throw and must never fall back to deletion. The host uses the recorded plugin ID and defers when that worker does not advertise the hook; a generic release method is insufficient evidence of stop-only support. Normal successful ephemeral release remains destructive after verified copyback.
Daytona stop-only preservation disables provider auto-delete and refreshes the provider record to confirm the disabled policy before stopping. An unavailable or unconfirmed policy leaves cleanup pending; it never falls back to stop or delete. The original ephemeral destroy policy applies only after exact accepted-result copyback and commitment.
A provider can refuse Stop while the allocation is in a provider error state. A stop-only hook may run one bounded provider recovery and then stop. When it cannot stop the allocation, it throws `PluginEnvironmentStopError` with a closed reason. The host treats `resource_unrecoverable` and `recovery_failed` as terminal: it escalates the pending cleanup at once and retries at its slowest interval. Deletion is never a fallback.
+8
View File
@@ -73,6 +73,14 @@ access; PAT connections use paginated `/user/repos`. Provider failures are repor
without exposing provider error bodies or credential material. Successful connections
remain selectable when another connection fails.
Task repository preparation distinguishes a temporary provider refusal from a
completed catalog that denies access. Before provider dispatch, known HTTP
availability failures and exhausted quotas use the existing bounded infrastructure
retry and provider deadline. Each successor discovers repositories again under the
same responsible user's current grants. Empty successful catalogs, revoked grants,
and permission denials remain terminal. A partial catalog can authorize a requested
repository only when that repository is present.
`ConnectionSetupFlow` owns provider setup in both Apps and project dialogs. Task
intents retain their existing callback protocol. Standalone dialogs verify the saved
connection through the API after the sign-in popup returns to the instance. Project
+45
View File
@@ -315,6 +315,51 @@ remove expired requests and enforce 10 pending-consent requests per client and 1
instance-wide, independently of further client registrations. Existing grants
remain usable when anonymous registration or authorization is throttled.
## Private Dot original-token authority
`POST /mcp/runner/oauth/cloud-principal` is a machine-only Cloud-to-tenant
identity check for the existing `assistant-connections/client-use` boundary.
It is not an agent-facing REST/MCP tool, a consent endpoint, or a board-control
operation. Normal hostname, runtime-claim and unclaimed-standby checks run
before principal resolution. The route uses the same normally constructed Dot OAuth owner; an
absent owner or current namespace fails closed.
The caller needs an existing Cloud signature for the closed
`mcp-dot:principal` action and the original Dot access token. The existing
signature/nonce verifier applies, with at most a 60-second action lifetime.
The request ID binds a UUID and the SHA-256 digest of this ordered, UTF-8 JSON
field tuple:
```text
["paperclip-dot-original-principal-v1", "mcp-dot:principal", expectedInstanceId,
canonicalVerifiedResource, upstreamClientId, selectingUserId,
requestedCompanyId ?? null, accessToken]
```
This covers every accepted body field, independent of JSON property order.
The raw request is limited to 16 KiB; compressed bodies, invalid UTF-8, malformed
JSON and unknown fields are rejected. The resource must be the current origin's
`/mcp/runner`. The stack, claim, instance, origin and connector identity must
remain current before and after the OAuth call. OAuth authenticates the original
token once and retains its client, selected user, company, purpose, scope,
membership and feature checks. The reply contains only the checked user,
company, grant, client, resource and agent-purpose identity. It creates no board
actor, grant, agent binding or execution authority.
The original token and signed request remain credentials. Request/response
headers, bodies, raw buffers, query data and reflected errors must not enter
logs on accepted or refused paths. The application uses a closed log projection
for this reserved path, including malformed variants; operators must keep the
same restriction at proxies. The Cloud caller requires the already verified
current instance and HTTPS tenant route, refuses redirects, and checks the
bounded reply and current namespace again.
This private boundary does not advertise or enable a hosted Dot profile, grant
anonymous wake authority, or change Personal MCP. Hosted discovery, wake,
capability, operation, event, retry/reactivation and revocation lifecycles and
Muse integration remain separate requirements. A controlled local HTTP proof
does not establish durable Cloud server bootstrap or a deployed provider path.
## Task monitoring with MCP Events
The same authenticated endpoint now supports MCP 2.0 (`2026-07-28`) alongside
Loaded 100 of 2504 files, more files were not shown because too many files have changed in this diff. Show more