Skip to content

feat(sandbox): auto-save sandbox work to the remote - #5400

Open
pedrofrxncx wants to merge 2 commits into
mainfrom
claude/sandbox-autocommit
Open

feat(sandbox): auto-save sandbox work to the remote#5400
pedrofrxncx wants to merge 2 commits into
mainfrom
claude/sandbox-autocommit

Conversation

@pedrofrxncx

@pedrofrxncx pedrofrxncx commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

The daemon only synced the sandbox working tree in shutdown(). Any ungraceful exit — SIGKILL on pod eviction, OOM, node loss, grace period elapsing before the push finishes — took the agent's work with it.

This adds AutoCommitter (daemon/git/auto-commit.ts): every 30s, if the tree is dirty or the branch has unpushed commits, commit and push. On by default, opt out with git.autoCommit: false in the daemon config (validated, works via PUT /_sandbox/config or a tenant-committed .decocms/daemon.json).

Reuse over reinvention

publish() is split into its local half and its network half so the loop shares the rules instead of doing a second blind add/commit/push:

  • stageAndCommit() — staging + commit, incl. the protected-branch refusal, the invalid-decofile-block net (onInvalidBlock: "skip", same as the shutdown sync) and the org-fs .git/info/exclude filtering.
  • ensureOriginPushable() — credentialed origin + the tokenless-GitHub guard.
  • publish() is now those two plus pushBranch(); behavior unchanged.
  • pushBranchAsync() — new: the loop's push goes through gitAsync so it never blocks the daemon's single event loop long enough to miss a health probe (Studio tears a sandbox down on one missed probe). Local git calls stay sync, matching BranchStatusMonitor's existing 3s poll.

Behavior notes

  • Never pushes to a protected branch (checked before the commit, so no stray commit either) and never force-pushes — an autosave must not clobber a diverged origin/<branch>, same reasoning as the shutdown sync. A rejected push is left for the interactive publish to reconcile.
  • Failures are logged and retried on the next tick; the shutdown publish remains the backstop.
  • The trigger reads BranchStatusMonitor's already-computed meta, so a clean tree costs zero git calls, and boot-dirty dev-server noise doesn't trigger a tick. The commit itself still stages everything dirty (identical to the shutdown sync) — carrying a little noise beats dropping the user's work.
  • unpushed > 0 is a trigger too, so commits the agent made itself via bash also get pushed.
  • Overlapping ticks are latched, and shutdown() stops the loop before its own publish so the two can't race for index.lock.

Testing

  • New daemon/git/auto-commit.test.ts: shouldAutoCommit decision table, plus two real-git tests against a bare file:// origin — a dirty tree ends up on the remote branch, and a disabled committer touches nothing.
  • bun test packages/sandbox/ → 878 pass / 0 fail, including the daemon e2e suite booting the rebuilt binary with the loop wired.
  • bun run check, bun run lint (0 errors), bun run fmt, knip clean.

Skipped

No Studio-side UI/org-flag to toggle it — the daemon config field is the switch; add a surface when someone asks for one.


Summary by cubic

Auto-save sandbox work to the remote a few seconds after repo writes settle, with a 30s floor, to prevent data loss on ungraceful exits. Enabled by default, never pushes protected branches, and can be disabled with git.autoCommit: false.

  • New Features

    • Added AutoCommitter that saves on change (5s trailing debounce) and on a 30s interval; commits and pushes when the tree is dirty or has unpushed commits, using async push to keep the daemon responsive.
    • Config toggle git.autoCommit (validated) via PUT /_sandbox/config or a tenant .decocms/daemon.json.
    • Skips protected branches and never force-pushes; logs failures and retries; shutdown publish remains the backstop.
  • Refactors

    • Split publish() into stageAndCommit and ensureOriginPushable to reuse staging rules; added pushBranchAsync for non-blocking pushes.
    • Integrated start/stop into the daemon lifecycle, wired to the repo write funnel, and latched overlapping ticks; reuses BranchStatusMonitor metadata to avoid extra git calls.
    • Updated README and added tests for decision logic, debounce behavior, and real git flow.

Written for commit f474f81. Summary will update on new commits.

Review in cubic

Pedro França added 2 commits July 29, 2026 18:50
The daemon only synced the working tree from shutdown(), so any ungraceful
exit — SIGKILL on pod eviction, OOM, node loss — took the agent's work with
it. Add a 30s loop that commits and pushes work in progress.

Reuses publish()'s local half (split out as stageAndCommit +
ensureOriginPushable) so the loop inherits the protected-branch refusal, the
invalid-decofile-block skip and credentialed origin setup instead of a second
blind add/commit/push. The push uses a new async twin so it never blocks the
daemon's single event loop long enough to miss a health probe.

On by default; opt out with `git.autoCommit: false` in the daemon config.
Make the file-change signal the primary trigger: every repo write nudges the
auto-committer, which saves 5s after the writes settle. The 30s interval stays
as the floor — the debounce alone can't be trusted (continuous writing keeps
resetting it, fs.watch has gaps, and `bash` commits produce no file event).

A trailing debounce rather than a save per write: one commit+push per file
would push every few hundred ms during a tool loop, and a push to a user's
repo can fan out into their CI.
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