refactor(runner): extract the sandbox and workspace lifecycle units - #5752
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughSummary by CodeRabbit
WalkthroughThe runner extracts sandbox acquisition and teardown, workspace management, and acquire-stage timing into shared environment modules. The environment composer delegates lifecycle and workspace operations to these modules. Unit tests cover the new seams and delegation boundaries. ChangesSandbox environment lifecycle
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Environment
participant SandboxLifecycle
participant DaytonaSandbox
Environment->>SandboxLifecycle: Acquire stored or new sandbox
SandboxLifecycle->>DaytonaSandbox: Reconnect by sandbox ID
DaytonaSandbox-->>SandboxLifecycle: Handle or reconnect failure
SandboxLifecycle-->>Environment: Handle, mode, and resumability
Environment->>SandboxLifecycle: Teardown sandbox
SandboxLifecycle->>DaytonaSandbox: Park, delete, and dispose
SandboxLifecycle-->>Environment: Parked status
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| * rides the `fields` suffix rather than the stage name, so a dashboard grouping by stage sees one | ||
| * series with a mode dimension. | ||
| */ | ||
| export const ACQUIRE_STAGES = [ |
There was a problem hiding this comment.
These stage names are a public interface. Dashboards and log queries match on [timing] stage=<name>.
The split must not rename, drop, or reorder a single one. That is why the names live in this array instead of at each call site, and why a seam test asserts that the whole set still fires after the move. The test is the evidence that the extraction preserved behavior at the observable boundary.
Adding a stage is safe. Renaming one breaks something outside this repository.
sandbox_start and create_session appear once each although both have two modes. The mode rides a mode=... suffix, so a dashboard grouping by stage keeps one series with a mode dimension.
| * Byte-for-byte the inline behavior, including the swallowed reconnect failure and the extra log | ||
| * line for a confirmed terminal Daytona state. | ||
| */ | ||
| export async function acquire( |
There was a problem hiding this comment.
This function is a code move. The reconnect ladder, the fresh create fallback, and the extra log line for a terminal Daytona state all behave exactly as they did inline.
The swallowed reconnect failure is deliberate and predates this PR. A stored id that will not reconnect degrades to a fresh create. A dead sandbox is an ordinary outcome, not an error, and the only cost is one round trip. If that catch stopped swallowing, a stale pointer would fail a turn that used to succeed.
The helper takes an explicit input object rather than reading shared state. That is what lets the unit be tested without an environment.
| * teardown step (the workspace cleanup). It is the first unit to split out because it is the one | ||
| * step 6 needs: an in-place workspace refresh is the cheapest live route in the whole design. | ||
| * | ||
| * WHAT "MANAGED" MEANS. The runner owns `AGENTS.md` / `CLAUDE.md`, the rendered harness files, and |
There was a problem hiding this comment.
This unit records what the runner wrote into the run directory. That record is new here, and the next lane depends on it.
The runner owns AGENTS.md or CLAUDE.md, the rendered harness files, and the skill directories. It owns nothing else. An agent's own working files belong to the user, and a refresh must never touch them.
That boundary is the reason refresh takes an explicit manifest instead of reconciling the whole tree, and the reason deletions are computed from this inventory instead of from a directory listing.
refresh is deliberately unwired in this PR. It exists now, sharing its write path with materialize, so the next lane is a routing change rather than a new implementation.
| /** | ||
| * Seam tests for the environment lifecycle units (lifecycle migration, step 5). | ||
| * | ||
| * Same style as the S6 coordinator proof: assert on the SEAM, not on behavior the existing suites |
There was a problem hiding this comment.
These tests assert the seam, not the behavior. That is the right division for this PR.
The behavior is already covered. The keep-alive suites and the characterization suite from the lane below exercise the same acquire and teardown paths through the engine, and they pass unedited across this move. Re-asserting that behavior here would duplicate them without adding evidence.
What these tests add is the boundary itself. Each unit gets its public surface pinned, and the stage list gets its own guard. Those are the things a later refactor could break silently, because no product test would notice a renamed stage.
85fcb4b to
33255a0
Compare
7678208 to
8e17fe0
Compare
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (3)
services/runner/src/environment/timing.ts (1)
21-25: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider typing
stageasAcquireStageinstead ofstring.
ACQUIRE_STAGESandAcquireStagedeclare the public set, butTimingLogaccepts anystring. A renamed or newly added stage therefore compiles cleanly, and the only guard is the source-regex assertion intests/unit/environment-units.test.ts. That guard matchestimingLog("<literal>"only, so a computed stage argument escapes it.If you type the parameter,
tsc --strictenforces the same contract at compile time and the regex guard becomes a backstop rather than the primary control.ACQUIRE_STAGESis declared afterTimingLogtoday, so move the array and theAcquireStagealias above the type.♻️ Proposed change
export type TimingLog = ( - stage: string, + stage: AcquireStage, startedAt: number, fields?: string, ) => void;services/runner/src/environment/sandbox-lifecycle.ts (1)
42-53: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winThe seam erases the sandbox handle type.
SandboxAcquireDeps.startSandboxAgentreturnsPromise<unknown>andSandboxAcquireResult.sandboxisunknown. The composer therefore casts on both sides:startSandboxAgent as unknown as (options: Record<string, unknown>) => Promise<unknown>atservices/runner/src/engines/sandbox_agent/environment.tslines 673-675, and it assigns theunknownresult toenvironment.sandboxat line 681.Before the extraction,
tsc --strictchecked that the composer'sstartSandboxAgentmatchedSandboxAgent.startand that the returned handle carriedcreateSession. After the extraction it checks neither. The runtimeassertatenvironment.tslines 903-907 is now the only check that the handle is usable.Make the unit generic in the handle type so the composer keeps its inference and drops both casts.
♻️ Proposed change
-export interface SandboxAcquireDeps { - startSandboxAgent: (options: Record<string, unknown>) => Promise<unknown>; +export interface SandboxAcquireDeps<TSandbox> { + startSandboxAgent: (options: Record<string, unknown>) => Promise<TSandbox>; readStoredSandboxPointer?: typeof readStoredSandboxPointer; } -export interface SandboxAcquireResult { - sandbox: unknown; +export interface SandboxAcquireResult<TSandbox> { + sandbox: TSandbox | undefined; /** True when this sandbox may be parked and reconnected on a later turn. */ resumable: boolean; /** Which path produced the handle. Reported for the composer's logs and for tests. */ mode: "reconnect" | "create"; }
acquirethen becomesacquire<TSandbox>(input: SandboxAcquireInput, deps: SandboxAcquireDeps<TSandbox>): Promise<SandboxAcquireResult<TSandbox>>, with the locallet sandbox: TSandbox | undefined.services/runner/src/environment/workspace-manager.ts (1)
112-114: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winA failed workspace cleanup is now silent.
cleanupdiscards the rejection and takes no logger. The composer calls it atservices/runner/src/engines/sandbox_agent/environment.tsline 358, insideenvironment.destroy. Every other teardown failure on that path is logged: the agent mountpoint failure at lines 347-349, and the skipped cleanup at lines 354-356. A failed workspace cleanup leaves a run directory on disk with no log line, so the leak is undetectable.Swallowing is correct, because
destroymust never throw. Add an optional log sink so the failure is still recorded.♻️ Proposed change
-export async function cleanup(workspace: Workspace | undefined): Promise<void> { - await workspace?.cleanup().catch(() => {}); +export async function cleanup( + workspace: Workspace | undefined, + log?: Log, +): Promise<void> { + await workspace?.cleanup().catch((err: unknown) => { + log?.(`workspace cleanup failed: ${(err as Error)?.message ?? err}`); + }); }At
environment.tsline 358, pass the logger:- await cleanupWorkspace(environment.workspace); + await cleanupWorkspace(environment.workspace, logger);
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 70daaa06-555f-4ab3-b767-d2f51a7cf346
📒 Files selected for processing (6)
services/runner/src/engines/sandbox_agent/environment-setup.tsservices/runner/src/engines/sandbox_agent/environment.tsservices/runner/src/environment/sandbox-lifecycle.tsservices/runner/src/environment/timing.tsservices/runner/src/environment/workspace-manager.tsservices/runner/tests/unit/environment-units.test.ts
33255a0 to
9885e95
Compare
8e17fe0 to
76d0c7b
Compare
9885e95 to
656f441
Compare
76d0c7b to
3a97d2d
Compare
|
@coderabbitai review |
✅ Action performedReview finished.
|
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
656f441 to
babd761
Compare
3a97d2d to
6b9c557
Compare
babd761 to
34d50fc
Compare
6b9c557 to
6fec70f
Compare
e47e102 to
af15bdb
Compare
2e72cd4 to
3a93002
Compare
af15bdb to
89c8a33
Compare
3a93002 to
50d1b2c
Compare
89c8a33 to
d14bbb7
Compare
50d1b2c to
90a61ea
Compare
d14bbb7 to
63f9445
Compare
90a61ea to
97ba90e
Compare
63f9445 to
6ffd18f
Compare
97ba90e to
7c79eef
Compare
6ffd18f to
b377d22
Compare
7c79eef to
0a97166
Compare
b377d22 to
13ce529
Compare
0a97166 to
616ad42
Compare
13ce529 to
98883bc
Compare
… by design) timing, workspace-manager (refresh declared, deliberately unwired), and sandbox-lifecycle move out of environment.ts with zero behavior change; 15 seam tests including a two-way stage-name guard so dashboards cannot silently lose a timing stage. The mount/runtime/harness-session cluster stays put on purpose: it shares one mutable closure over re-signed credentials, and its split starts with an AcquireContext type review (S7b) rather than code motion.
616ad42 to
06ddcbb
Compare
98883bc to
6b97afe
Compare
Context
Part of the agent-config-editing stack. Targets
agent-config-editing-s1b. Read the stack bottom up.Expect no behavior change. This is a code move: the reconnect ladder, the fresh-create fallback, the park-versus-delete decision, the in-flight registry, and the workspace write all behave exactly as they did inline. The characterization suite from s5 and the keep-alive suites pin that across the move.
acquireEnvironmentwas one long function that owned every layer of an environment at once. Later lanes need to reconfigure individual layers of a running environment, and you cannot reconfigure a layer that has no boundary.This lane is partial by design. Three units come out cleanly. The remaining three are entangled and land in the next lane.
Changes
Three units now live under
src/environment/:sandbox-lifecycle.tsowns the provider instance: thesandbox_startstage and the sandbox half of teardown. Acquire either reconnects a parked sandbox or creates a fresh one, and both emitsandbox_startwith the mode in themode=...field, so a dashboard grouping by stage keeps one series with a mode dimension. The reconnect ladder still never fails a turn. A stored id that will not reconnect degrades to a fresh create, which is why that catch swallows.workspace-manager.tsowns writing the managed files and, new here, recording an inventory of what it wrote. A refresh cannot know what to delete without one, and listing the directory is not an answer because a durable cwd holds the user's own project.timing.tsholds the acquire-stage timing helper, which used to be a closure insideprepareEnvironmentSetup. Every unit emits a stage line and a unit cannot reach into another module's closure.The stage names are a public interface. Dashboards and log queries match on
[timing] stage=<name>, soACQUIRE_STAGESlists them and a seam test asserts the whole set still fires. Adding a stage is fine. Renaming one breaks something outside this repository.Tests / notes
environment-units.test.ts, plus the seam test over the stage list.