fix(daytona): force a stuck stop before starting a retained sandbox (#20609)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - On Daytona, a follow-up task resumes the agent's retained sandbox
before the agent starts
> - The host stops an idle sandbox gracefully. A process that ignores
SIGTERM can leave Daytona in `stopping` for many minutes
> - Resume waited for that state change until its 300 s start deadline.
It never escalated, so each attempt failed after five minutes, and the
bounded retries did the same
> - On staging, a user comment waited about 21 minutes before the agent
started. Two resume attempts each used 299.7 s in the plugin dispatch
> - This pull request forces a stuck stop once, then starts the sandbox
within the same deadline. It also traces the wait with the observed
state
> - The benefit is that a stuck stop costs about 30 s instead of failing
the run, and the next case is diagnosable from the trace

## Linked Issues or Issue Description

**What happened?**
A user commented on a task that used a retained Daytona sandbox. The
first run and its first retry each failed after about 5 min 20 s with
`reusable_sandbox_resume_failed (cause=provider_error)`. The Honeycomb
trace shows `execution.plugin_dispatch` at 299.7 s in both. Inside it
there is only `lease.lookup` (44 ms) and `lease.channel_cleanup`, and no
`lease.start`. So the whole budget went to `waitOutSandboxTransition`,
which polled a sandbox that stayed in a transition state. The later
attempt, after a control-plane restart, found the sandbox `stopped`. Its
`lease.start` took 1.3 s. A transition that ends in `stopped` is a stop.

**Expected behavior**
A sandbox that stays `stopping` for longer than the host's own
graceful-stop budget is forced to stop, then starts. The run does not
fail on the start deadline.

**Steps to reproduce**
1. Retain a Daytona sandbox whose graceful stop does not finish (Daytona
reports `stopping`).
2. Resume or realize the lease.
3. Before this change, the call polls until the 300 s deadline and fails
with "is still stopping at its start deadline".

## What Changed

- `packages/plugins/sandbox-providers/daytona/src/plugin.ts`:
`waitOutSandboxTransition` tracks how long the sandbox stays `stopping`.
After `STUCK_STOPPING_FORCE_AFTER_MS` (30 s) it calls
`sandbox.stop(seconds, true)` once, then keeps polling. A refused force
stop keeps the old wait within the same deadline.
- The wait is a `lease.wait_transition` span. The force stop is a
`lease.force_stop` child span.
- `lease-tracing.ts`: the closed `paperclip.sandbox.startup.state` label
also records `starting`, `stopping`, `restoring`, `archiving` and
`resizing`. Before, these were all `other`.
- `server/src/services/plugin-host-services.ts`: the host admits the two
new span names and the new closed state values.
- `doc/observability.md`: documents the new spans and states.

## Verification

- Red/green: with the plugin change stashed, the three new
stuck-`stopping` tests (realize, refused force stop, resume) fail by
hanging to the deadline. With the change they pass. The "settles on its
own" test confirms that no force stop happens for a normal stop.
- `vitest run` in `packages/plugins/sandbox-providers/daytona`: 14
files, 618 tests passed (8 skipped). `tsc --noEmit` passed.
- `server/src/__tests__/plugin-host-services-span.test.ts`: red before
the allowlist change, 25/25 after.
- `pnpm verify` exited 2 (the doc is a global CI input), so CI supplies
the broad coverage.
- Not verified on a live Daytona sandbox. Hosted acceptance needs the
change deployed to staging.

## Risks

- The force stop sends SIGKILL to processes in a sandbox that is already
stopping and is about to start. The disk persists across a stop, so the
retained workspace is kept. A graceful stop that is still running past
30 s is killed. The host's own graceful stop budget is 30 s, so the host
had already given up on it.
- Daytona can refuse a stop while a state change is in progress. Then
the code waits as before, so the result is no worse than today.
- The root cause is inferred from the trace (a transition that ends in
`stopped`). The new state label confirms or refutes it the next time it
happens.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use
(shell, file edits, browser, test execution).

## 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>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-10 19:20:30 +00:00
1 parent 9b21dcbdda
commit 42d4499489
7 files changed
+258 -15

No files matched your search

+11 -1
View File
@@ -1999,6 +1999,8 @@ execution.plugin_dispatch
├── sandbox.daytona.lease.lookup
├── sandbox.daytona.lease.refresh (missing-container confirmation)
├── sandbox.daytona.lease.channel_cleanup (before restart)
├── sandbox.daytona.lease.wait_transition (only while the sandbox is changing state)
│ └── sandbox.daytona.lease.force_stop (only when `stopping` outlasts 30 s)
├── sandbox.daytona.lease.start (or lease.recover)
└── sandbox.daytona.lease.verify
├── sandbox.daytona.lease.workspace
@@ -2011,8 +2013,16 @@ execution.plugin_dispatch
internal readiness wait. They also cover starts triggered by execution or sync.
They do not separate SDK-internal requests or provider-side queue time.
Their `paperclip.sandbox.startup.state` attribute records the state before the
call: `started`, `stopped`, `archived`, `error`, or `other`. Both the plugin and
call: `started`, `stopped`, `archived`, `error`, a transition (`starting`,
`stopping`, `restoring`, `archiving`, `resizing`), or `other`. Both the plugin and
host restrict this value; no provider error text or resource identity is sent.
`lease.wait_transition` covers polling a sandbox that is changing state before a
start; its state attribute is the state it found. A start that finds the sandbox
`stopping` for more than 30 s forces the stop (`lease.force_stop`) and keeps
waiting within the same start deadline if Daytona refuses. The forced stop belongs
to the sandbox: parallel callers send one, and none of them starts the sandbox
until that request settles. A confirmed start ends that stuck stop, so a later
one is forced again.
Fresh acquisition emits `lease.create`, `lease.workspace`, `lease.shell`,
`lease.expiry`, and `lease.sentinel`. `lease.create` covers the existing creation
@@ -7,6 +7,8 @@ export type LeaseSpanName =
| "lease.channel_cleanup"
| "lease.start"
| "lease.recover"
| "lease.wait_transition"
| "lease.force_stop"
| "lease.verify"
| "lease.workspace"
| "lease.sentinel"
@@ -15,12 +17,17 @@ export type LeaseSpanName =
| "lease.expiry"
| "lease.cleanup";
// A closed set: the settled states plus the transitions a start waits out.
const LABELLED_STATES: ReadonlySet<string> = new Set([
"started", "stopped", "archived", "error",
"starting", "stopping", "restoring", "archiving", "resizing",
]);
/** Keep provider state useful without exporting a new SDK value as free text. */
export function leaseStateAttributes(state: unknown): Record<string, string> {
return {
"paperclip.sandbox.startup.state":
state === "started" || state === "stopped" || state === "archived" || state === "error"
? state : "other",
typeof state === "string" && LABELLED_STATES.has(state) ? state : "other",
};
}
@@ -36,6 +36,7 @@ import plugin, {
__getDaytonaWritableDirsForTest,
__setDaytonaPluginContextForTest,
WARM_IDLE_AUTO_STOP_MARGIN_MINUTES,
STUCK_STOPPING_FORCE_AFTER_MS,
} from "./plugin.js";
import type { PluginContext } from "@paperclipai/plugin-sdk";
import { environmentSyncErrorData, readEnvironmentSyncErrorDiagnostic, withEnvironmentSyncErrorCapture, environmentCreationCleanupErrorData, readEnvironmentCreationCleanupError, readEnvironmentAcquisitionDiagnostic, PluginEnvironmentStopError, environmentStopErrorData, readEnvironmentStopFailure } from "@paperclipai/plugin-sdk";
@@ -330,6 +331,111 @@ describe("Daytona idle eligibility", () => {
expect(sandbox.start.mock.calls[0]![0]).toBeLessThanOrEqual(2);
});
it("does not force a stop that settles on its own", async () => {
const sandbox = createMockSandbox({ id: "sandbox-transition", state: "stopping" });
mockGet.mockResolvedValue(sandbox);
sandbox.refreshData.mockImplementation(async () => { sandbox.state = "stopped"; });
sandbox.start.mockImplementation(async () => { sandbox.state = "started"; });
await expect(realize()).resolves.toMatchObject({ cwd: "/home/daytona/paperclip-workspace" });
expect(sandbox.stop).not.toHaveBeenCalled();
});
it("force-stops a sandbox stuck stopping, then starts it", async () => {
// A graceful stop whose processes ignore SIGTERM can leave Daytona in
// `stopping` for many minutes. Only a forced stop completes it.
const sandbox = createMockSandbox({ id: "sandbox-transition", state: "stopping" });
mockGet.mockResolvedValue(sandbox);
sandbox.refreshData.mockImplementation(async () => {});
sandbox.stop.mockImplementation(async (_timeout?: number, force?: boolean) => {
if (!force) throw stateChangeInProgress();
sandbox.state = "stopped";
});
sandbox.start.mockImplementation(async () => { sandbox.state = "started"; });
vi.useFakeTimers();
try {
const realized = realize();
await vi.advanceTimersByTimeAsync(STUCK_STOPPING_FORCE_AFTER_MS + 5_000);
await expect(realized).resolves.toMatchObject({ cwd: "/home/daytona/paperclip-workspace" });
} finally {
vi.useRealTimers();
}
expect(sandbox.stop).toHaveBeenCalledTimes(1);
expect(sandbox.stop.mock.calls[0]![1]).toBe(true);
expect(sandbox.start).toHaveBeenCalledTimes(1);
});
it("sends one forced stop for parallel callers and starts nothing until it settles", async () => {
const sandbox = createMockSandbox({ id: "sandbox-transition", state: "stopping" });
mockGet.mockResolvedValue(sandbox);
sandbox.refreshData.mockImplementation(async () => {});
let finishStop!: () => void;
sandbox.stop.mockImplementation(() => new Promise<void>(resolve => {
finishStop = () => { sandbox.state = "stopped"; resolve(); };
}));
sandbox.start.mockImplementation(async () => { sandbox.state = "started"; });
vi.useFakeTimers();
try {
const first = realize();
const second = realize();
await vi.advanceTimersByTimeAsync(STUCK_STOPPING_FORCE_AFTER_MS + 5_000);
expect(sandbox.stop).toHaveBeenCalledTimes(1);
// The forced stop outlives its own liveness bound; no caller may start
// the sandbox while that request could still land.
await vi.advanceTimersByTimeAsync(60_000);
expect(sandbox.stop).toHaveBeenCalledTimes(1);
expect(sandbox.start).not.toHaveBeenCalled();
finishStop();
await vi.advanceTimersByTimeAsync(2_000);
await expect(first).resolves.toMatchObject({ cwd: "/home/daytona/paperclip-workspace" });
await expect(second).resolves.toMatchObject({ cwd: "/home/daytona/paperclip-workspace" });
} finally {
vi.useRealTimers();
}
expect(sandbox.stop).toHaveBeenCalledTimes(1);
expect(sandbox.start.mock.invocationCallOrder[0]!).toBeGreaterThan(sandbox.stop.mock.invocationCallOrder[0]!);
});
it("forces a new stuck stop after the sandbox restarted, inside the cooldown", async () => {
const sandbox = createMockSandbox({ id: "sandbox-transition", state: "stopping" });
mockGet.mockResolvedValue(sandbox);
sandbox.refreshData.mockImplementation(async () => {});
sandbox.stop.mockImplementation(async (_timeout?: number, force?: boolean) => {
if (force) sandbox.state = "stopped";
});
sandbox.start.mockImplementation(async () => { sandbox.state = "started"; });
vi.useFakeTimers();
try {
for (let cycle = 0; cycle < 2; cycle++) {
sandbox.state = "stopping"; // The next graceful stop is stuck again.
const realized = realize();
await vi.advanceTimersByTimeAsync(STUCK_STOPPING_FORCE_AFTER_MS + 5_000);
await expect(realized).resolves.toMatchObject({ cwd: "/home/daytona/paperclip-workspace" });
}
} finally {
vi.useRealTimers();
}
expect(sandbox.stop).toHaveBeenCalledTimes(2);
expect(sandbox.start).toHaveBeenCalledTimes(2);
});
it("keeps waiting to the deadline when the forced stop is refused", async () => {
const sandbox = createMockSandbox({ id: "sandbox-transition", state: "stopping" });
mockGet.mockResolvedValue(sandbox);
sandbox.refreshData.mockImplementation(async () => {});
sandbox.stop.mockRejectedValue(stateChangeInProgress());
vi.useFakeTimers();
try {
const realized = realize(STUCK_STOPPING_FORCE_AFTER_MS + 20_000);
const outcome = expect(realized).rejects.toThrow("is still stopping at its start deadline");
await vi.advanceTimersByTimeAsync(STUCK_STOPPING_FORCE_AFTER_MS + 40_000);
await outcome;
} finally {
vi.useRealTimers();
}
expect(sandbox.stop).toHaveBeenCalledTimes(1);
expect(sandbox.start).not.toHaveBeenCalled();
});
it("fails other start errors at once", async () => {
const sandbox = createMockSandbox({ id: "sandbox-transition", state: "stopped" });
mockGet.mockResolvedValue(sandbox);
@@ -2511,6 +2617,39 @@ describe("Daytona sandbox provider plugin", () => {
expect(sandbox.delete).not.toHaveBeenCalled();
});
it("resumes a retained sandbox stuck stopping by forcing the stop", async () => {
const sandbox = createMockSandbox({ id: "sandbox-retained", state: "stopping" });
const allocationRoot = "/home/daytona/paperclip-workspace";
const workspaceId = "6028a46b-b65a-421f-952d-66b9c002b8e2";
const remoteCwd = `${allocationRoot}/.paperclip-workspaces/${workspaceId}`;
const sentinelPath = `${allocationRoot}/.paperclip-runtime/reusable-sandbox-lease.json`;
sandbox.process.executeCommand.mockImplementation(async command => ({ exitCode: 0,
result: command.startsWith("cat ") ? JSON.stringify({ token: "sentinel-token" }) : "bash" }));
sandbox.refreshData.mockImplementation(async () => {});
sandbox.stop.mockImplementation(async (_timeout?: number, force?: boolean) => {
if (force) sandbox.state = "stopped";
});
sandbox.start.mockImplementation(async () => { sandbox.state = "started"; });
mockGet.mockResolvedValue(sandbox);
vi.useFakeTimers();
try {
const resuming = plugin.definition.onEnvironmentResumeLease!({
driverKey: "daytona", companyId: "company-1", environmentId: "env-1", providerLeaseId: sandbox.id,
config: { apiKey: "fixture-key", reuseLease: true }, requestedCwd: remoteCwd, leaseMetadata: { remoteCwd,
remoteWorkspaceId: workspaceId, sandboxId: sandbox.id, reuseLease: true, workspaceSentinel: { path: sentinelPath,
token: "sentinel-token", runId: "fixture-run", providerLeaseId: sandbox.id, result: "written" } },
});
await vi.advanceTimersByTimeAsync(STUCK_STOPPING_FORCE_AFTER_MS + 5_000);
await expect(resuming).resolves.toMatchObject({ providerLeaseId: sandbox.id });
} finally {
vi.useRealTimers();
}
expect(sandbox.stop).toHaveBeenCalledWith(expect.any(Number), true);
expect(sandbox.start).toHaveBeenCalledTimes(1);
expect(mockCreate).not.toHaveBeenCalled();
expect(sandbox.delete).not.toHaveBeenCalled();
});
it.each([null, 7, "", "relative/workspace", "/workspace/../other", "/workspace\0other"])(
"rejects a malformed recorded cwd before touching the provider: %j", async remoteCwd => {
await expect(plugin.definition.onEnvironmentResumeLease!({ driverKey: "daytona", companyId: "company-1",
@@ -646,27 +646,104 @@ function isSandboxStateChangeInProgress(error: unknown): boolean {
return /state change in progress/i.test(error instanceof Error ? error.message : String(error));
}
async function waitOutSandboxTransition(sandbox: Sandbox, deadline: number, readTimeoutMs: number): Promise<void> {
while (SANDBOX_TRANSITION_STATES.has(sandbox.state ?? "") && Date.now() + SANDBOX_TRANSITION_POLL_MS < deadline) {
await new Promise(resolve => setTimeout(resolve, SANDBOX_TRANSITION_POLL_MS));
await withLivenessTimeout("sandbox.refreshData", Math.min(readTimeoutMs, Math.max(1, deadline - Date.now())),
() => sandbox.refreshData());
/**
* How long a sandbox may stay `stopping` before a start forces the stop. The
* host stops an idle sandbox gracefully with a 30 s budget, so a stop still
* pending after this is stuck, typically on a process that ignores SIGTERM.
* Daytona can leave such a sandbox `stopping` for many minutes.
*/
export const STUCK_STOPPING_FORCE_AFTER_MS = 30_000;
const STUCK_STOPPING_FORCE_STOP_SECONDS = 30;
/**
* One forced stop per stuck stop, whichever caller sends it. A confirmed start
* ends the episode early (see {@link endForcedStopEpisode}).
*/
export const FORCED_STOP_COOLDOWN_MS = 10 * 60_000;
// Forced stops are owned by the sandbox, not by a caller. Several callers can
// wait on one stuck sandbox at once (execute, sync, realize, resume). Only one
// of them sends the forced stop, and no caller starts the sandbox while that
// request is unsettled: a late stop would otherwise kill a sandbox another
// caller has just restarted. The entry outlives its liveness timeout until
// the SDK request itself settles.
const forcedSandboxStops = new Map<string, { issuedAt: number; pending: Promise<void> | null }>();
function forcedStopInFlight(key: string): Promise<void> | null {
return forcedSandboxStops.get(key)?.pending ?? null;
}
// A confirmed start ends the stuck stop. A later stuck stop is a new episode
// and may be forced at once. An unsettled request still blocks every start.
function endForcedStopEpisode(key: string): void {
if (forcedSandboxStops.get(key)?.pending === null) forcedSandboxStops.delete(key);
}
function claimForcedStop(key: string, now: number): boolean {
const previous = forcedSandboxStops.get(key);
if (previous && (previous.pending || now - previous.issuedAt < FORCED_STOP_COOLDOWN_MS)) return false;
forcedSandboxStops.set(key, { issuedAt: now, pending: null });
return true;
}
async function sendForcedStop(sandbox: Sandbox, key: string, deadline: number): Promise<void> {
const remainingSeconds = Math.max(1, Math.floor((deadline - Date.now()) / 1_000));
const stopSeconds = Math.min(STUCK_STOPPING_FORCE_STOP_SECONDS, remainingSeconds);
const entry = forcedSandboxStops.get(key)!;
// `request` settles only when the SDK call does, even past the liveness bound.
const request = Promise.resolve().then(() => sandbox.stop(stopSeconds, true));
entry.pending = request.then(() => undefined, () => undefined)
.finally(() => { if (forcedSandboxStops.get(key) === entry) entry.pending = null; });
try {
await traceLeaseStep("lease.force_stop", () => withLivenessTimeout("sandbox.stop",
stopSeconds * 1_000 + LIVENESS_START_TIMEOUT_MARGIN_MS, () => request));
} catch {
// Keep waiting within the same deadline; the next read reports the state.
}
}
async function waitOutSandboxTransition(sandbox: Sandbox, scope: SandboxScope, deadline: number,
readTimeoutMs: number): Promise<void> {
const key = sandboxHandleCacheKey(scope);
const mustWait = () => SANDBOX_TRANSITION_STATES.has(sandbox.state ?? "") || forcedStopInFlight(key) !== null;
if (!mustWait()) return;
await traceLeaseStep("lease.wait_transition", async () => {
let stoppingSince: number | null = null;
while (mustWait() && Date.now() + SANDBOX_TRANSITION_POLL_MS < deadline) {
stoppingSince = sandbox.state === "stopping" ? stoppingSince ?? Date.now() : null;
// The sandbox is about to be started, so killing what holds the stop
// loses nothing a graceful stop would keep: the disk survives a stop.
// A refusal leaves the plain wait unchanged.
if (stoppingSince !== null && Date.now() - stoppingSince >= STUCK_STOPPING_FORCE_AFTER_MS
&& claimForcedStop(key, Date.now())) {
await sendForcedStop(sandbox, key, deadline);
continue;
}
const pending = forcedStopInFlight(key);
await Promise.race([
new Promise(resolve => setTimeout(resolve, SANDBOX_TRANSITION_POLL_MS)),
...(pending ? [pending] : []),
]);
await withLivenessTimeout("sandbox.refreshData", Math.min(readTimeoutMs, Math.max(1, deadline - Date.now())),
() => sandbox.refreshData());
}
}, leaseStateAttributes(sandbox.state));
}
async function ensureSandboxStarted(sandbox: Sandbox, timeoutSeconds: number, scope: SandboxScope): Promise<void> {
if (sandbox.state === "started") return;
sandboxHandleSessionStore.clear(scope);
const deadline = Date.now() + timeoutSeconds * 1_000;
const readTimeoutMs = Math.min(scope.config.livenessTimeoutMs, 30_000);
for (;;) {
await waitOutSandboxTransition(sandbox, deadline, readTimeoutMs);
if (SANDBOX_TRANSITION_STATES.has(sandbox.state ?? "") || Date.now() >= deadline) {
await waitOutSandboxTransition(sandbox, scope, deadline, readTimeoutMs);
if (SANDBOX_TRANSITION_STATES.has(sandbox.state ?? "") || forcedStopInFlight(sandboxHandleCacheKey(scope))
|| Date.now() >= deadline) {
throw new Error(`Daytona sandbox ${sandbox.id} is still ${sandbox.state ?? "changing state"} at its start deadline`);
}
const remainingSeconds = Math.max(1, Math.ceil((deadline - Date.now()) / 1_000));
try {
await startSettledSandbox(sandbox, remainingSeconds, scope);
endForcedStopEpisode(sandboxHandleCacheKey(scope));
return;
} catch (error) {
if (!isSandboxStateChangeInProgress(error) || Date.now() + SANDBOX_TRANSITION_POLL_MS >= deadline) throw error;
@@ -1858,6 +1935,7 @@ export function __resetDaytonaSandboxHandleCacheForTest(): void {
sandboxHandleActivityGates.reset();
sandboxHandleLeaseAdmissionStates.reset();
sandboxHandleWritableDirs.reset();
forcedSandboxStops.clear();
sandboxHandleSessionStore.reset();
runnerIngressGenerationStore.reset();
retiredDaytonaDuplexChannelByRoute.clear();
@@ -349,6 +349,9 @@ describe("nested worker timing receipts",()=>{
startTimeMs: now - 80, endTimeMs: now - 20, attributes: { "paperclip.sandbox.startup.state": "archived" } });
expect(JSON.stringify(mockRecordSpan.mock.calls)).not.toContain("private-token");
expect(clampProviderSpanAttributes({ "paperclip.sandbox.startup.state": "private-state" })).toEqual({});
for (const state of ["starting", "stopping", "restoring", "archiving", "resizing"]) {
expect(clampProviderSpanAttributes({ "paperclip.sandbox.startup.state": state })).toEqual({ "paperclip.sandbox.startup.state": state });
}
});
it("admits a workspace failure span with only a closed failure reason", () => {
@@ -366,7 +369,7 @@ describe("nested worker timing receipts",()=>{
it("admits every closed lease phase and rejects dynamic phase names", () => {
mockRecordSpan.mockReset();
const names = ["lease.wait_for_teardown", "lease.lookup", "lease.refresh", "lease.channel_cleanup",
"lease.start", "lease.recover", "lease.verify", "lease.workspace", "lease.sentinel", "lease.shell",
"lease.start", "lease.recover", "lease.wait_transition", "lease.force_stop", "lease.verify", "lease.workspace", "lease.sentinel", "lease.shell",
"lease.create", "lease.expiry", "lease.cleanup", "lease.private-resource-id"];
for (const name of names) recordWorkerProviderSpan({ name }, { traceparent: VALID_TRACEPARENT });
expect(mockRecordSpan.mock.calls.map(call => call[0].name)).toEqual([
+8 -2
View File
@@ -541,7 +541,7 @@ const KNOWN_PROVIDER_SPAN_NAMES: ReadonlySet<string> = new Set([
"session.open",
"session.close",
"lease.wait_for_teardown", "lease.lookup", "lease.refresh", "lease.channel_cleanup",
"lease.start", "lease.recover", "lease.verify", "lease.workspace", "lease.sentinel",
"lease.start", "lease.recover", "lease.wait_transition", "lease.force_stop", "lease.verify", "lease.workspace", "lease.sentinel",
"lease.shell", "lease.create", "lease.expiry", "lease.cleanup",
"plugin.worker", "plugin.host.identity.read", "plugin.host.config.read", "plugin.host.progress.read", "plugin.host.progress.write",
"plugin.workspace.prepare", "plugin.workspace.failure", "plugin.storage.queue", "plugin.storage.execute",
@@ -591,6 +591,12 @@ const KNOWN_SPAN_OUTCOMES: ReadonlySet<string> = new Set(["ok", "skipped", "fail
/** The closed value set for the `transfer.direction` attribute. */
const KNOWN_TRANSFER_DIRECTIONS: ReadonlySet<string> = new Set(["inbound", "outbound"]);
// Settled states plus the transitions a start waits out; anything else is dropped.
const PROVIDER_STARTUP_STATES: ReadonlySet<string> = new Set([
"started", "stopped", "archived", "error", "other",
"starting", "stopping", "restoring", "archiving", "resizing",
]);
/**
* Re-clamp the worker-sent attributes at the trust boundary. Drop every key that
* is not on the allowlist. Re-map `provider` through `normalizeProviderFamily`,
@@ -606,7 +612,7 @@ export function clampProviderSpanAttributes(
for (const [key, value] of Object.entries(raw)) {
if (!PROVIDER_SPAN_ATTR_ALLOWLIST.has(key)) continue;
if (key === SPAN_ATTRS.state) {
if (typeof value === "string" && ["started", "stopped", "archived", "error", "other"].includes(value)) clamped[key] = value;
if (typeof value === "string" && PROVIDER_STARTUP_STATES.has(value)) clamped[key] = value;
continue;
}
if (key === "plugin.operation") {
@@ -528,12 +528,12 @@
"packages/shared/src/worktree-seed-source.test.ts": "852856b1b5b8f4d0d3dada6501886fe50a2c2dde",
"packages/shared/src/worktree-seed-source.ts": "3d1a09bed2d969feabef3400ff84fd499c1408f4",
"packages/shared/tsconfig.json": "5a24989cd30dcf3749ca7e60d4e83d6ddbb14714",
"pnpm-lock.yaml": "851e65af7af027e3db99e759ece3d89e4a4700c2",
"pnpm-lock.yaml": "6d825f9785f931f3bb08b0a6d2a741781f28f58a",
"scripts/ensure-plugin-build-deps.mjs": "356e0e0f80e50890067027da3003485cf5d2ccbe",
"tsconfig.base.json": "2d0e41f723add0c678936eb9ae1c60eab710f8e0",
"tsconfig.json": "994ee490fb97be7cc0954f4dfd88cf0723290e6c"
},
"lockSha256": "2360d5e93876ce3cb6e810257d92d669ac2b109027b0e01af36f2048861648c0",
"lockSha256": "b32083066ce89ba9a570a2927b3c4c8385294a5f04efcbae462db82b2e8c4455",
"compilerPackageSha256": "3722b30210616a13a3213ded11575ba6b2dbab10c32a5ef67afca8513e27017e",
"parserPackageSha256": "6d6a65b879cd7cd249a4367c0b57cce00e78c728de45056ac2a5ca1d1f528d1a",
"parserEntrySha256": "6969920ae0610df927b6b3e675d1309372c268e36d391652af8e3e0183cbe8f8",