Repository navigation
Reconcile only expired sandboxes, bounded by enforced deadlines - #278
Conversation
reconcile_orphaned_sandboxes() destroyed every sandbox labelled spawned-by=workflow-runner, so with more than one runner replica a single restarting pod tore down other replicas' live sandboxes. OpenShellSpawner now stamps a cloud-agents/owner-instance label from the new RUNNER_INSTANCE_ID env var (Helm and Kind manifests set it to the pod name via the downward API), and reconcile only destroys sandboxes carrying this instance's owner label. Sandboxes owned by other instances, or legacy ones with no owner label, are left alone and counted in a log line. With RUNNER_INSTANCE_ID unset, reconcile destroys nothing and logs a warning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner-scoped reconcile left sandboxes from replaced pods behind forever, since a new pod never reuses the old pod name. OpenShellSpawner now stamps a cloud-agents/expires-at label (spawn time + SANDBOX_MAX_LIFETIME_SECONDS, default 86400), and reconcile also destroys any spawned-by=workflow-runner sandbox whose expiry has passed, regardless of owner. Sandboxes without the label, or with a malformed one, are left alone and logged. Startup fails if the lifetime is below 3900s (a CLI session's default 3600s plus the 300s sandbox ready wait). The runner identity now falls back from RUNNER_INSTANCE_ID to HOSTNAME. An invalid explicit RUNNER_INSTANCE_ID fails startup; an invalid HOSTNAME only disables own-instance cleanup with a warning, and expired-sandbox cleanup still runs. Reconcile now makes a single gateway listing via a new AgentSpawner.list_active_labels() and partitions by label client-side. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A single global SANDBOX_MAX_LIFETIME_SECONDS could not cover steps whose unbounded timeout_seconds exceeded it, so another replica's restart could reap a still-running long step's sandbox. Spawn now takes a per-sandbox lifetime_seconds and stamps expires-at = clock + max(lifetime, SANDBOX_MIN_LIFETIME_SECONDS) + SANDBOX_EXPIRY_SKEW_SECONDS (defaults 86400 and 600). Workflow steps pass max(step timeout, permissions timeout) plus the 300s gateway and 60s HTTP ready waits, the 180s activity heartbeat timeout and 120s destroy grace; CLI sessions pass max_session_seconds plus the monitor interval, ready waits and grace. Spawns without a lifetime get no expiry. The startup floor check is gone because safety no longer depends on the global value. Caller-supplied owner-instance/expires-at labels are always dropped, label keys and timing constants live in cloud_agents.spawner.lifetime and are used by the spawner, reconcile and the Temporal workflow, and the HOSTNAME identity fallback warns that runners sharing a host network must set RUNNER_INSTANCE_ID. Docs cover mixed-version rollouts, one-time legacy cleanup and bare-process dev runs; the Podman compose files set RUNNER_INSTANCE_ID. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The post-run GET /v1/agent/events reused the step-sized HTTP timeout after the Temporal heartbeat loop had stopped, so a sandbox could live about twice its step bound and outlive its expires-at label. Transcript collection in both runners now has its own 30s wall-clock bound (SANDBOX_TRANSCRIPT_TIMEOUT_SECONDS, added to the step lifetime), and the local engine's run POST enforces http_timeout as an overall deadline, since httpx's timeout is per read. CLI sessions claim a lifetime only while the launcher's timeout monitor is running. OpenShellSpawner rejects negative min_lifetime_seconds and expiry_skew_seconds, execution.py uses the shared default step timeout, and the legacy cleanup snippet in the docs pages through the listing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the local engine's overall POST deadline fired with a provider credential set, the secret-redaction handler re-raised RuntimeError(str(TimeoutError())), which is empty, so the step failed with a blank error. The timeout is now caught at the POST and re-raised as "Agent run timed out after <http_timeout>s", which passes through redaction unchanged. It also increments ls_sandbox_timeout_total and emits a sandbox_timeout audit event with reason "timeout", the local counterpart of the Temporal path's cancellation event. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A matching runner identity never proves a sandbox's previous owner is gone, so the owner-instance fast path could destroy live sandboxes. Startup reconcile now destroys only sandboxes past their expires-at label, and the identity mechanism is removed: RUNNER_INSTANCE_ID/HOSTNAME resolution, owner-instance stamping, and the Helm/Kind/Podman wiring. Caller-supplied expires-at and leftover owner-instance labels are still stripped. Reconcile deletes exactly the expired gateway ref: the new list_sandbox_labels()/destroy_sandbox() carry the unique sandbox name through to deletion, instead of destroy(agent_name), which re-expanded by agent-name label and could delete a live or legacy ref sharing it. The expiry is now the sum of enforced wall-clock deadlines, measured from when it is stamped: a single 420s spawn budget (CreateSandbox 30s RPC timeout + 300s gateway ready + 60s HTTP readiness + 30s post-create) that starts at the stamp and covers create and every post-create phase, the run POST (now also enforced locally in the Temporal activity), the 30s transcript fetch and a new 30s transcript-save bound, plus 120s destroy grace. The stamp is rounded up rather than down. On a spawn timeout only a sandbox whose name CreateSandbox returned is deleted. CLI sessions have no enforced bound and get no expiry. Docs state the assumptions (enforced deadlines plus a clock-skew margin), that old runners must be drained before new ones admit work, and give a dry-run legacy cleanup that skips CLI sessions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Cancellation during spawn now cleans up like any other failure: _do_spawn deletes the exact created sandbox and base spawn() restores the active count on BaseException, then re-raises. - The Temporal activity heartbeats from before spawn() until the activity ends, so a long spawn can't trip the 180s heartbeat timeout; the task is cancelled in the outer finally on every path. - AgentSpawner._do_destroy_sandbox() raises NotImplementedError instead of falling back to agent-name destroy(), which could delete more than the selected sandbox; reconcile skips and logs such sandboxes. - OpenShellSpawner pages ListSandboxes with offset instead of a single limit=1000 call. The progress-streaming tests' POST mock now yields once, as a real agent run does; they had relied on an incidental await in the old heartbeat placement for the progress task to start. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The run deadline sat inside the HTTP client context, so a client close that stalled, or a progress task slow to cancel, kept the step using the sandbox past its expiry. Nesting asyncio.timeout can't fix that: a timeout fires once, so cleanup that runs after it (a close following a POST that timed out) gets no bound of its own. The new await_within()/cancel_within() helpers in spawner/lifetime.py run the work as a task, wait for it up to its deadline, then cancel it and wait at most a 5s cancel grace. Work still running after that is abandoned: logged, never awaited again, and the step fails or moves on without touching the sandbox from that phase. Both engines run the run call (POST plus client close), the transcript fetch (GET plus client close) and the transcript save through await_within(), and cancel the progress stream (and the Temporal heartbeat) with cancel_within(). A timed-out run therefore never goes on to fetch the transcript. The step lifetime is now 420 + run timeout + 30 + 30 + 4 x 5 + 120 seconds. Docs describe bounded cooperative I/O versus abandoned uncooperative work, and that Python can't preempt arbitrary code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The spawn budget used asyncio.timeout_at, which fires once, so cleanup that runs after the deadline (e.g. the readiness check's HTTP client close) had no bound of its own, and the best-effort delete after a failed spawn could block the caller indefinitely. CreateSandbox and all post-create work now run as a nested coroutine through await_within(), with the rest of the budget that started at the expires-at stamp, so work still running after the deadline is cancelled and, past the cancel grace, abandoned and logged. The failure-path sandbox cleanup and provider delete are each bounded by SANDBOX_SPAWN_CLEANUP_TIMEOUT_SECONDS (30s); that's not part of the lifetime, because a sandbox whose spawn failed is no longer used. The step lifetime is now 420 + run timeout + 30 + 30 + 5 x 5 + 120 seconds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- cancel_within() attaches its consume callback right after cancel(), so
if the caller is cancelled again during the cancel grace the abandoned
task's eventual exception is still retrieved ("Task exception was never
retrieved" no longer leaks), and the abandonment is logged.
- await_within() raises SandboxDeadlineExceeded (a TimeoutError) for its
own deadline. The spawner reports "exceeded its deadline" only for that;
a spawn phase's own TimeoutError propagates unchanged.
- Progress-streaming tests no longer leak un-awaited AsyncMock coroutines
into later tests: AsyncMock spawners get a sync get_query_ssl_context,
and the shared HTTP mock answers the transcript GET. With those changes
test_progress_streaming.py and test_bounded_io.py pass together under
-W error in either order.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#277 MCP secret delivery) Conflicts were confined to import blocks and one test pair, all resolved by keeping both sides: - openshell_spawner.py, step_runner.py, activities.py: keep upstream's AGENT_EVENT_LOG_PATH import from spawner.base alongside this branch's spawner.lifetime imports. - tests/unit/workflow/test_step_runner.py: keep both new tests, test_run_step_passes_per_step_lifetime and test_run_step_sets_shared_agent_event_log. lightspeed-core#277's post-create work in _do_spawn (the _inject_mcp_secrets call and the server env filtered after merging extra_env) merged cleanly into _create_and_start, so MCP secret writes now run inside the spawn budget that starts at the expires-at stamp, and a failed or stalled write goes through the bounded BaseException-safe cleanup that deletes the exact created sandbox. The mcp_secret_keys filter on spec.environment stays before CreateSandbox. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since the merge of lightspeed-core#277, MCP secret files are written within _create_and_start, under the spawn budget. Regressions using the real OpenShellSpawner against a fake gateway: - a failing secret write deletes exactly the created sandbox and restores the active count; - a hanging secret write is cut off by the spawn budget, raising the deadline error, and the sandbox is deleted; - the MCP secret key stays out of spec.environment while other env passes through (read from either the real protobuf map or the session-wide openshell stub that test_openshell_spawner.py may install). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
beesarmy
left a comment
There was a problem hiding this comment.
Reviewed merge-result head beb5ea9 against base a432a65. No blocking findings remain in the reviewed scope.
The source retains expired-only reconciliation and exact gateway-name deletion, without owner-identity-based deletion. Creation and post-create operations share a stamp-origin spawn budget; run, client closure, progress cleanup, transcript fetch/save and cancellation grace are covered by the reviewed lifetime policy. Timed-out parent flows do not proceed to transcript access. Unbounded lifecycles such as CLI sessions and legacy sandboxes remain without automatic expiry.
Inspected the semantic merge with the exec-output and dormant secret-helper changes. SDK output handling and the shared event path remain intact. Secret-file writes stay within the bounded spawn phase, their failures enter exact-target cleanup, and environment filtering remains in place. Added real-spawner/fake-gateway regressions cover failing and hanging secret writes and environment omission. The shared spawn budget may reject slow initialization; it is a fail-closed budget, not a performance guarantee for every number of mounts.
This was read-only code/test inspection; I did not independently rerun the reported local suite. Independently verified that build, lint, unit-tests, integration and e2e checks succeeded at this exact head. No live gateway, built sandbox image, real label acceptance or deployment timing was verified.
The deadlines bound caller waiting/control flow, not arbitrary background code or remote RPC termination. Cancellation-resistant work can be abandoned as documented. Preserve the rollout/rollback requirement to drain unsafe old runners before admitting new work. This review does not activate secret-bearing MCP or establish a general execution lease.
Summary
On startup the runner destroyed every OpenShell sandbox labelled
spawned-by=workflow-runner, including sandboxes that other runner replicas were still using. Against a fake gateway, the old reconcile destroyed this runner's, another runner's and an unlabelled sandbox alike.Startup reconcile now destroys only sandboxes whose expiry has passed, and it deletes each one by its exact gateway name.
How expiry works
cloud-agents/expires-atlabel: the stamp time (rounded up) +max(lifetime, SANDBOX_MIN_LIFETIME_SECONDS)+SANDBOX_EXPIRY_SKEW_SECONDS(defaults 86400 and 600; both are operational margins, not enforcement).lifetimeis the sum of enforced deadlines from the stamp, defined inspawner/lifetime.py: spawn 420s (create and all post-create work, one budget starting at the stamp) + the step's run timeout + transcript fetch 30s + transcript save 30s + a 5s cancellation grace for each of those five phases + 120s destroy grace. With default timeouts that is 1225s before the floor and skew.await_within/cancel_within: on its deadline the work is cancelled, given at most 5s to finish, then abandoned and logged. A timed-out run never goes on to fetch the transcript. This bounds how long the runner keeps using a sandbox; it does not stop a remote RPC that is already in flight or force-stop uncooperative code.cloud-agents/expires-atlabels are stripped, as is the retiredcloud-agents/owner-instancelabel.Other changes
AgentSpawner.list_sandbox_labels()anddestroy_sandbox(name); reconcile no longer callsdestroy(agent_name), which deleted every sandbox sharing an agent name. The basedestroy_sandboxraisesNotImplementedErrorinstead of falling back.sandbox_timeoutaudit event.Rollout
Older runners still destroy every
spawned-by=workflow-runnersandbox on startup. Stop or drain old runners before new runners accept work, and do the reverse on rollback. Sandboxes created before this change have no expiry label and are never reaped automatically;docs/sandbox-contract.mdhas a paged one-time cleanup snippet to run after old runners are drained.Testing
OpenShellSpawnerwith a fake gateway and an advancing clock, covering same-agent-name expired/live/legacy collisions, a slow create, a trickling health check, stalled client closes after both timed-out and successful requests, progress cleanup that ignores cancellation, and stuck failure cleanup, for both the local and Temporal engines.helm templaterenders.🤖 Generated with Claude Code