Skip to content

remo-tart: rebuild detection + lifecycle hook layering (devcontainer-style) #68

@yi-jiang-applovin

Description

@yi-jiang-applovin

Motivation

After #66/#67 land, remo-tart has two states from the orchestrator's
perspective:

That's coarse. Real-world workflows have a third case: the user changed
project.toml or a pack script
, and the running VM is now stale even
though mount_matches is True. Today nothing surfaces this — packs are
idempotent so they'd no-op for already-installed tools, but adding a
new pack (e.g. the uv pack a user just appended to packs.enabled)
won't run until the VM is bootout-ed or recreated. The user has to know
to do that manually.

Docker / devcontainer solve the same problem with two ideas worth
stealing:

  1. Configuration hash drives rebuild detection. devcontainer CLI
    hashes devcontainer.json + Dockerfile + features lockfile and
    compares against the hash recorded when the container was last
    created. Mismatch → "Configuration changed, rebuild?" banner.
  2. Lifecycle hooks fan out to different cadences. onCreateCommand
    (once per container), updateContentCommand (when content changes),
    postCreateCommand (after create), postStartCommand (every start),
    postAttachCommand (every attach). One blob of "provision" gets
    sliced so expensive things run once, cheap things run often.

Proposal

Phase 1 — config-hash rebuild detection

Extend ensure_attached to compare a hash of the live config against
what was used last time provision succeeded:

# tools/remo-tart/src/remo_tart/provision.py (rough sketch)
def config_hash(project: ProjectConfig, packs_dir_host: Path) -> str:
    h = hashlib.sha256()
    h.update(json.dumps(project.model_dump(), sort_keys=True).encode())
    for pack in sorted(project.packs):
        h.update((packs_dir_host / f"{pack}.sh").read_bytes())
    h.update((packs_dir_host / "_lib.sh").read_bytes())
    h.update((repo_root / project.scripts.provision).read_bytes())
    return h.hexdigest()

# In ensure_attached, replace the current Fix #3 gate with:
last = (paths.state_dir(pool.name) / f"{pool.name}.provisioned-hash").read_text(...)
current = config_hash(project, repo_root / ".tart" / "packs")
if current != last or any(a != Action.NOTHING for a in actions):
    provision.run_provision(...)
    write(paths.state_dir / f"{pool.name}.provisioned-hash", current)

Effect: a user appending "uv" to packs.enabled and running up
again gets the new pack installed automatically — without bootout, without
tart delete. The state file lives next to the manifest so it's per-VM
and survives up cycles.

Open question: should config drift trigger Action.UPDATE_AND_REPROVISION
(implicit) or print a "config changed, will reprovision" line and proceed
(explicit)? Devcontainer requires user confirmation; tart's tighter
coupling to a single user's machine probably tolerates implicit.

Phase 2 — lifecycle hook layering

Today everything is "provision". Split the project config into:

[scripts]
post_create = ".tart/post-create.sh"   # once per CREATE; mint bootstrap, plugin install
post_start  = ".tart/post-start.sh"    # every START; light env setup
post_attach = ".tart/post-attach.sh"   # every ensure_attached; null currently
verify_worktree = ".tart/verify-worktree.sh"   # explicit, doctor-ish

Backwards compat: legacy [scripts] provision = ... maps to
post_create. verify_worktree stays.

Maps onto current actions:

Action Runs
CREATE packs ensure → post_create → post_start → post_attach
START packs ensure → post_start → post_attach (no post_create)
UPDATE_MOUNT_AND_RESTART (same as START — VM was stopped to update)
ATTACH_MOUNT_AND_START (same as START)
NOTHING (config drift detected) packs ensure → post_attach
NOTHING (no drift) nothing

This makes the mint bootstrap / claude plugin install cost paid once
(post_create), while a cheap ~/.zshrc fixup or cargo build
incremental rebuild can live in post_start without doubling attach time.

Phase 3 — surface drift in status and doctor

  • remo-tart status shows provisioned: hash<8 chars> and an
    "out of date" marker if config has drifted since last provision.
  • remo-tart doctor adds a check for "config changed since last
    provision" — informational, not a hard failure.

Phase 4 — --no-provision / --reprovision escape hatches

  • --no-provision for up — skip even when drift detected (debug case).
  • remo-tart reprovision [--full] — explicit reprovision command,
    doesn't restart the VM.

Why this is the right scope for one issue

These four phases map to ~four PRs and can land independently:

  1. Hash-based drift detection (small, behavior-preserving for current
    users, immediate UX win for users adding packs).
  2. Lifecycle hook split (config schema change, needs migration logic
    and docs).
  3. Status/doctor surface (small, follows from Implement Remo: iOS remote inspection & control tool (Lookin-like) #1).
  4. CLI flags (small, polish).

Phase 1 is the most valuable on its own and could be merged before the
rest is designed.

Relationship to #66 / #67

#67 closes the "provision never runs" gap. This issue closes the
"provision runs at the wrong granularity" gap that opens up once #67
lands. Together they make remo-tart up a real Docker-like
self-healing primitive instead of a "fingers crossed, did this rebuild?"
escape hatch.

I'm happy to PR Phase 1 if there's interest in the direction.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions