Files
PaperClipAI/server/src/__tests__/execution-preparation-phases.test.ts
T
DottaandClaude Opus 5.5 bce7503a82 perf(workspaces): time a restore's sandbox upload and extraction separately (#20331)
## 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>
2026-11-09 12:56:25 -06:00

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);
});
});