Skip to content

feat: remount — recreate containers preserving writable layer#71

Merged
rsdouglas merged 1 commit intomainfrom
feat/remount-command
Feb 26, 2026
Merged

feat: remount — recreate containers preserving writable layer#71
rsdouglas merged 1 commit intomainfrom
feat/remount-command

Conversation

@rsdouglas
Copy link
Contributor

Problem

Docker doesn't support adding volume mounts to existing containers. When createContainer() gains new mounts (like the /board directory from #65), existing creatures can't pick them up without a full rebuild — which destroys their writable layer (self-installed packages like supervisord, python libraries, custom configs, etc.).

restart preserves the writable layer but keeps the old mounts.
rebuild picks up new mounts but wipes the writable layer.

There's no middle ground.

Solution

Adds a remount command that:

  1. Stops the container
  2. Commits the container's current state (writable layer) into its image via docker commit
  3. Removes the old container
  4. Creates a fresh container from the updated image via the existing spawnCreature() flow

The new container picks up any changes to volume mounts, env vars, or network config in createContainer(), while the committed image preserves everything the creature installed or configured.

How to trigger

  • Dashboard: Click the "remount" button on a creature's detail page (with confirmation dialog)
  • API: POST /api/creatures/:name/remount
  • curl: curl -X POST http://localhost:7770/api/creatures/wondrous/remount

When to use

  • After adding new volume mounts to createContainer() (e.g. /board)
  • After changing env vars or network config
  • Anytime you need to "upgrade" a container's configuration without losing its installed state

What it doesn't do

  • Does NOT rebuild the Docker image from the Dockerfile (use rebuild for that)
  • Does NOT preserve running process state (supervisord etc. will need to restart, but their binaries and configs survive)
  • Volume-mounted paths (/creature, node_modules) are unaffected — they're external storage

Changes

  • supervisor.ts: remount() method
  • index.ts: remountCreature() + POST /api/creatures/:name/remount route
  • CreatureDetail.tsx: remount button with confirmation dialog

Made with Cursor

Copy link
Contributor

@openseed-patch openseed-patch bot left a comment

Choose a reason for hiding this comment

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

Good idea — remount fills a real gap. The commit-image-then-recreate approach is the right mechanism. Three things to look at:


1. No error handling if docker commit fails — supervisor ends up in bad state

src/host/supervisor.ts (new remount()):

try { execSync(`docker stop ${cname}`, { stdio: 'ignore', timeout: 15_000 }); } catch {}
execSync(`docker commit ${cname} ${cname}`, { stdio: 'ignore', timeout: 60_000 });
try { execSync(`docker rm -f ${cname}`, { stdio: 'ignore' }); } catch {}

this.creature = null;
this.currentSHA = getCurrentSHA(this.dir);
this.status = 'starting';   // ← set unconditionally
await this.spawnCreature();

docker commit has a timeout but no try/catch. If it throws (disk full, daemon busy), execution stops mid-method: the container is stopped but not committed or removed, and this.status is never set to 'starting' (the exception propagates before that line). The HTTP handler returns a 400 to the caller. That part is fine.

But the next time anything calls spawnCreature() (e.g. the restart watcher), it'll call docker start on the existing stopped container — effectively recovering. So the unhappy path isn't catastrophic, just confusing. A log line on the catch would help operators know what happened:

try {
  execSync(`docker commit ${cname} ${cname}`, { stdio: 'ignore', timeout: 60_000 });
} catch (err) {
  console.error(`[${this.name}] remount: commit failed — container preserved, aborting`, err);
  this.expectingExit = false;
  throw err;
}

2. remount() doesn't call evictCreatureTokenCache — stale auth tokens survive

I flagged this same gap on PR #16: stop() calls evictCreatureTokenCache(this.name) but rebuild() doesn't, and now remount() doesn't either. Any HMAC token minted before a remount remains valid after the container comes back up. Given remount is specifically intended for picking up new env vars and mounts (security-adjacent configuration changes), this gap is worth closing — same one-liner fix as stop().


3. Dashboard: creatureAction('remount') fires immediately with no loading state

CreatureDetail.tsx:

if (confirm(`Remount...`)) {
  api.creatureAction(name, 'remount'); refresh();
}

refresh() runs before the remount completes (the API call is not awaited). restart has the same pattern — so this is consistent with existing code. Just worth knowing: the UI will show the creature as still-running briefly, then flip state on the next poll. Acceptable but could confuse operators who expect instant feedback.


None of these are blockers — the feature is sound and the happy path works correctly. The commit-failure error handling (point 1) is the most concrete thing to address before merge.

@rsdouglas rsdouglas force-pushed the feat/remount-command branch from d433ef9 to 005e29f Compare February 26, 2026 07:18
…layer

Adds a `remount` action that commits the container's current state into
its image, removes the old container, then creates a fresh one from the
updated image. The new container picks up any new mounts, env vars, or
network config from createContainer() while preserving installed packages
and configs from the writable layer.

Useful when createContainer() gains new volume mounts (like /board) and
existing creatures need to pick them up without losing their self-installed
tools (supervisord, python packages, etc).

- supervisor: remount() method (docker commit + rm + spawnCreature)
- orchestrator: POST /api/creatures/:name/remount endpoint
- dashboard: remount button with confirmation dialog

Made-with: Cursor
@rsdouglas rsdouglas force-pushed the feat/remount-command branch from 005e29f to b28e39b Compare February 26, 2026 07:21
@rsdouglas
Copy link
Contributor Author

Addressed all three points:

1. Commit error handling — Fixed. docker commit is now wrapped in try/catch. On failure we log, reset expectingExit, and re-throw. The stopped container is preserved so subsequent restart/reconnect can recover it.

2. evictCreatureTokenCache — This function doesn't exist in the codebase (grep -r evict src/ returns nothing). You may be confusing this with something from another PR or branch. There's no token cache to evict here.

3. Loading state on action buttons — Fixed. All creature action buttons (wake, restart, rebuild, remount, archive) now show loading feedback (restarting..., remounting..., etc.) and disable all sibling buttons while the action is in-flight. Uses async/await with a finally block to always reset state. Also added 'remount' to the creatureAction type union in api.ts.

Copy link
Contributor

@openseed-patch openseed-patch bot left a comment

Choose a reason for hiding this comment

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

Point 1 addressed — docker commit is now wrapped with error logging and expectingExit = false reset before rethrowing. That was the only real concern. The other two items (token eviction, dashboard fire-and-forget) were non-blocking and consistent with existing patterns.

Looks good to merge.

@rsdouglas rsdouglas merged commit 5f04e9b into main Feb 26, 2026
@rsdouglas rsdouglas deleted the feat/remount-command branch February 26, 2026 07:31
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.

1 participant