Skip to content

Reconcile only expired sandboxes, bounded by enforced deadlines - #278

Merged
jameswnl merged 12 commits into
lightspeed-core:mainfrom
jameswnl:fix-reconcile-scope
Nov 1, 2026
Merged

jameswnl merged 12 commits into
lightspeed-core:mainfrom
jameswnl:fix-reconcile-scope

Conversation

@jameswnl

@jameswnl jameswnl commented Nov 1, 2026

Copy link
Copy Markdown
Collaborator

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

  • Each spawned sandbox gets a cloud-agents/expires-at label: 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).
  • lifetime is the sum of enforced deadlines from the stamp, defined in spawner/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.
  • Each phase runs through 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.
  • Lifecycles without an enforced bound get no expiry and are never reaped by this mechanism. CLI sessions are in this group.
  • Caller-supplied cloud-agents/expires-at labels are stripped, as is the retired cloud-agents/owner-instance label.

Other changes

  • New AgentSpawner.list_sandbox_labels() and destroy_sandbox(name); reconcile no longer calls destroy(agent_name), which deleted every sandbox sharing an agent name. The base destroy_sandbox raises NotImplementedError instead of falling back.
  • Sandbox listing pages through all results.
  • A cancelled spawn deletes only the sandbox CreateSandbox returned and restores the concurrency count. If create never returned a name, nothing is deleted; a create that completes after our timeout keeps its expiry label and is reaped later.
  • The Temporal heartbeat starts before spawn.
  • Local-engine run timeouts raise a clear message and emit a sandbox_timeout audit event.

Rollout

Older runners still destroy every spawned-by=workflow-runner sandbox 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.md has a paged one-time cleanup snippet to run after old runners are drained.

Testing

  • Tests written first throughout. Deletion and deadline regressions use the real OpenShellSpawner with 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.
  • Unit suite: 2267 passed, 5 skipped. Example and DEPLOYMENT.md YAML tests: 50 passed, 5 skipped. black and ruff clean. helm template renders.
  • Not run: integration and e2e (need Temporal, Podman or Kind). Nothing was run against a live gateway, so label handling on a real gateway and real create/readiness timings against the 420s budget are unverified.

🤖 Generated with Claude Code

jameswnl and others added 12 commits October 31, 2026 21:52
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 beesarmy left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@jameswnl
jameswnl merged commit 37a0840 into lightspeed-core:main Nov 1, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants