Skip to content

feat(gc): sandy --gc — unified Docker-resource reclaim (#36) - #63

Merged
rappdw merged 1 commit into
mainfrom
feat/36-sandy-gc
Jul 21, 2026
Merged

feat(gc): sandy --gc — unified Docker-resource reclaim (#36)#63
rappdw merged 1 commit into
mainfrom
feat/36-sandy-gc

Conversation

@rappdw

@rappdw rappdw commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Closes #36.

Problem

Sandy accumulates Docker resources during normal use with no reclaim path: dangling <none> images from same-tag rebuilds, orphaned per-project/skill images, and dead-owner containers left by SIGKILL/OOM where the cleanup() trap never fired. Networks were already handled (--prune-orphans); nothing else was.

What this adds

sandy --gc — a global, workspace-independent fast-path command (same pre-preflight family as --print-state/--prune-orphans; needs only a reachable Docker daemon) that reclaims all four resource types:

  • dead-owner containerssandy-* / sandy-proxy-* whose owning session is dead
  • orphan networks — folds in the existing _sandy_reap_orphan_networks
  • orphaned per-project / skill imagessandy-project-<name> / sandy-skills-* no longer referenced by a sandbox/config
  • dangling <none> sandy images — scoped by a new sandy.managed=1 build label so a stranger's dangling image is never touched

--dry-run / --yes mirror --update-sessions (nothing-to-do → exit 0; non-TTY with something to reclaim and no --yes → exit 1; TTY → y/N). Reap order is containers → networks → images so docker rmi (no -f) actually frees. Two new full-mode-only --print-state keys — dangling_images, orphaned_containers (null in light mode, zero added light-mode spawns). schema_version stays 1.

Safety — never reap a live session's resource

  • Container liveness reuses the "container is truth only with a live inner tmux session" rule (D6/DEC-B/--stop), never trusting sandy.daemon_pid — so the D9 reboot case (a container --restart-resurrected with a dead supervisor pid) stays alive via a tmux has-session probe.
  • Agent-vs-proxy is discriminated by IMAGE, not name prefix, and a proxy's liveness follows its paired agent (two-pass classifier) — keeps a live daemon's proxy when its supervisor was killed, and stops a workspace literally named proxy from being mis-stripped.
  • The reaper's daemon probe uses the same 5×/sleep 1 mid-startup retry as --start so a concurrent launch isn't reaped out from under itself.

Process

Plan (fable) → implement (Sonnet 5) → adversarial verify (Opus 4.8). The verify pass caught two live-session reap paths (proxy strand of a supervisor-less daemon; proxy-named workspace) + two majors (mid-startup race; skills in-use set ignoring host-global SANDY_SKILL_PACKS). All four fixed and re-verified: SHIP — no new false-positive paths, tests genuinely bind.

Tests

run-tests.sh §79 — 49 assertions: structural + a stateful stubbed-docker harness covering the D9-alive-survives, unlabeled-dangling-untouched, proxy-paired/proxy-named, orphan-proxy, mid-startup-retry, and host-config-skills cases. Real reclaim against live Docker is deferred to a maintainer-run acceptance harness (like other Docker-runtime features).

Notes

  • --prune-orphans kept unchanged as a documented network-only subset (shares the lister/reaper, can't drift).
  • Retroactive gap: images built by pre-1.3.0 sandy lack the sandy.managed=1 label and won't be caught by the dangling-image step — self-heals as images churn.

Additive-only (1.3.0); sandbox forward-compat holds.

🤖 Generated with Claude Code

@rappdw
rappdw force-pushed the feat/36-sandy-gc branch from fddfd0b to be74e71 Compare July 21, 2026 14:47
Adds a `sandy --gc` fast-path command (in the --print-state/--prune-orphans
family — pre-preflight, needs only a reachable Docker daemon) that reclaims the
resources sandy leaks during normal use:

- dead-owner containers (sandy-*/sandy-proxy-* whose owning session is dead)
- orphaned sandy_* networks (folds in the existing reaper)
- orphaned per-project (sandy-project-<name>) and skill (sandy-skills-*) images
  whose sandbox/config no longer references them
- dangling <none> sandy images, scoped by a new `sandy.managed=1` build label
  so a stranger's dangling image is never in range

Sub-flags --dry-run / --yes mirror --update-sessions (nothing-to-do exits 0;
non-TTY without --yes when there IS something to reclaim → exit 1; TTY → y/N).
Reap order is containers → networks → images so rmi (no -f) actually frees.

Safety (never reap a live session's resource):
- Container liveness reuses the "container is truth only with a live inner tmux
  session" rule (D6/DEC-B/--stop), NEVER trusting sandy.daemon_pid — the D9
  reboot case (--restart resurrects a container whose supervisor pid is dead)
  is kept alive by probing `tmux has-session`.
- Agent-vs-proxy is discriminated by IMAGE, not name prefix; a proxy's liveness
  follows its paired agent (two-pass classifier). This keeps a live daemon's
  proxy when its supervisor was killed, and stops a workspace literally named
  `proxy` from being mis-stripped and reaped.
- The reaper's daemon has-session probe uses the same 5x/sleep-1 mid-startup
  retry as --start so a concurrent launch isn't reaped out from under itself;
  the informational --print-state count stays single-probe.

Adds two full-mode-only --print-state keys (dangling_images, orphaned_containers)
— null in light mode, zero added light-mode spawns. schema_version stays 1.

--prune-orphans is kept unchanged as a documented network-only subset (shares
the lister/reaper, can't drift). Real reclaim against live Docker is deferred to
a maintainer-run acceptance harness; run-tests.sh §79 covers structure +
stubbed-docker behavior (49 assertions, incl. the D9-alive and unlabeled-
dangling-untouched safety cases).

Plan(fable)/implement(sonnet)/verify(opus) loop: verify caught two live-session
reap paths (proxy strand + `proxy`-named workspace), both fixed and re-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@rappdw
rappdw force-pushed the feat/36-sandy-gc branch from be74e71 to 11f4ca6 Compare July 21, 2026 14:56
@rappdw
rappdw merged commit 11f4ca6 into main Jul 21, 2026
2 checks passed
@rappdw
rappdw deleted the feat/36-sandy-gc branch July 21, 2026 15:03
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.

Resource leaks during normal use: images never GC'd; containers/networks orphaned on hard exit

1 participant