Files
cleveragents-core/features/sandbox_manager_coverage.feature
Luis Mendes 7f078f75a5
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 28s
CI / security (pull_request) Successful in 1m8s
CI / build (pull_request) Successful in 20s
CI / helm (pull_request) Successful in 34s
CI / quality (pull_request) Successful in 3m53s
CI / integration_tests (pull_request) Successful in 4m2s
CI / typecheck (pull_request) Successful in 4m9s
CI / unit_tests (pull_request) Successful in 4m48s
CI / docker (pull_request) Successful in 1m19s
CI / e2e_tests (pull_request) Successful in 9m47s
CI / coverage (pull_request) Successful in 11m41s
CI / status-check (pull_request) Successful in 1s
CI / quality (push) Successful in 52s
CI / lint (push) Successful in 3m18s
CI / build (push) Successful in 20s
CI / typecheck (push) Successful in 3m56s
CI / helm (push) Successful in 22s
CI / security (push) Successful in 4m15s
CI / unit_tests (push) Successful in 4m39s
CI / docker (push) Successful in 18s
CI / integration_tests (push) Successful in 6m56s
CI / e2e_tests (push) Successful in 12m34s
CI / coverage (push) Successful in 11m39s
CI / status-check (push) Successful in 1s
CI / benchmark-regression (push) Has been skipped
CI / benchmark-publish (push) Successful in 30m10s
CI / benchmark-regression (pull_request) Successful in 57m33s
fix(sandbox): make commit_all atomic per specification
Changed SandboxManager.commit_all() from partial-commit semantics to
all-or-nothing atomic operation per specification requirement. On
partial failure, already-committed sandboxes are rolled back. Error
reporting indicates which sandbox failed and what was rolled back.
Added Behave scenarios verifying atomicity guarantee.

Hardened atomicity guarantees after code review:

- commit_all and _rollback_committed catch Exception (not just
  SandboxError) so unexpected errors cannot bypass rollback.
  Non-SandboxError exceptions are wrapped in a new AtomicCommitError
  (chaining the original as __cause__) that carries rolled_back_ids
  and failed_rollback_ids attributes so callers can programmatically
  determine rollback outcomes.

- _rollback_committed returns both rolled_back_ids and
  failed_rollback_ids; the error result metadata now carries
  both "rolled_back" and "rollback_failed" keys.

- _rollback_committed iterates in reverse (LIFO) order following
  the standard transaction-log undo pattern.  Clarified in
  docstring that this is distinct from the specification DAG-based
  "top-down" rollback ordering (line 24632).

- TransactionSandbox.rollback() from COMMITTED now raises
  SandboxRollbackError (database commits are irreversible)
  instead of silently transitioning to ROLLED_BACK.

- TransactionSandbox is now classified as non-rollbackable in
  commit_all alongside NoSandbox and committed last in the batch,
  since database COMMIT is irreversible.  Docstring corrected to
  match the raising behavior.

- CopyOnWriteSandbox and OverlaySandbox rollback-from-COMMITTED
  uses rename-based safe_restore() to prevent data loss when
  the copytree step fails after the original was removed.

- CopyOnWriteSandbox and OverlaySandbox rollback() now catches
  Exception (not just OSError), matching the broader catch used
  in _rollback_committed, so non-OSError exceptions from
  safe_restore set the status to ERRORED correctly.

- Extracted shared _fs_utils module (backup_directory,
  safe_restore, compute_diff) with symlink, permission, and
  timestamp preservation (including directory timestamps),
  replacing duplicated per-class _backup_directory and
  _compute_diff methods.

- backup_directory defers directory permissions and timestamps
  to a bottom-up post-walk pass, fixing incorrect mtime
  preservation (POSIX file creation inside a directory overwrites
  its mtime) and preventing restrictive source permissions from
  blocking backup writes.

- backup_directory skips non-regular files (FIFOs, sockets,
  device files) with a warning to prevent hangs on special files.

- CopyOnWriteSandbox and OverlaySandbox commit() now attempts
  to restore the original from the pre-commit backup when the
  file-copy phase fails midway, preventing partial corruption.
  If the restore itself fails the backup is preserved for manual
  recovery (cleanup() still removes it).

- CopyOnWriteSandbox and OverlaySandbox commit() error handler
  now catches Exception (not just OSError) so that unexpected
  errors during the file-copy phase also trigger pre-commit
  backup restoration, preventing partial corruption of the
  original directory.

- Pre-commit backup exception handler catches Exception (not
  just OSError) preventing temp directory leaks on non-OSError
  failures from backup_directory.

- Fixed _pre_commit_backup assignment timing: the backup
  reference is now assigned only AFTER backup_directory()
  succeeds, preventing safe_restore() from corrupting an intact
  original with a partial backup when backup_directory() fails
  (e.g. disk full).

- Rollback from COMMITTED with no pre-commit backup (no changes
  were applied) is now a no-op instead of raising
  SandboxRollbackError, preventing false rollback-failure
  reports in commit_all error metadata.

- commit_all logs a warning when NoSandbox or TransactionSandbox
  instances are present in the batch since their changes cannot
  be rolled back, which breaks the atomicity guarantee.

- Pre-commit backup is skipped when compute_diff returns no
  changes, avoiding a full directory copy for no-op commits.

- GitWorktreeSandbox clears _pre_merge_commit on commit failure
  so the stale value cannot be used by future code.

- commit_all docstring documents Raises clause for AtomicCommitError
  exception wrapping behavior.

- GitWorktreeSandbox.rollback() docstring warns about
  multi-worktree safety when rolling back from COMMITTED.

- Updated SandboxStatus transition diagram in protocol.py to
  clearly show the COMMITTED -> ROLLED_BACK path.

- Added spec-contradiction note (line 45938 vs 19193) in
  commit_all docstring.

- OverlaySandbox rollback from COMMITTED now properly remounts
  OverlayFS for real overlay (unmount, clean upper/work dirs,
  remount) and uses dirs_exist_ok=True for userspace fallback
  to prevent FileExistsError if rmtree silently fails.  The
  merged directory is reset from the restored original,
  preventing stale pre-rollback data from being exposed on
  re-activation via get_path() (which allows ROLLED_BACK status).

- OverlaySandbox rollback from COMMITTED now raises
  SandboxRollbackError if the OverlayFS unmount fails,
  preventing a double-mount attempt that would leave the
  sandbox in an inconsistent state.

- OverlaySandbox rollback from ACTIVE now uses dirs_exist_ok=True
  for userspace fallback to prevent FileExistsError when rmtree
  with ignore_errors=True silently fails.

- CopyOnWriteSandbox rollback from ACTIVE now uses
  dirs_exist_ok=True in copytree to prevent FileExistsError
  when rmtree with ignore_errors=True silently fails, matching
  the fix already applied to OverlaySandbox.

- Non-rollbackable sandboxes (NoSandbox, TransactionSandbox) are
  committed last in the batch so that all rollbackable sandboxes
  commit first; if any rollbackable sandbox fails, none of the
  non-rollbackable sandboxes will have committed yet.

- Moved NoSandbox and TransactionSandbox imports to module level
  in manager.py (no circular dependency exists).

- Pre-commit backups are now created on the same filesystem as
  the original directory (using dir= argument to mkdtemp),
  avoiding cross-device copy overhead and ensuring os.rename
  compatibility.

- safe_restore now renames the target into the mkdtemp directory
  instead of removing the mkdtemp dir first, eliminating the
  residual TOCTOU window between rmdir and rename.

- safe_restore catches BaseException (not just OSError) to
  ensure the original directory is always renamed back on
  unexpected errors, preventing the original from being left
  in the renamed-aside state.

- Added AtomicCommitError exception class to protocol.py
  carrying rolled_back_ids and failed_rollback_ids attributes.

- Exported AtomicCommitError from sandbox package __init__.py
  so callers can import it from the public API.

- Added BDD scenarios: LIFO rollback order, AtomicCommitError
  wrapping with RuntimeError cause and rollback metadata,
  _fs_utils backup/restore coverage, no-change commit rollback
  success, directory timestamp preservation, OverlaySandbox
  merged dir reset after COMMITTED rollback,
  CopyOnWriteSandbox rollback from COMMITTED restores original,
  GitWorktreeSandbox rollback from COMMITTED undoes merge,
  TransactionSandbox rollback from COMMITTED raises
  SandboxRollbackError about irreversible commit.

ISSUES CLOSED: #925

Post-review hardening (PR #1146 review findings):

- OverlaySandbox rollback from COMMITTED with no backup (no-op)
  now skips the merged directory reset entirely, preventing
  unnecessary unmount/remount or re-copy that could fail and
  turn a harmless no-op rollback into a SandboxRollbackError
  during commit_all atomic recovery.

- OverlaySandbox rollback no longer double-wraps
  SandboxRollbackError: the outer except Exception handler
  now has a preceding except SandboxRollbackError clause that
  re-raises directly, avoiding a confusing double-wrapped
  error chain.

- CopyOnWriteSandbox.get_path() now accepts ROLLED_BACK status
  for consistency with OverlaySandbox and the protocol status
  transition table (ROLLED_BACK -> ACTIVE).

- CopyOnWriteSandbox rollback from COMMITTED now resets the
  sandbox copy from the restored original via rmtree+copytree,
  preventing stale pre-rollback modifications from being
  exposed on re-activation.

- rollback_all now catches Exception (not just SandboxError)
  so that unexpected rollback errors do not prevent remaining
  sandboxes from being rolled back, consistent with the pattern
  already used in _rollback_committed.

- commit_all docstring now documents a thread-safety warning:
  the method is not safe for concurrent calls on the same
  plan_id since sandbox commit/rollback runs outside the lock.

- Fixed CHANGELOG.md whitespace inconsistencies (double leading
  spaces on two lines).

Post-review hardening round 2 (PR #1146 automated review):

- safe_restore now uses os.rename (O(1) atomic rename) instead
  of shutil.copytree (O(n) recursive copy) for the main restore
  path, since backup and target are always on the same
  filesystem.  This eliminates the ENOTEMPTY bug where a partial
  copytree failure left target_path partially populated, causing
  the recovery os.rename to fail and strand the original in the
  stale temp directory.

- OverlaySandbox.get_path() now transitions ROLLED_BACK to
  ACTIVE, matching CopyOnWriteSandbox and the protocol
  transition table (ROLLED_BACK -> ACTIVE).

- GitWorktreeSandbox.get_path() now accepts ROLLED_BACK status
  for consistency with all other sandbox implementations and
  the protocol transition table (ROLLED_BACK -> ACTIVE).

- rollback_all now also handles sandboxes in COMMITTED status
  (not just ACTIVE), consistent with the state machine allowing
  COMMITTED -> ROLLED_BACK.

- cleanup_all now catches Exception (not just SandboxError) so
  a single unexpected error does not abort cleanup of remaining
  sandboxes, consistent with _rollback_committed and
  rollback_all.

- Restructured CHANGELOG entry from a single ~90-line paragraph
  into structured sub-bullets for readability.

- Added BDD scenarios: no-op rollback from COMMITTED for
  CopyOnWriteSandbox and OverlaySandbox (zero-change commit),
  commit ordering verification (rollbackable before
  non-rollbackable).

Post-review hardening round 3 (PR #1146 deep automated review):

- cleanup_abandoned now catches Exception (not just SandboxError)
  so that unexpected errors (e.g. raw OSError, PermissionError)
  do not crash the loop and prevent remaining abandoned sandboxes
  from being cleaned up, consistent with cleanup_all,
  rollback_all, and _rollback_committed.

- OverlaySandbox._mount_overlay() now catches
  subprocess.TimeoutExpired (in addition to CalledProcessError
  and OSError), preventing create() from leaving the sandbox in
  PENDING status when mount hangs beyond the timeout.

- OverlaySandbox._unmount_overlay() now catches
  subprocess.TimeoutExpired (in addition to CalledProcessError
  and OSError), preventing cleanup() from leaving the sandbox
  in a zombie state when umount hangs beyond the timeout.

- OverlaySandbox._mount_overlay() validates that overlay paths
  do not contain commas, which would corrupt the OverlayFS mount
  options string (comma is the mount option delimiter).

- GitWorktreeSandbox.commit() now checks git diff return code
  so that a failed diff command raises CalledProcessError instead
  of silently concluding there are no changes and skipping the
  merge.

- safe_restore cleanup of the temporary rollback container now
  runs in a finally block, preventing a temp directory leak
  when the rename fails and the exception is re-raised.

- Fixed misleading BDD step name: "backup path that will cause
  copytree to fail" renamed to "backup path that will cause
  rename to fail" since safe_restore now uses os.rename.
2026-03-29 18:57:15 +01:00

363 lines
20 KiB
Gherkin

Feature: Sandbox Manager Lifecycle
As an execution engine
I want a manager that tracks sandbox instances across plan executions
So that resources are isolated during execution and cleaned up reliably
Background:
Given a sandbox factory instance
And a sandbox manager with the factory
# Initialisation
Scenario: A new manager starts with no tracked sandboxes
Then the manager should have no active sandboxes
And the manager cleanup_on_exit flag should be true
Scenario: A manager can be created without automatic exit cleanup
Given a sandbox manager with cleanup_on_exit disabled
Then the manager cleanup_on_exit flag should be false
Scenario: Creating a manager without a factory is rejected
When I create a sandbox manager with None factory
Then a sandbox ValueError should be raised with message "factory cannot be None"
# Lazy sandbox creation
Scenario: Requesting a sandbox for a plan creates and tracks a new sandbox
When I get or create a sandbox for plan "plan-001" resource "res-001" path "/tmp/repo" strategy "none"
Then a sandbox should be returned
And the sandbox should have status "created"
And the sandbox should be tracked for plan "plan-001"
Scenario: Requesting the same plan-resource pair returns the existing sandbox
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo" strategy "none"
When I get or create a sandbox for plan "plan-001" resource "res-001" path "/tmp/repo" strategy "none"
Then the same sandbox instance should be returned
Scenario: An active sandbox is reused when requested again
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo" strategy "none"
And the sandbox for plan "plan-001" resource "res-001" is activated
When I get or create a sandbox for plan "plan-001" resource "res-001" path "/tmp/repo" strategy "none"
Then the same sandbox instance should be returned
Scenario: A committed sandbox is replaced by a fresh instance on next request
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo" strategy "none"
And the sandbox for plan "plan-001" resource "res-001" is committed
When I get or create a sandbox for plan "plan-001" resource "res-001" path "/tmp/repo" strategy "none"
Then a different sandbox instance should be returned
Scenario: A cleaned-up sandbox is replaced by a fresh instance on next request
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo" strategy "none"
And the sandbox for plan "plan-001" resource "res-001" is cleaned up
When I get or create a sandbox for plan "plan-001" resource "res-001" path "/tmp/repo" strategy "none"
Then a different sandbox instance should be returned
Scenario: A rolled-back sandbox is still usable and returned on next request
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo" strategy "none"
And the sandbox for plan "plan-001" resource "res-001" is rolled back
When I get or create a sandbox for plan "plan-001" resource "res-001" path "/tmp/repo" strategy "none"
Then the same sandbox instance should be returned
Scenario: An errored sandbox is replaced by a fresh instance on next request
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo" strategy "none"
And the sandbox for plan "plan-001" resource "res-001" is in errored state
When I get or create a sandbox for plan "plan-001" resource "res-001" path "/tmp/repo" strategy "none"
Then a different sandbox instance should be returned
# Argument validation on sandbox creation
Scenario: A sandbox request without a plan identifier is rejected
When I get or create a sandbox with empty plan_id
Then a sandbox ValueError should be raised with message "plan_id cannot be empty"
Scenario: A sandbox request without a resource identifier is rejected
When I get or create a sandbox with empty resource_id
Then a sandbox ValueError should be raised with message "resource_id cannot be empty"
Scenario: A sandbox request without an original path is rejected
When I get or create a sandbox with empty original_path
Then a sandbox ValueError should be raised with message "original_path cannot be empty"
# Multi-resource and multi-plan tracking
Scenario: Multiple resources under the same plan are tracked independently
When I get or create a sandbox for plan "plan-001" resource "res-001" path "/tmp/repo1" strategy "none"
And I get or create a sandbox for plan "plan-001" resource "res-002" path "/tmp/repo2" strategy "none"
Then 2 sandboxes should be tracked for plan "plan-001"
Scenario: Sandboxes from different plans do not interfere with each other
When I get or create a sandbox for plan "plan-001" resource "res-001" path "/tmp/repo1" strategy "none"
And I get or create a sandbox for plan "plan-002" resource "res-001" path "/tmp/repo2" strategy "none"
Then 1 sandboxes should be tracked for plan "plan-001"
And 1 sandboxes should be tracked for plan "plan-002"
# Looking up an existing sandbox
Scenario: An existing sandbox can be retrieved by plan and resource
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo" strategy "none"
When I get sandbox for plan "plan-001" resource "res-001"
Then a sandbox should be returned
Scenario: Looking up a sandbox for an unknown plan returns nothing
When I get sandbox for plan "plan-999" resource "res-999"
Then the sandbox result should be None
Scenario: Looking up a sandbox for a known plan but unknown resource returns nothing
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo" strategy "none"
When I get sandbox for plan "plan-001" resource "res-999"
Then the sandbox result should be None
# Listing sandboxes for a plan
Scenario: All sandboxes for a plan can be listed
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo1" strategy "none"
And a sandbox exists for plan "plan-001" resource "res-002" path "/tmp/repo2" strategy "none"
When I list sandboxes for plan "plan-001"
Then the sandbox list should contain 2 sandboxes
Scenario: Listing sandboxes for a plan with none returns an empty list
When I list sandboxes for plan "plan-999"
Then the sandbox list should contain 0 sandboxes
# Batch commit (atomic)
Scenario: All active sandboxes for a plan are committed together
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo1" strategy "none"
And the sandbox for plan "plan-001" resource "res-001" is activated
And a sandbox exists for plan "plan-001" resource "res-002" path "/tmp/repo2" strategy "none"
And the sandbox for plan "plan-001" resource "res-002" is activated
When I commit all sandboxes for plan "plan-001"
Then 2 commit results should be returned
And all commit results should be successful
Scenario: Batch commit skips sandboxes that are already committed
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo1" strategy "none"
And the sandbox for plan "plan-001" resource "res-001" is committed
When I commit all sandboxes for plan "plan-001"
Then 0 commit results should be returned
Scenario: Batch commit on a plan with no sandboxes returns an empty result set
When I commit all sandboxes for plan "plan-999"
Then 0 commit results should be returned
Scenario: Batch commit without a plan identifier is rejected
When I commit all sandboxes with empty plan_id
Then a sandbox ValueError should be raised with message "plan_id cannot be empty"
Scenario: A single sandbox failure in atomic commit returns an error result
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo1" strategy "none"
And the sandbox for plan "plan-001" resource "res-001" is activated
And the sandbox for plan "plan-001" resource "res-001" will fail on commit
When I commit all sandboxes for plan "plan-001"
Then 1 commit results should be returned
And the first commit result should have success false
Scenario: Newly created sandboxes can be committed before activation
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo1" strategy "none"
When I commit all sandboxes for plan "plan-001"
Then 1 commit results should be returned
And all commit results should be successful
# Atomic commit — rollback on failure
Scenario: Atomic commit rolls back already-committed sandboxes when one fails
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo1" strategy "none"
And the sandbox for plan "plan-001" resource "res-001" is replaced with a committable mock
And a sandbox exists for plan "plan-001" resource "res-002" path "/tmp/repo2" strategy "none"
And the sandbox for plan "plan-001" resource "res-002" is activated
And the sandbox for plan "plan-001" resource "res-002" will fail on commit
When I commit all sandboxes for plan "plan-001"
Then 1 commit results should be returned
And the first commit result should have success false
And the first commit result error should mention the failed sandbox
And the first commit result metadata should list rolled back sandboxes
Scenario: Atomic commit error reports the sandbox that failed
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo1" strategy "none"
And the sandbox for plan "plan-001" resource "res-001" is activated
And the sandbox for plan "plan-001" resource "res-001" will fail on commit
When I commit all sandboxes for plan "plan-001"
Then 1 commit results should be returned
And the first commit result should have success false
And the first commit result error should contain "Atomic commit failed"
Scenario: Atomic commit tolerates rollback errors during recovery
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo1" strategy "none"
And the sandbox for plan "plan-001" resource "res-001" will succeed commit then fail rollback
And a sandbox exists for plan "plan-001" resource "res-002" path "/tmp/repo2" strategy "none"
And the sandbox for plan "plan-001" resource "res-002" is activated
And the sandbox for plan "plan-001" resource "res-002" will fail on commit
When I commit all sandboxes for plan "plan-001"
Then 1 commit results should be returned
And the first commit result should have success false
And the first commit result metadata should list failed rollback sandboxes
Scenario: Atomic commit succeeds rollback for a no-change sandbox
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo1" strategy "none"
And the sandbox for plan "plan-001" resource "res-001" is replaced with a no-change committable mock
And a sandbox exists for plan "plan-001" resource "res-002" path "/tmp/repo2" strategy "none"
And the sandbox for plan "plan-001" resource "res-002" is activated
And the sandbox for plan "plan-001" resource "res-002" will fail on commit
When I commit all sandboxes for plan "plan-001"
Then 1 commit results should be returned
And the first commit result should have success false
And the no-change sandbox rollback should have succeeded for plan "plan-001" resource "res-001"
Scenario: Atomic commit wraps non-SandboxError in AtomicCommitError after rollback
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo1" strategy "none"
And the sandbox for plan "plan-001" resource "res-001" is replaced with a committable mock
And a sandbox exists for plan "plan-001" resource "res-002" path "/tmp/repo2" strategy "none"
And the sandbox for plan "plan-001" resource "res-002" is activated
And the sandbox for plan "plan-001" resource "res-002" will raise RuntimeError on commit
When I commit all sandboxes for plan "plan-001" expecting a non-sandbox error
Then an AtomicCommitError should have been raised with RuntimeError as cause
And the AtomicCommitError should carry rollback metadata
And the committable mock for plan "plan-001" resource "res-001" should have been rolled back
Scenario: Atomic commit rolls back in reverse order
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo1" strategy "none"
And the sandbox for plan "plan-001" resource "res-001" is replaced with a committable mock
And a sandbox exists for plan "plan-001" resource "res-002" path "/tmp/repo2" strategy "none"
And the sandbox for plan "plan-001" resource "res-002" is replaced with a committable mock
And a sandbox exists for plan "plan-001" resource "res-003" path "/tmp/repo3" strategy "none"
And the sandbox for plan "plan-001" resource "res-003" is activated
And the sandbox for plan "plan-001" resource "res-003" will fail on commit
When I commit all sandboxes for plan "plan-001"
Then 1 commit results should be returned
And the first commit result should have success false
And the rollback order should be reverse of commit order for plan "plan-001"
Scenario: Atomic commit orders rollbackable sandboxes before non-rollbackable
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo1" strategy "none"
And the sandbox for plan "plan-001" resource "res-001" is replaced with a committable mock
And a sandbox exists for plan "plan-001" resource "res-002" path "/tmp/repo2" strategy "none"
And the sandbox for plan "plan-001" resource "res-002" is replaced with a non-rollbackable mock
And a sandbox exists for plan "plan-001" resource "res-003" path "/tmp/repo3" strategy "none"
And the sandbox for plan "plan-001" resource "res-003" is activated
And the sandbox for plan "plan-001" resource "res-003" will fail on commit
When I commit all sandboxes for plan "plan-001"
Then 1 commit results should be returned
And the first commit result should have success false
And the non-rollbackable mock for plan "plan-001" resource "res-002" should not have been committed
# Batch rollback
Scenario: All active sandboxes for a plan are rolled back together
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo1" strategy "none"
And the sandbox for plan "plan-001" resource "res-001" is activated
When I rollback all sandboxes for plan "plan-001"
Then no sandbox error should be raised
Scenario: Batch rollback skips sandboxes that are not active
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo1" strategy "none"
When I rollback all sandboxes for plan "plan-001"
Then no sandbox error should be raised
Scenario: Batch rollback on a plan with no sandboxes succeeds silently
When I rollback all sandboxes for plan "plan-999"
Then no sandbox error should be raised
Scenario: Batch rollback without a plan identifier is rejected
When I rollback all sandboxes with empty plan_id
Then a sandbox ValueError should be raised with message "plan_id cannot be empty"
Scenario: A single sandbox failure during batch rollback does not abort the others
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo1" strategy "none"
And the sandbox for plan "plan-001" resource "res-001" is activated
And the sandbox for plan "plan-001" resource "res-001" will fail on rollback
When I rollback all sandboxes for plan "plan-001"
Then no sandbox error should be raised
# Batch cleanup
Scenario: All sandboxes for a plan are cleaned up and the plan is untracked
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo1" strategy "none"
And a sandbox exists for plan "plan-001" resource "res-002" path "/tmp/repo2" strategy "none"
When I cleanup all sandboxes for plan "plan-001"
Then no sandbox error should be raised
And plan "plan-001" should no longer be tracked
Scenario: Batch cleanup without a plan identifier is rejected
When I cleanup all sandboxes with empty plan_id
Then a sandbox ValueError should be raised with message "plan_id cannot be empty"
Scenario: Batch cleanup on a plan with no sandboxes succeeds silently
When I cleanup all sandboxes for plan "plan-999"
Then no sandbox error should be raised
Scenario: A single sandbox failure during batch cleanup does not prevent untracking
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo1" strategy "none"
And the sandbox for plan "plan-001" resource "res-001" will fail on cleanup
When I cleanup all sandboxes for plan "plan-001"
Then no sandbox error should be raised
And plan "plan-001" should no longer be tracked
# Abandoned sandbox cleanup
Scenario: Errored sandboxes are cleaned up as abandoned
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo1" strategy "none"
And the sandbox for plan "plan-001" resource "res-001" is in errored state
When I cleanup abandoned sandboxes
Then 1 abandoned sandboxes should be cleaned up
Scenario: Committed sandboxes are cleaned up as abandoned
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo1" strategy "none"
And the sandbox for plan "plan-001" resource "res-001" is committed
When I cleanup abandoned sandboxes
Then 1 abandoned sandboxes should be cleaned up
Scenario: Rolled-back sandboxes are cleaned up as abandoned
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo1" strategy "none"
And the sandbox for plan "plan-001" resource "res-001" is rolled back
When I cleanup abandoned sandboxes
Then 1 abandoned sandboxes should be cleaned up
Scenario: Active sandboxes are not considered abandoned
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo1" strategy "none"
And the sandbox for plan "plan-001" resource "res-001" is activated
When I cleanup abandoned sandboxes
Then 0 abandoned sandboxes should be cleaned up
Scenario: A plan entry is removed once all of its sandboxes have been cleaned up
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo1" strategy "none"
And the sandbox for plan "plan-001" resource "res-001" is committed
When I cleanup abandoned sandboxes
Then 1 abandoned sandboxes should be cleaned up
And plan "plan-001" should no longer be tracked
Scenario: Cleanup errors on individual abandoned sandboxes are contained
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo1" strategy "none"
And the sandbox for plan "plan-001" resource "res-001" is in errored state
And the sandbox for plan "plan-001" resource "res-001" will fail on cleanup
When I cleanup abandoned sandboxes
Then 0 abandoned sandboxes should be cleaned up
Scenario: Abandoned sandbox cleanup operates across all tracked plans
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo1" strategy "none"
And the sandbox for plan "plan-001" resource "res-001" is committed
And a sandbox exists for plan "plan-002" resource "res-002" path "/tmp/repo2" strategy "none"
And the sandbox for plan "plan-002" resource "res-002" is in errored state
When I cleanup abandoned sandboxes
Then 2 abandoned sandboxes should be cleaned up
# Graceful process exit
Scenario: The exit handler cleans up all remaining sandboxes
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo1" strategy "none"
And a sandbox exists for plan "plan-002" resource "res-002" path "/tmp/repo2" strategy "none"
When the atexit cleanup handler runs
Then plan "plan-001" should no longer be tracked
And plan "plan-002" should no longer be tracked
Scenario: The exit handler tolerates individual sandbox cleanup failures
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo1" strategy "none"
And the sandbox for plan "plan-001" resource "res-001" will fail on cleanup
When the atexit cleanup handler runs
Then no sandbox error should be raised
Scenario: The exit handler swallows exceptions from the cleanup subsystem
Given a sandbox exists for plan "plan-001" resource "res-001" path "/tmp/repo1" strategy "none"
And cleanup_all is patched to raise a RuntimeError
When the atexit cleanup handler runs
Then no sandbox error should be raised