mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 06:10:44 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The restart chaos harness runs Daytona waves with a local control
plane that installs the bundled Daytona sandbox-provider plugin from
`packages/plugins/sandbox-providers/daytona`
> - That plugin is excluded from the pnpm workspace, so workspace builds
and `scripts/agent-preflight.mjs` never rebuild it
> - The harness built it only when `dist/` was missing. A long-lived
chaos worktree that moved across many master commits kept serving the
plugin it built on day one
> - As a result, plugin-side fixes such as #19696 (reclaim sandboxes
left by a restart during acquire) and #19754 (keep a runner sandbox
alive through the reconnect grace) were not under test in those waves,
while server and runner fixes were
> - This pull request keys the plugin build on a content hash of its
inputs and rebuilds when the hash changes
> - The benefit is that every Daytona wave tests the plugin of the
commit it claims to test
## Linked Issues or Issue Description
**Bug description**
`ensureDaytonaPlugin()` in `tests/restart-chaos/main.ts` returned early
whenever `dist/manifest.js`, `dist/worker.js` and the Daytona SDK
existed.
**Steps to reproduce**
Build the plugin on commit A, check out a later commit B that changes
`packages/plugins/sandbox-providers/daytona/src`, and run a `--env
daytona` wave. The wave installs the commit-A plugin. In one chaos
worktree, `dist/plugin.js` was 20 hours older than `src/plugin.ts` and
lacked `runnerReconnectGraceMs` from #19754.
**Expected behavior**
The harness rebuilds the plugin whenever its sources, lockfile or the
plugin SDK source changed since the last build.
Refs #19544
## What Changed
- `tests/restart-chaos/plugin-build.ts` (new): `pluginBuildInputHash`
hashes the plugin's `package.json`, `pnpm-lock.yaml`, `tsconfig.json`
and `src/**`, plus extra inputs: the plugin SDK `src`, `package.json`
and `tsconfig.json`, and the root `tsconfig.json` and
`tsconfig.base.json` that both packages extend. It excludes
`node_modules` and `dist`. `pluginBuildState` compares that hash with a
stamp in `dist/.restart-chaos-build-input.sha256` and reports a reason.
- `tests/restart-chaos/main.ts`: `ensureDaytonaPlugin` installs, links
the SDK and builds when the state is stale, then writes the stamp. It
records `daytona_plugin_build_state` and `daytona_plugin_built` in the
timeline.
- `tests/restart-chaos/plugin-build.test.ts` (new): an unstamped build
is stale; the stamped build is up to date; a changed source, lockfile or
SDK source is stale; changes to `node_modules` and `dist` are ignored;
missing outputs or dependencies are stale.
Other out-of-workspace packages, checked as requested:
- `pnpm-workspace.yaml` excludes only
`packages/plugins/sandbox-providers/**`.
- The restart chaos local mode (`--env local`) does not install a
sandbox provider.
- CI's runner-e2e "Prepare bundled Daytona plugin" step runs on a fresh
checkout per job, so it cannot see a stale `dist/`.
- The other sandbox providers are not used by these harnesses. The risk
is therefore limited to long-lived local worktrees running Daytona
waves, which this change covers.
## Verification
- `vitest run --config tests/restart-chaos/vitest.config.ts`: 121/121
pass, including the new staleness tests.
- `tsc -p tests/restart-chaos/tsconfig.json`: no errors in the changed
files.
- Manual: in the chaos worktree, `dist/plugin.js` (built 2026-11-06
11:16) lacked `runnerReconnectGraceMs`. After a rebuild at master
35d41f11bb it contains it.
## Risks
- This change touches the test harness only.
- The first wave after this change rebuilds the plugin once in each
worktree, because existing builds have no stamp. That takes a few
seconds.
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in Claude Code with
tool use (shell, file edits, GitHub CLI). Default context window,
extended thinking.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
64 lines
3.2 KiB
TypeScript
64 lines
3.2 KiB
TypeScript
/**
|
|
* The bundled Daytona plugin lives outside the pnpm workspace, so workspace
|
|
* builds and `agent-preflight` never rebuild it. A worktree that moves to a new
|
|
* master would otherwise keep serving the plugin built for an older commit,
|
|
* and plugin-side fixes would silently go untested.
|
|
*
|
|
* The build is keyed by a content hash of the plugin's inputs, recorded next
|
|
* to its output.
|
|
*/
|
|
import { createHash } from "node:crypto";
|
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
import path from "node:path";
|
|
|
|
export const PLUGIN_BUILD_STAMP = "dist/.restart-chaos-build-input.sha256";
|
|
const INPUT_FILES = ["package.json", "pnpm-lock.yaml", "tsconfig.json"];
|
|
const INPUT_DIRECTORIES = ["src"];
|
|
|
|
function listFiles(root: string, relative: string): string[] {
|
|
const directory = path.join(root, relative);
|
|
if (!existsSync(directory)) return [];
|
|
return readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
|
|
const child = path.posix.join(relative, entry.name);
|
|
if (entry.isDirectory()) return entry.name === "node_modules" ? [] : listFiles(root, child);
|
|
return entry.isFile() ? [child] : [];
|
|
});
|
|
}
|
|
|
|
/** sha256 over the plugin's build inputs: paths and contents, in sorted order. */
|
|
export function pluginBuildInputHash(pluginRoot: string, extraInputs: readonly string[] = []): string {
|
|
const hash = createHash("sha256");
|
|
const files = [...INPUT_FILES.filter(file => existsSync(path.join(pluginRoot, file))),
|
|
...INPUT_DIRECTORIES.flatMap(directory => listFiles(pluginRoot, directory))].sort();
|
|
for (const file of files) {
|
|
hash.update(`${file}\0`);
|
|
hash.update(readFileSync(path.join(pluginRoot, file)));
|
|
hash.update("\0");
|
|
}
|
|
// Inputs outside the plugin directory (for example the linked plugin SDK) are hashed as given.
|
|
for (const extra of [...extraInputs].sort()) {
|
|
hash.update(`extra:${extra}\0`);
|
|
for (const file of statSync(extra, { throwIfNoEntry: false })?.isDirectory() ? listFiles(extra, "").sort() : [""]) {
|
|
const absolute = file ? path.join(extra, file) : extra;
|
|
if (!existsSync(absolute)) continue;
|
|
hash.update(`${file}\0`);
|
|
hash.update(readFileSync(absolute));
|
|
}
|
|
}
|
|
return hash.digest("hex");
|
|
}
|
|
|
|
export interface PluginBuildState { needsInstall: boolean; needsBuild: boolean; inputHash: string; reason: string }
|
|
|
|
/** Whether the installed and built plugin matches its current sources. */
|
|
export function pluginBuildState(pluginRoot: string, extraInputs: readonly string[] = []): PluginBuildState {
|
|
const inputHash = pluginBuildInputHash(pluginRoot, extraInputs);
|
|
const needsInstall = !existsSync(path.join(pluginRoot, "node_modules/@daytonaio/sdk"));
|
|
const outputs = ["dist/manifest.js", "dist/worker.js"].every(file => existsSync(path.join(pluginRoot, file)));
|
|
const stamp = existsSync(path.join(pluginRoot, PLUGIN_BUILD_STAMP))
|
|
? readFileSync(path.join(pluginRoot, PLUGIN_BUILD_STAMP), "utf8").trim() : null;
|
|
const reason = needsInstall ? "dependencies missing" : !outputs ? "build output missing"
|
|
: stamp === null ? "build was not stamped with its inputs" : stamp !== inputHash ? "plugin sources changed since the last build" : "up to date";
|
|
return { needsInstall, needsBuild: reason !== "up to date", inputHash, reason };
|
|
}
|