mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Remote (sandbox) workspaces are restored from a durable snapshot before a later turn, so restore time delays that turn's first token > - #20280 added run-log timing for the storage steps of a save and a restore > - A Daytona wave with a Paperclip-sized workspace (~750 MB) spent 39.9 s preparing the workspace of a restored run. The storage steps (download, decrypt, verify, publish) took 2.4 s, and `workspace.snapshot.sandbox_restore` took 35.6 s > - The archive helper unpacks the same archive locally in 3.6 s, so most of that time is elsewhere, probably copying 750 MB into the sandbox. One span cannot say which > - This pull request splits that span into the upload into the sandbox and the extraction in it > - The benefit is that the next measurement shows which part to fix ## Linked Issues or Issue Description Refs #20280 **What happened?** A restored run on Daytona spent 35.6 s in `workspace.snapshot.sandbox_restore` (wave `w-20261109T170632-s9921`, R1 workload, 754,838,327-byte snapshot). The run log records only that total, so it cannot show whether the copy into the sandbox or the unpack inside it is slow. **Steps to reproduce** 1. Run a remote workspace task with a large repository on Daytona and let a later run restore it. 2. Read the restored run's `run.performance.span` events. 3. Only `workspace.snapshot.sandbox_restore` covers the time after the storage download. **Expected behavior** The run log separates the upload into the sandbox from the extraction there. ## What Changed - `remote-workspace-transport.ts` (`restoreRemoteWorkspaceDirectory`): the archive copy into the sandbox runs inside `workspace.snapshot.sandbox_upload`, and the in-sandbox restore command inside `workspace.snapshot.sandbox_extract`. Both are children of the existing `workspace.snapshot.sandbox_restore`. - `execution-preparation-timing.ts`: the two new phase names. A traced `measureRunPhase` scope also carries its preparation ancestor's run (`recordRunId`). Without it, with an OTLP endpoint set, a step nested inside another step (here, upload and extract inside `sandbox_restore`) never reached the run log. Execution attempts still record nothing. - `doc/run-log-events.md`: the two rows in the durable storage timing table. - No behavior change. This is a run-log change: the rows stay in the instance database. ## Verification - New test `times the restore's sandbox upload and extraction as separate run-log steps` (`remote-workspace-transport.test.ts`). It restores a real archive through the real helper, inside the production `sandbox_restore` step with an OTLP endpoint set, with a 60 ms pause in each step, then reads the drained run phases. It fails without the spans or without the scope fix. - New test `records a step nested inside another step when an OTLP endpoint is set` (`execution-preparation-phases.test.ts`). It fails without the scope fix. - `npx vitest run` on `execution-preparation-phases`, `execution-preparation-timing`, `execution-timing-context`, `database-operation-attribution`, `native-workspace-finalization-work`, `remote-workspace-transport` and `remote-workspace-snapshot-storage`: 256 passed. ## Risks - Low risk. Two more measured steps on the restore path; spans under 50 ms are not recorded. ## Model Used Claude Opus 5.5 (`claude-opus-5-5`, Anthropic), 1M context window, tool use and code execution, via Claude Code. ## 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
78 lines
3.9 KiB
TypeScript
78 lines
3.9 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { createRunPreparationTiming, drainRunPreparationPhases, measureRunPhase, withRunExecutionTiming } from "../services/execution-preparation-timing.js";
|
|
|
|
describe("run preparation phases for the run log", () => {
|
|
afterEach(() => { vi.unstubAllEnvs(); });
|
|
it("records slow named phases per run, marks failures, and forgets them once drained", async () => {
|
|
const runId = randomUUID();
|
|
const time = createRunPreparationTiming(runId);
|
|
await time("workspace_provision", () => delay(70));
|
|
await time("workspace_binding", async () => undefined);
|
|
await expect(time("runner_binary", async () => { await delay(60); throw new Error("upload failed"); })).rejects.toThrow("upload failed");
|
|
await Promise.resolve();
|
|
const phases = drainRunPreparationPhases(runId);
|
|
expect(phases.map(phase => [phase.name, phase.outcome])).toEqual([
|
|
["heartbeat.prepare.workspace_provision", "ok"],
|
|
["heartbeat.prepare.runner_binary", "failed"],
|
|
]);
|
|
expect(phases[0]!.endedAtMs - phases[0]!.startedAtMs).toBeGreaterThanOrEqual(50);
|
|
expect(drainRunPreparationPhases(runId)).toEqual([]);
|
|
});
|
|
|
|
it("records nested run phases inside a preparation phase without a tracer", async () => {
|
|
const runId = randomUUID();
|
|
await createRunPreparationTiming(runId)("runtime_selection", async () => {
|
|
await measureRunPhase("runtime_selection.provider_discovery", () => delay(60));
|
|
await measureRunPhase("runtime_selection.skill_assets", async () => undefined);
|
|
});
|
|
expect(drainRunPreparationPhases(runId).map(phase => phase.name)).toEqual([
|
|
"runtime_selection.provider_discovery", "heartbeat.prepare.runtime_selection",
|
|
]);
|
|
});
|
|
|
|
it("never records an execution attempt, so a resumed run's prepare holds only its own preparation", async () => {
|
|
const runId = randomUUID();
|
|
await createRunPreparationTiming(runId)("workspace", () => delay(60));
|
|
expect(drainRunPreparationPhases(runId)).toHaveLength(1);
|
|
await withRunExecutionTiming(runId, async () => {
|
|
await delay(60);
|
|
await measureRunPhase("workspace_sync.transfer", () => delay(60));
|
|
});
|
|
expect(drainRunPreparationPhases(runId)).toEqual([]);
|
|
});
|
|
|
|
it("records nested preparation steps when an OTLP endpoint is set too", async () => {
|
|
vi.stubEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:9");
|
|
const runId = randomUUID();
|
|
await createRunPreparationTiming(runId)("runtime_selection", async () => {
|
|
await measureRunPhase("runtime_selection.workspace_sync", () => delay(60));
|
|
});
|
|
expect(drainRunPreparationPhases(runId).map(phase => phase.name)).toEqual([
|
|
"runtime_selection.workspace_sync", "heartbeat.prepare.runtime_selection",
|
|
]);
|
|
});
|
|
|
|
it("records a step nested inside another step when an OTLP endpoint is set", async () => {
|
|
vi.stubEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:9");
|
|
const runId = randomUUID();
|
|
await createRunPreparationTiming(runId)("workspace", () =>
|
|
measureRunPhase("workspace.snapshot.sandbox_restore", async () => {
|
|
await measureRunPhase("workspace.snapshot.sandbox_upload", () => delay(60));
|
|
await measureRunPhase("workspace.snapshot.sandbox_extract", () => delay(60));
|
|
}));
|
|
expect(drainRunPreparationPhases(runId).map(phase => phase.name)).toEqual([
|
|
"workspace.snapshot.sandbox_upload", "workspace.snapshot.sandbox_extract",
|
|
"workspace.snapshot.sandbox_restore", "heartbeat.prepare.workspace",
|
|
]);
|
|
});
|
|
|
|
it("keeps runs apart", async () => {
|
|
const [first, second] = [randomUUID(), randomUUID()];
|
|
await createRunPreparationTiming(first)("instructions", () => delay(60));
|
|
expect(drainRunPreparationPhases(second)).toEqual([]);
|
|
expect(drainRunPreparationPhases(first)).toHaveLength(1);
|
|
});
|
|
});
|