fix(sandbox/v2): LifecycleAction uses computer.BoxID instead of cfg.ID

cfg (ast.SandboxV2) is a pointer shared across all concurrent calls to
the same Assistant. GetComputer writes cfg.ID = <random hex> for each
oneshot request, so concurrent tasks overwrite each other's ID.

When cleanup runs in defer, cfg.ID holds whichever task last wrote it —
not the ID of the box this request created — so manager.Remove/Heartbeat
silently fails with "sandbox: not found" and the container leaks.

Fix: derive the box ID from computer.ComputerInfo().BoxID, which is the
immutable b.id set at Create time and never mutated by concurrent callers.

Made-with: Cursor
This commit is contained in:
Max 2026-03-29 23:08:50 +08:00
parent c26d764857
commit cc0bb7e161

View file

@ -284,24 +284,30 @@ func resolveBox(
// LifecycleAction performs the post-request lifecycle operation based on policy.
// Called in defer after executeSandboxStream completes.
//
// NOTE: cfg.ID must NOT be used here — it is a shared mutable field on the
// Assistant struct and is overwritten by concurrent requests. The authoritative
// box ID is computer.ComputerInfo().BoxID, which is set once when the Box is
// created and never changes.
func LifecycleAction(ctx context.Context, cfg *types.SandboxConfig, computer infra.Computer, manager *infra.Manager) {
if computer == nil || cfg == nil {
return
}
info := computer.ComputerInfo()
boxID := info.BoxID // use the box's own immutable ID, not cfg.ID
switch cfg.Lifecycle {
case "oneshot":
if info.Kind == "box" && manager != nil {
if err := manager.Remove(ctx, cfg.ID); err != nil {
log.Trace("[sandbox/v2] oneshot remove %s: %v", cfg.ID, err)
if err := manager.Remove(ctx, boxID); err != nil {
log.Trace("[sandbox/v2] oneshot remove %s: %v", boxID, err)
}
}
case "session", "longrunning":
if info.Kind == "box" && manager != nil {
manager.Heartbeat(cfg.ID, false, 0) // active=false: request finished, start idle timer
manager.Heartbeat(boxID, false, 0) // active=false: request finished, start idle timer
}
case "persistent":