Skip to content

Commit ab8e2a2

Browse files
aronchickclaude
andauthored
feat: add repo hibernate command to archive and stop work (#315)
* feat: add repo hibernate command to archive and stop work Adds `multiclaude repo hibernate` command that cleanly stops all work in a repository while preserving uncommitted changes: - Archives uncommitted changes as patch files to ~/.multiclaude/archive/<repo>/<timestamp>/ - Saves metadata (branch, task, worktree path) for each agent - Lists untracked files separately for manual restoration - Stops workers and review agents by default (--all for persistent agents) - Force-removes worktrees after archiving to ensure clean shutdown Also adds ArchiveDir to Paths config and updates all test files to include it. Usage: multiclaude repo hibernate [--repo <repo>] [--all] [--yes] Co-Authored-By: Claude Opus 4.5 <[email protected]> * fix: address lint issues in hibernate command - Check error return from client.Send (errcheck) - Fix formatting with gofmt Co-Authored-By: Claude Opus 4.5 <[email protected]> --------- Co-authored-by: Claude Opus 4.5 <[email protected]>
1 parent 9112b94 commit ab8e2a2

12 files changed

Lines changed: 277 additions & 1 deletion

File tree

internal/bugreport/collector_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ func TestCollector_Collect(t *testing.T) {
3131
MessagesDir: filepath.Join(tmpDir, "messages"),
3232
OutputDir: filepath.Join(tmpDir, "output"),
3333
ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"),
34+
ArchiveDir: filepath.Join(tmpDir, "archive"),
3435
}
3536

3637
// Create a test state file
@@ -123,6 +124,7 @@ func TestCollector_CollectVerbose(t *testing.T) {
123124
MessagesDir: filepath.Join(tmpDir, "messages"),
124125
OutputDir: filepath.Join(tmpDir, "output"),
125126
ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"),
127+
ArchiveDir: filepath.Join(tmpDir, "archive"),
126128
}
127129

128130
// Create a test state file with multiple repos

internal/cli/cli.go

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,13 @@ func (c *CLI) registerCommands() {
426426
Run: c.showHistory,
427427
}
428428

429+
repoCmd.Subcommands["hibernate"] = &Command{
430+
Name: "hibernate",
431+
Description: "Hibernate a repository, archiving uncommitted changes",
432+
Usage: "multiclaude repo hibernate [--repo <repo>] [--all] [--yes]",
433+
Run: c.hibernateRepo,
434+
}
435+
429436
c.rootCmd.Subcommands["repo"] = repoCmd
430437

431438
// Backward compatibility aliases for root-level repo commands
@@ -2859,6 +2866,245 @@ func (c *CLI) removeWorker(args []string) error {
28592866
return nil
28602867
}
28612868

2869+
// hibernateRepo stops all work in a repository and archives uncommitted changes
2870+
func (c *CLI) hibernateRepo(args []string) error {
2871+
flags, _ := ParseFlags(args)
2872+
skipConfirm := flags["yes"] == "true"
2873+
hibernateAll := flags["all"] == "true" // Also hibernate persistent agents (supervisor, workspace)
2874+
2875+
// Determine repository
2876+
repoName, err := c.resolveRepo(flags)
2877+
if err != nil {
2878+
return errors.NotInRepo()
2879+
}
2880+
2881+
// Get agent list from daemon
2882+
client := socket.NewClient(c.paths.DaemonSock)
2883+
resp, err := client.Send(socket.Request{
2884+
Command: "list_agents",
2885+
Args: map[string]interface{}{
2886+
"repo": repoName,
2887+
},
2888+
})
2889+
if err != nil {
2890+
return errors.DaemonCommunicationFailed("getting agent info", err)
2891+
}
2892+
if !resp.Success {
2893+
return errors.Wrap(errors.CategoryRuntime, "failed to get agent info", fmt.Errorf("%s", resp.Error))
2894+
}
2895+
2896+
agents, _ := resp.Data.([]interface{})
2897+
if len(agents) == 0 {
2898+
fmt.Printf("No agents running in repository '%s'\n", repoName)
2899+
return nil
2900+
}
2901+
2902+
// Filter agents to hibernate (workers, review agents; optionally all)
2903+
var agentsToHibernate []map[string]interface{}
2904+
var agentsWithChanges []map[string]interface{}
2905+
2906+
for _, agent := range agents {
2907+
agentMap, ok := agent.(map[string]interface{})
2908+
if !ok {
2909+
continue
2910+
}
2911+
2912+
agentType, _ := agentMap["type"].(string)
2913+
wtPath, _ := agentMap["worktree_path"].(string)
2914+
2915+
// Determine if this agent should be hibernated
2916+
shouldHibernate := false
2917+
switch agentType {
2918+
case "worker", "review":
2919+
shouldHibernate = true
2920+
case "supervisor", "merge-queue", "pr-shepherd", "workspace", "generic-persistent":
2921+
shouldHibernate = hibernateAll
2922+
}
2923+
2924+
if !shouldHibernate {
2925+
continue
2926+
}
2927+
2928+
agentsToHibernate = append(agentsToHibernate, agentMap)
2929+
2930+
// Check for uncommitted changes
2931+
if wtPath != "" {
2932+
hasUncommitted, err := worktree.HasUncommittedChanges(wtPath)
2933+
if err == nil && hasUncommitted {
2934+
agentsWithChanges = append(agentsWithChanges, agentMap)
2935+
}
2936+
}
2937+
}
2938+
2939+
if len(agentsToHibernate) == 0 {
2940+
fmt.Printf("No agents to hibernate in repository '%s'\n", repoName)
2941+
if !hibernateAll {
2942+
fmt.Println("Use --all to also hibernate persistent agents (supervisor, workspace, etc.)")
2943+
}
2944+
return nil
2945+
}
2946+
2947+
// Show summary and confirm
2948+
fmt.Printf("Hibernating %d agent(s) in repository '%s':\n", len(agentsToHibernate), repoName)
2949+
for _, agent := range agentsToHibernate {
2950+
name, _ := agent["name"].(string)
2951+
agentType, _ := agent["type"].(string)
2952+
hasChanges := false
2953+
for _, changed := range agentsWithChanges {
2954+
if changed["name"] == name {
2955+
hasChanges = true
2956+
break
2957+
}
2958+
}
2959+
changeMarker := ""
2960+
if hasChanges {
2961+
changeMarker = " [has uncommitted changes]"
2962+
}
2963+
fmt.Printf(" - %s (%s)%s\n", name, agentType, changeMarker)
2964+
}
2965+
2966+
if len(agentsWithChanges) > 0 {
2967+
fmt.Printf("\n%d agent(s) have uncommitted changes that will be archived.\n", len(agentsWithChanges))
2968+
}
2969+
2970+
if !skipConfirm {
2971+
fmt.Print("\nContinue? [y/N]: ")
2972+
var response string
2973+
fmt.Scanln(&response)
2974+
if response != "y" && response != "Y" {
2975+
fmt.Println("Cancelled")
2976+
return nil
2977+
}
2978+
}
2979+
2980+
// Create archive directory with timestamp
2981+
timestamp := time.Now().Format("2006-01-02_15-04-05")
2982+
archiveDir := filepath.Join(c.paths.RepoArchiveDir(repoName), timestamp)
2983+
if len(agentsWithChanges) > 0 {
2984+
if err := os.MkdirAll(archiveDir, 0755); err != nil {
2985+
return fmt.Errorf("failed to create archive directory: %w", err)
2986+
}
2987+
fmt.Printf("\nArchiving to: %s\n", archiveDir)
2988+
}
2989+
2990+
// Archive uncommitted changes
2991+
var archivedAgents []string
2992+
for _, agent := range agentsWithChanges {
2993+
name, _ := agent["name"].(string)
2994+
wtPath, _ := agent["worktree_path"].(string)
2995+
branch, _ := agent["branch"].(string)
2996+
task, _ := agent["task"].(string)
2997+
2998+
fmt.Printf("Archiving changes from %s...\n", name)
2999+
3000+
// Create patch file with git diff
3001+
patchPath := filepath.Join(archiveDir, name+".patch")
3002+
cmd := exec.Command("git", "diff", "HEAD")
3003+
cmd.Dir = wtPath
3004+
output, err := cmd.Output()
3005+
if err != nil {
3006+
fmt.Printf("Warning: failed to create patch for %s: %v\n", name, err)
3007+
continue
3008+
}
3009+
3010+
// Include untracked files in the patch
3011+
untrackedCmd := exec.Command("git", "ls-files", "--others", "--exclude-standard")
3012+
untrackedCmd.Dir = wtPath
3013+
untrackedOutput, _ := untrackedCmd.Output()
3014+
3015+
// Write patch file
3016+
if err := os.WriteFile(patchPath, output, 0644); err != nil {
3017+
fmt.Printf("Warning: failed to write patch for %s: %v\n", name, err)
3018+
continue
3019+
}
3020+
3021+
// Write untracked files list if any
3022+
if len(untrackedOutput) > 0 {
3023+
untrackedPath := filepath.Join(archiveDir, name+".untracked")
3024+
os.WriteFile(untrackedPath, untrackedOutput, 0644)
3025+
}
3026+
3027+
// Write metadata for this agent
3028+
metaPath := filepath.Join(archiveDir, name+".json")
3029+
meta := map[string]interface{}{
3030+
"name": name,
3031+
"type": agent["type"],
3032+
"branch": branch,
3033+
"task": task,
3034+
"worktree_path": wtPath,
3035+
"archived_at": time.Now().Format(time.RFC3339),
3036+
}
3037+
metaData, _ := json.MarshalIndent(meta, "", " ")
3038+
os.WriteFile(metaPath, metaData, 0644)
3039+
3040+
archivedAgents = append(archivedAgents, name)
3041+
}
3042+
3043+
// Write summary metadata
3044+
if len(agentsWithChanges) > 0 {
3045+
summaryPath := filepath.Join(archiveDir, "hibernate-summary.json")
3046+
summary := map[string]interface{}{
3047+
"repo": repoName,
3048+
"hibernated_at": time.Now().Format(time.RFC3339),
3049+
"agents_hibernated": len(agentsToHibernate),
3050+
"agents_archived": archivedAgents,
3051+
}
3052+
summaryData, _ := json.MarshalIndent(summary, "", " ")
3053+
os.WriteFile(summaryPath, summaryData, 0644)
3054+
}
3055+
3056+
// Stop agents
3057+
tmuxSession := sanitizeTmuxSessionName(repoName)
3058+
repoPath := c.paths.RepoDir(repoName)
3059+
wt := worktree.NewManager(repoPath)
3060+
3061+
fmt.Println()
3062+
for _, agent := range agentsToHibernate {
3063+
name, _ := agent["name"].(string)
3064+
wtPath, _ := agent["worktree_path"].(string)
3065+
tmuxWindow, _ := agent["tmux_window"].(string)
3066+
3067+
fmt.Printf("Stopping %s...\n", name)
3068+
3069+
// Kill tmux window
3070+
if tmuxWindow != "" {
3071+
cmd := exec.Command("tmux", "kill-window", "-t", fmt.Sprintf("%s:%s", tmuxSession, tmuxWindow))
3072+
cmd.Run() // Ignore errors
3073+
}
3074+
3075+
// Remove worktree (force since we archived changes)
3076+
if wtPath != "" {
3077+
if err := wt.Remove(wtPath, true); err != nil {
3078+
// Try harder with force
3079+
cmd := exec.Command("git", "worktree", "remove", "--force", wtPath)
3080+
cmd.Dir = repoPath
3081+
cmd.Run()
3082+
}
3083+
}
3084+
3085+
// Unregister from daemon (ignore errors during cleanup)
3086+
_, _ = client.Send(socket.Request{
3087+
Command: "remove_agent",
3088+
Args: map[string]interface{}{
3089+
"repo": repoName,
3090+
"agent": name,
3091+
},
3092+
})
3093+
}
3094+
3095+
fmt.Println()
3096+
fmt.Printf("✓ Hibernated %d agent(s) in '%s'\n", len(agentsToHibernate), repoName)
3097+
if len(archivedAgents) > 0 {
3098+
fmt.Printf("✓ Archived %d agent(s) with uncommitted changes to:\n", len(archivedAgents))
3099+
fmt.Printf(" %s\n", archiveDir)
3100+
fmt.Println("\nTo restore archived patches:")
3101+
fmt.Println(" cd <worktree>")
3102+
fmt.Printf(" git apply %s/<agent>.patch\n", archiveDir)
3103+
}
3104+
3105+
return nil
3106+
}
3107+
28623108
// Workspace command implementations
28633109

28643110
// workspaceDefault handles `multiclaude workspace` with no subcommand or `multiclaude workspace <name>`

internal/cli/cli_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,7 @@ func setupTestEnvironment(t *testing.T) (*CLI, *daemon.Daemon, func()) {
316316
MessagesDir: filepath.Join(tmpDir, "messages"),
317317
OutputDir: filepath.Join(tmpDir, "output"),
318318
ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"),
319+
ArchiveDir: filepath.Join(tmpDir, "archive"),
319320
}
320321

321322
if err := paths.EnsureDirectories(); err != nil {
@@ -662,6 +663,7 @@ func TestCLISendMessageFallbackWhenDaemonUnavailable(t *testing.T) {
662663
MessagesDir: filepath.Join(tmpDir, "messages"),
663664
OutputDir: filepath.Join(tmpDir, "output"),
664665
ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"),
666+
ArchiveDir: filepath.Join(tmpDir, "archive"),
665667
}
666668

667669
if err := paths.EnsureDirectories(); err != nil {
@@ -1044,6 +1046,7 @@ func TestNewWithPaths(t *testing.T) {
10441046
MessagesDir: filepath.Join(tmpDir, "messages"),
10451047
OutputDir: filepath.Join(tmpDir, "output"),
10461048
ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"),
1049+
ArchiveDir: filepath.Join(tmpDir, "archive"),
10471050
}
10481051

10491052
// Test CLI creation

internal/daemon/daemon_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ func setupTestDaemon(t *testing.T) (*Daemon, func()) {
3939
MessagesDir: filepath.Join(tmpDir, "messages"),
4040
OutputDir: filepath.Join(tmpDir, "output"),
4141
ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"),
42+
ArchiveDir: filepath.Join(tmpDir, "archive"),
4243
}
4344

4445
// Create directories

internal/daemon/handlers_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ func setupTestDaemonWithState(t *testing.T, setupFn func(*state.State)) (*Daemon
3333
MessagesDir: filepath.Join(tmpDir, "messages"),
3434
OutputDir: filepath.Join(tmpDir, "output"),
3535
ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"),
36+
ArchiveDir: filepath.Join(tmpDir, "archive"),
3637
}
3738

3839
if err := paths.EnsureDirectories(); err != nil {

internal/daemon/worktree_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ func setupTestDaemonWithGitRepo(t *testing.T) (*Daemon, string, func()) {
7272
MessagesDir: filepath.Join(tmpDir, "messages"),
7373
OutputDir: filepath.Join(tmpDir, "output"),
7474
ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"),
75+
ArchiveDir: filepath.Join(tmpDir, "archive"),
7576
}
7677

7778
// Create directories

pkg/config/config.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type Paths struct {
1919
MessagesDir string // messages/
2020
OutputDir string // output/
2121
ClaudeConfigDir string // claude-config/
22+
ArchiveDir string // archive/ (for paused work)
2223
}
2324

2425
// DefaultPaths returns the default paths for multiclaude
@@ -41,6 +42,7 @@ func DefaultPaths() (*Paths, error) {
4142
MessagesDir: filepath.Join(root, "messages"),
4243
OutputDir: filepath.Join(root, "output"),
4344
ClaudeConfigDir: filepath.Join(root, "claude-config"),
45+
ArchiveDir: filepath.Join(root, "archive"),
4446
}, nil
4547
}
4648

@@ -53,6 +55,7 @@ func (p *Paths) EnsureDirectories() error {
5355
p.MessagesDir,
5456
p.OutputDir,
5557
p.ClaudeConfigDir,
58+
p.ArchiveDir,
5659
}
5760

5861
for _, dir := range dirs {
@@ -138,5 +141,11 @@ func NewTestPaths(tmpDir string) *Paths {
138141
MessagesDir: filepath.Join(tmpDir, "messages"),
139142
OutputDir: filepath.Join(tmpDir, "output"),
140143
ClaudeConfigDir: filepath.Join(tmpDir, "claude-config"),
144+
ArchiveDir: filepath.Join(tmpDir, "archive"),
141145
}
142146
}
147+
148+
// RepoArchiveDir returns the path for a repository's archived work
149+
func (p *Paths) RepoArchiveDir(repoName string) string {
150+
return filepath.Join(p.ArchiveDir, repoName)
151+
}

pkg/config/config_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,15 @@ func TestEnsureDirectories(t *testing.T) {
6060
MessagesDir: filepath.Join(tmpDir, "test-multiclaude", "messages"),
6161
OutputDir: filepath.Join(tmpDir, "test-multiclaude", "output"),
6262
ClaudeConfigDir: filepath.Join(tmpDir, "test-multiclaude", "claude-config"),
63+
ArchiveDir: filepath.Join(tmpDir, "test-multiclaude", "archive"),
6364
}
6465

6566
if err := paths.EnsureDirectories(); err != nil {
6667
t.Fatalf("EnsureDirectories() failed: %v", err)
6768
}
6869

6970
// Verify directories were created
70-
dirs := []string{paths.Root, paths.ReposDir, paths.WorktreesDir, paths.MessagesDir, paths.OutputDir, paths.ClaudeConfigDir}
71+
dirs := []string{paths.Root, paths.ReposDir, paths.WorktreesDir, paths.MessagesDir, paths.OutputDir, paths.ClaudeConfigDir, paths.ArchiveDir}
7172
for _, dir := range dirs {
7273
if _, err := os.Stat(dir); os.IsNotExist(err) {
7374
t.Errorf("Directory not created: %s", dir)

0 commit comments

Comments
 (0)