@hueyexe/opencode-ensemble 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@ import path from "node:path"
5
5
  import { mkdirSync } from "node:fs"
6
6
  import { createDb, getDbPath } from "./db"
7
7
  import { wrapThrowingClient } from "./client"
8
- import { recoverStaleMembers, recoverUndeliveredMessages, recoverOrphanedWorktrees } from "./recovery"
8
+ import { recoverStaleMembers, recoverUndeliveredMessages, recoverOrphanedWorktrees, recoverOrphanedBranches } from "./recovery"
9
9
  import { MemberRegistry, DescendantTracker } from "./state"
10
10
  import { isWorktreeInstance } from "./util"
11
11
  import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation, shouldNudgeIdleMember } from "./hooks"
@@ -21,6 +21,7 @@ import { executeTeamMessage } from "./tools/team-message"
21
21
  import { executeTeamBroadcast } from "./tools/team-broadcast"
22
22
  import { executeTeamShutdown } from "./tools/team-shutdown"
23
23
  import { executeTeamCleanup } from "./tools/team-cleanup"
24
+ import { executeTeamMerge } from "./tools/team-merge"
24
25
  import { executeTeamTasksList } from "./tools/team-tasks-list"
25
26
  import { executeTeamTasksAdd } from "./tools/team-tasks-add"
26
27
  import { executeTeamTasksComplete } from "./tools/team-tasks-complete"
@@ -71,7 +72,7 @@ const plugin: Plugin = async (input) => {
71
72
  // calls back to the server, which deadlocks because the server is still handling session.create.
72
73
  if (!isWorktreeInstance(input.directory)) {
73
74
  log("init:recovery:start (main instance)")
74
- const recovery = await recoverStaleMembers(db, client)
75
+ const recovery = await recoverStaleMembers(db, client, input.directory)
75
76
  if (recovery.interrupted > 0) {
76
77
  log(`init:recovery:interrupted=${recovery.interrupted}`)
77
78
  const members = db.query(
@@ -88,6 +89,9 @@ const plugin: Plugin = async (input) => {
88
89
  recoverOrphanedWorktrees(db, client).catch((err) => {
89
90
  log(`init:recover-worktrees:failed err=${err instanceof Error ? err.message : String(err)}`)
90
91
  })
92
+ recoverOrphanedBranches(db, input.directory).catch((err) => {
93
+ log(`init:recover-branches:failed err=${err instanceof Error ? err.message : String(err)}`)
94
+ })
91
95
  log("init:recovery:done")
92
96
  } else {
93
97
  log(`init:skip-recovery (worktree instance: ${input.directory})`)
@@ -109,6 +113,7 @@ const plugin: Plugin = async (input) => {
109
113
  stallThresholdMs: config.stallThresholdMs,
110
114
  stallMinSteps: config.stallMinSteps,
111
115
  stallTokenThreshold: config.stallTokenThreshold,
116
+ cwd: input.directory,
112
117
  })
113
118
  watchdog.start()
114
119
 
@@ -150,12 +155,27 @@ const plugin: Plugin = async (input) => {
150
155
  } catch { /* TUI may not be available */ }
151
156
  } else if (transition.to === "busy_while_shutdown") {
152
157
  // Session went busy after shutdown was requested — re-issue abort
153
- const entry = registry.getBySession(sessionID)
154
- if (entry) {
155
- try {
156
- await client.session.abort({ sessionID })
157
- } catch { /* best effort */ }
158
+ // Branch should already be preserved by the graceful shutdown path,
159
+ // but verify and re-preserve if needed
160
+ const member = deps.db.query(
161
+ "SELECT worktree_branch, name, team_id FROM team_member WHERE session_id = ?"
162
+ ).get(sessionID) as { worktree_branch: string | null; name: string; team_id: string } | null
163
+ if (member?.worktree_branch && !member.worktree_branch.startsWith("ensemble/preserved/")) {
164
+ const teamRow = deps.db.query("SELECT name FROM team WHERE id = ?").get(member.team_id) as { name: string } | null
165
+ if (teamRow) {
166
+ const { preserveBranch: preserve, preservedBranchName: branchName } = await import("./tools/merge-helper")
167
+ const safeBranch = branchName(teamRow.name, member.name)
168
+ const ok = await preserve(member.worktree_branch, safeBranch, deps.directory)
169
+ if (ok) {
170
+ deps.db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?",
171
+ [safeBranch, member.team_id, member.name])
172
+ log(`busy_while_shutdown:branch:preserved src=${member.worktree_branch} target=${safeBranch}`)
173
+ }
174
+ }
158
175
  }
176
+ try {
177
+ await client.session.abort({ sessionID })
178
+ } catch { /* best effort */ }
159
179
  }
160
180
 
161
181
  // Show working progress after every transition so the user sees who's still active
@@ -406,13 +426,28 @@ const plugin: Plugin = async (input) => {
406
426
  acknowledge_uncommitted: tool.schema.boolean().default(false),
407
427
  },
408
428
  async execute(args, ctx) {
409
- const result = await executeTeamCleanup(deps, args, ctx.sessionID, undefined, undefined, config.mergeOnCleanup)
429
+ const result = await executeTeamCleanup(deps, args, ctx.sessionID, undefined, undefined, undefined, config.mergeOnCleanup)
410
430
  const blocked = result.includes("uncommitted")
411
431
  ctx.metadata({ title: blocked ? "Cleanup blocked — uncommitted changes" : "Team cleaned up" })
412
432
  return result
413
433
  },
414
434
  }),
415
435
 
436
+ team_merge: tool({
437
+ description: "Merge a shutdown teammate's branch into the working directory as unstaged changes. " +
438
+ "Use this after team_shutdown to review and integrate a teammate's work. " +
439
+ "The teammate must be shut down first.",
440
+ args: {
441
+ member: tool.schema.string().describe("Teammate name whose branch to merge"),
442
+ },
443
+ async execute(args, ctx) {
444
+ const result = await executeTeamMerge(deps, args, ctx.sessionID)
445
+ const conflict = result.includes("conflict")
446
+ ctx.metadata({ title: conflict ? `Merge conflict: ${args.member}` : `Merged ${args.member}` })
447
+ return result
448
+ },
449
+ }),
450
+
416
451
  team_status: tool({
417
452
  description: "View team members with their current status, agent type, and session IDs. " +
418
453
  "Use this to check who is working, idle, or shut down. Includes a task summary.",
package/src/recovery.ts CHANGED
@@ -2,21 +2,24 @@ import type { Database } from "bun:sqlite"
2
2
  import type { PluginClient } from "./types"
3
3
  import type { MemberRegistry } from "./state"
4
4
  import { getUndeliveredMessages, markDelivered } from "./messaging"
5
+ import { preserveBranch, preservedBranchName } from "./tools/merge-helper"
6
+ import { log } from "./log"
5
7
 
6
8
  /**
7
9
  * Scan for team members stuck in 'busy' status (stale from a crash)
8
10
  * and mark them as 'error' with execution_status 'idle'.
9
- * Also aborts their orphaned sessions (best effort).
11
+ * Preserves worktree branches before aborting orphaned sessions.
10
12
  * Only processes members in active teams.
11
13
  * Returns the count of interrupted members.
12
14
  */
13
- export async function recoverStaleMembers(db: Database, client?: PluginClient): Promise<{ interrupted: number }> {
14
- // Find stale members before updating so we can abort their sessions
15
+ export async function recoverStaleMembers(db: Database, client?: PluginClient, cwd?: string): Promise<{ interrupted: number }> {
16
+ // Find stale members with branch info so we can preserve before aborting
15
17
  const stale = db.query(
16
- `SELECT tm.session_id FROM team_member tm
18
+ `SELECT tm.session_id, tm.worktree_branch, tm.name, tm.team_id, t.name as team_name
19
+ FROM team_member tm
17
20
  JOIN team t ON tm.team_id = t.id
18
21
  WHERE tm.status = 'busy' AND t.status = 'active'`
19
- ).all() as Array<{ session_id: string }>
22
+ ).all() as Array<{ session_id: string; worktree_branch: string | null; name: string; team_id: string; team_name: string }>
20
23
 
21
24
  const result = db.run(
22
25
  `UPDATE team_member SET status = 'error', execution_status = 'idle', time_updated = ?
@@ -25,9 +28,19 @@ export async function recoverStaleMembers(db: Database, client?: PluginClient):
25
28
  [Date.now()]
26
29
  )
27
30
 
28
- // Abort orphaned sessions (best effort)
31
+ // Preserve branches then abort orphaned sessions
29
32
  if (client) {
30
33
  for (const member of stale) {
34
+ // Preserve branch BEFORE abort — session.abort() may destroy the worktree + branch
35
+ if (cwd && member.worktree_branch && !member.worktree_branch.startsWith("ensemble/preserved/")) {
36
+ const safeBranch = preservedBranchName(member.team_name, member.name)
37
+ const ok = await preserveBranch(member.worktree_branch, safeBranch, cwd)
38
+ if (ok) {
39
+ db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?",
40
+ [safeBranch, member.team_id, member.name])
41
+ log(`recovery:branch:preserved src=${member.worktree_branch} target=${safeBranch}`)
42
+ }
43
+ }
31
44
  try {
32
45
  await client.session.abort({ sessionID: member.session_id })
33
46
  } catch { /* best effort */ }
@@ -125,3 +138,53 @@ export async function recoverUndeliveredMessages(
125
138
 
126
139
  return { redelivered }
127
140
  }
141
+
142
+ /**
143
+ * Clean up orphaned ensemble/preserved/* branches that belong to archived teams
144
+ * with no active members. Scoped carefully to avoid interfering with other
145
+ * running OpenCode sessions that may have active teams.
146
+ */
147
+ export async function recoverOrphanedBranches(db: Database, cwd: string): Promise<{ removed: number }> {
148
+ let removed = 0
149
+
150
+ // Get archived team names that have NO active members
151
+ const archivedTeams = db.query(
152
+ `SELECT t.name FROM team t
153
+ WHERE t.status = 'archived'
154
+ AND NOT EXISTS (
155
+ SELECT 1 FROM team_member tm
156
+ WHERE tm.team_id = t.id AND tm.status NOT IN ('shutdown', 'error')
157
+ )`
158
+ ).all() as Array<{ name: string }>
159
+
160
+ if (archivedTeams.length === 0) return { removed: 0 }
161
+
162
+ const archivedNames = new Set(archivedTeams.map(t => t.name))
163
+
164
+ // List all local branches matching ensemble/preserved/*
165
+ const proc = Bun.spawn(["git", "branch", "--list", "ensemble/preserved/*"], { cwd, stdout: "pipe", stderr: "pipe" })
166
+ const stdoutPromise = new Response(proc.stdout).text()
167
+ await proc.exited
168
+ const stdout = await stdoutPromise
169
+
170
+ const branches = stdout.split("\n").map(b => b.trim().replace(/^\* /, "")).filter(Boolean)
171
+
172
+ for (const branch of branches) {
173
+ // Parse team name from branch: ensemble/preserved/{teamName}/{memberName}
174
+ const parts = branch.split("/")
175
+ if (parts.length < 4) continue
176
+ const teamName = parts[2]
177
+ if (!teamName || !archivedNames.has(teamName)) continue
178
+
179
+ try {
180
+ const del = Bun.spawn(["git", "branch", "-D", branch], { cwd, stdout: "pipe", stderr: "pipe" })
181
+ const exitCode = await del.exited
182
+ if (exitCode === 0) {
183
+ removed++
184
+ log(`recovery:branch:deleted branch=${branch}`)
185
+ }
186
+ } catch { /* best effort */ }
187
+ }
188
+
189
+ return { removed }
190
+ }
@@ -109,6 +109,12 @@ export function buildLeadSystemPrompt(db: Database, teamId: string): string {
109
109
  "After spawning all teammates, tell the user what you've set up and wait.",
110
110
  "When all teammates finish, summarize results and suggest next steps.",
111
111
  "",
112
+ "MERGE WORKFLOW:",
113
+ "After a teammate finishes and you shut them down, use team_merge to merge their branch.",
114
+ "Do NOT tell teammates to commit — they handle that themselves.",
115
+ "Do NOT run git merge manually — use team_merge which handles stashing and unstaging.",
116
+ "team_cleanup will safety-net merge any branches you forgot, but prefer explicit team_merge.",
117
+ "",
112
118
  "Before calling team_cleanup, verify teammates have committed their work.",
113
119
  "team_shutdown will warn you if a teammate has uncommitted changes.",
114
120
  "team_cleanup will block if any worktree has uncommitted changes — merge or commit first.",
@@ -0,0 +1,126 @@
1
+ import { log } from "../log"
2
+
3
+ /** Result of merging a single branch. */
4
+ export interface MergeResult {
5
+ ok: boolean
6
+ error?: string
7
+ }
8
+
9
+ /** Injectable function for testing. */
10
+ export type MergeBranchFn = (branch: string, cwd: string) => Promise<MergeResult>
11
+
12
+ /** Injectable function for preserving a branch before worktree deletion. */
13
+ export type PreserveBranchFn = (sourceBranch: string, targetBranch: string, cwd: string) => Promise<boolean>
14
+
15
+ /** Injectable function for deleting a branch. */
16
+ export type DeleteBranchFn = (branch: string, cwd: string) => Promise<boolean>
17
+
18
+ /**
19
+ * Copy a git branch to a new ref. Used to preserve worktree branches
20
+ * before session.abort() which may delete the worktree and its branch.
21
+ * Returns true if the branch was successfully copied.
22
+ */
23
+ export async function preserveBranch(sourceBranch: string, targetBranch: string, cwd: string): Promise<boolean> {
24
+ const proc = Bun.spawn(["git", "branch", targetBranch, sourceBranch], { cwd, stdout: "pipe", stderr: "pipe" })
25
+ const exit = await proc.exited
26
+ if (exit !== 0) {
27
+ const stderr = await new Response(proc.stderr).text()
28
+ log(`merge-helper:preserve:failed src=${sourceBranch} target=${targetBranch} err=${stderr.trim()}`)
29
+ return false
30
+ }
31
+ return true
32
+ }
33
+
34
+ /**
35
+ * Delete a git branch. Returns true if successful.
36
+ */
37
+ export async function deleteBranch(branch: string, cwd: string): Promise<boolean> {
38
+ const proc = Bun.spawn(["git", "branch", "-D", branch], { cwd, stdout: "pipe", stderr: "pipe" })
39
+ const exit = await proc.exited
40
+ if (exit !== 0) {
41
+ log(`merge-helper:delete:failed branch=${branch}`)
42
+ return false
43
+ }
44
+ return true
45
+ }
46
+
47
+ /**
48
+ * Raw squash merge of a single branch. No stash/pop — caller handles that.
49
+ * Used by mergeBranch (single) and mergeMultipleBranches (batch).
50
+ */
51
+ export async function mergeBranchRaw(branch: string, cwd: string): Promise<MergeResult> {
52
+ const merge = Bun.spawn(["git", "merge", "--squash", branch], { cwd, stdout: "pipe", stderr: "pipe" })
53
+ const stderrPromise = new Response(merge.stderr).text()
54
+ const mergeExit = await merge.exited
55
+
56
+ if (mergeExit !== 0) {
57
+ const stderr = await stderrPromise
58
+ log(`merge-helper:merge:conflict branch=${branch} err=${stderr.trim()}`)
59
+ const abort = Bun.spawn(["git", "merge", "--abort"], { cwd, stdout: "pipe", stderr: "pipe" })
60
+ await abort.exited
61
+ return { ok: false, error: stderr.trim() || `merge exited with code ${mergeExit}` }
62
+ }
63
+
64
+ return { ok: true }
65
+ }
66
+
67
+ /** Stash existing work. Returns true if something was stashed. */
68
+ export async function gitStash(cwd: string): Promise<boolean> {
69
+ const stash = Bun.spawn(["git", "stash", "--include-untracked"], { cwd, stdout: "pipe", stderr: "pipe" })
70
+ const stashOut = await new Response(stash.stdout).text()
71
+ const stashExit = await stash.exited
72
+ return stashExit === 0 && !stashOut.includes("No local changes")
73
+ }
74
+
75
+ /** Unstage all changes. */
76
+ export async function gitReset(cwd: string): Promise<void> {
77
+ const reset = Bun.spawn(["git", "reset", "HEAD"], { cwd, stdout: "pipe", stderr: "pipe" })
78
+ await reset.exited
79
+ }
80
+
81
+ /** Restore stashed work, preserving staged/unstaged state. Returns true if successful. */
82
+ export async function gitStashPop(cwd: string): Promise<boolean> {
83
+ const pop = Bun.spawn(["git", "stash", "pop", "--index"], { cwd, stdout: "pipe", stderr: "pipe" })
84
+ const exit = await pop.exited
85
+ if (exit !== 0) {
86
+ // --index is strict about staged/unstaged distinction — fall back to plain pop
87
+ const fallback = Bun.spawn(["git", "stash", "pop"], { cwd, stdout: "pipe", stderr: "pipe" })
88
+ const fallbackExit = await fallback.exited
89
+ if (fallbackExit !== 0) {
90
+ log("merge-helper:stash-pop:failed — stashed work remains in git stash list")
91
+ return false
92
+ }
93
+ }
94
+ return true
95
+ }
96
+
97
+ /**
98
+ * Stash-safe squash merge of a single branch into the working directory.
99
+ * Stashes existing work, merges, unstages, restores stash.
100
+ */
101
+ export async function mergeBranch(branch: string, cwd: string): Promise<MergeResult> {
102
+ const didStash = await gitStash(cwd)
103
+
104
+ const result = await mergeBranchRaw(branch, cwd)
105
+ if (!result.ok) {
106
+ if (didStash) {
107
+ const restored = await gitStashPop(cwd)
108
+ if (!restored) return { ok: false, error: `${result.error}. WARNING: stashed work could not be restored — check git stash list` }
109
+ }
110
+ return result
111
+ }
112
+
113
+ await gitReset(cwd)
114
+ if (didStash) {
115
+ const restored = await gitStashPop(cwd)
116
+ if (!restored) return { ok: true, error: "Merge succeeded but stashed work could not be restored — check git stash list" }
117
+ }
118
+ return { ok: true }
119
+ }
120
+
121
+ /**
122
+ * Build the preserved branch name for a team member.
123
+ */
124
+ export function preservedBranchName(teamName: string, memberName: string): string {
125
+ return `ensemble/preserved/${teamName}/${memberName}`
126
+ }
@@ -2,75 +2,22 @@ import type { ToolDeps } from "../types"
2
2
  import { requireLead, checkWorktreeDirty } from "./shared"
3
3
  import type { IsDirtyFn } from "./shared"
4
4
  import { spawnFailures } from "./team-spawn"
5
+ import { mergeBranch, deleteBranch, preserveBranch, preservedBranchName } from "./merge-helper"
6
+ import type { MergeBranchFn, DeleteBranchFn, PreserveBranchFn } from "./merge-helper"
5
7
  import { log } from "../log"
6
8
 
7
- /** Result of merging all branches. */
8
- export interface MergeAllResult {
9
- merged: string[]
10
- conflicted: string[]
11
- }
12
-
13
- /** Injectable function that merges all branches into the working directory. */
14
- export type MergeAllFn = (branches: string[], cwd: string) => Promise<MergeAllResult>
15
-
16
- /**
17
- * Default merge implementation: stash user work, squash-merge each branch,
18
- * unstage merged changes, restore user work. One stash/pop cycle for all branches.
19
- */
20
- export async function mergeAllBranches(branches: string[], cwd: string): Promise<MergeAllResult> {
21
- const merged: string[] = []
22
- const conflicted: string[] = []
23
-
24
- // Stash existing work to preserve staged/unstaged state
25
- const stash = Bun.spawn(["git", "stash", "--include-untracked"], { cwd, stdout: "pipe", stderr: "pipe" })
26
- const stashOut = await new Response(stash.stdout).text()
27
- const stashExit = await stash.exited
28
- const didStash = stashExit === 0 && !stashOut.includes("No local changes")
29
-
30
- for (const branch of branches) {
31
- const merge = Bun.spawn(["git", "merge", "--squash", branch], { cwd, stdout: "pipe", stderr: "pipe" })
32
- const stderrPromise = new Response(merge.stderr).text()
33
- const exit = await merge.exited
34
- if (exit !== 0) {
35
- const stderr = await stderrPromise
36
- log(`cleanup:merge:conflict branch=${branch} err=${stderr.trim()}`)
37
- const abort = Bun.spawn(["git", "merge", "--abort"], { cwd, stdout: "pipe", stderr: "pipe" })
38
- await abort.exited
39
- conflicted.push(branch)
40
- } else {
41
- const del = Bun.spawn(["git", "branch", "-D", branch], { cwd, stdout: "pipe", stderr: "pipe" })
42
- await del.exited
43
- merged.push(branch)
44
- }
45
- }
46
-
47
- // Unstage merged changes so user reviews with `git diff`
48
- if (merged.length > 0) {
49
- const reset = Bun.spawn(["git", "reset", "HEAD"], { cwd, stdout: "pipe", stderr: "pipe" })
50
- await reset.exited
51
- }
52
-
53
- // Restore user's original staged/unstaged work
54
- if (didStash) {
55
- const pop = Bun.spawn(["git", "stash", "pop", "--index"], { cwd, stdout: "pipe", stderr: "pipe" })
56
- await pop.exited
57
- }
58
-
59
- return { merged, conflicted }
60
- }
61
-
62
9
  /**
63
10
  * Execute the team_cleanup tool. Archives the team and cleans up resources.
64
- * Checks for uncommitted changes BEFORE aborting sessions or removing worktrees.
65
- * If force=true, aborts active sessions but still blocks on dirty worktrees.
66
- * Pass acknowledge_uncommitted=true to remove dirty worktrees.
11
+ * Acts as a safety net: merges any remaining unmerged preserved branches
12
+ * that the lead forgot to merge with team_merge.
67
13
  */
68
14
  export async function executeTeamCleanup(
69
15
  deps: ToolDeps,
70
16
  args: { force: boolean; acknowledge_uncommitted?: boolean },
71
17
  sessionId: string,
72
18
  isDirty: IsDirtyFn = checkWorktreeDirty,
73
- mergeAll: MergeAllFn = mergeAllBranches,
19
+ merge: MergeBranchFn = mergeBranch,
20
+ delBranch: DeleteBranchFn = deleteBranch,
74
21
  mergeOnCleanup = true,
75
22
  ): Promise<string> {
76
23
  const teamInfo = requireLead(deps, sessionId)
@@ -85,7 +32,7 @@ export async function executeTeamCleanup(
85
32
  throw new Error(`Cannot clean up team "${teamInfo.teamName}": ${active.length} member(s) still active: ${names}. Use team_shutdown on each member first, or call team_cleanup with force: true to abort them immediately.`)
86
33
  }
87
34
 
88
- // Check for uncommitted changes BEFORE aborting sessions — agents can still commit if warned
35
+ // Check for uncommitted changes BEFORE aborting sessions
89
36
  if (!args.acknowledge_uncommitted) {
90
37
  const dirty: Array<{ name: string; branch: string }> = []
91
38
  for (const member of members) {
@@ -99,53 +46,64 @@ export async function executeTeamCleanup(
99
46
  }
100
47
  }
101
48
  }
102
-
103
49
  if (dirty.length > 0) {
104
50
  const warnings = dirty.map(d => ` - ${d.name} (branch: ${d.branch})`).join("\n")
105
51
  return `Warning: ${dirty.length} teammate(s) have uncommitted changes in their worktrees:\n${warnings}\n\nCommit or merge their work first, then call team_cleanup with acknowledge_uncommitted: true to proceed.`
106
52
  }
107
53
  }
108
54
 
109
- // Force-abort active members (only reached if worktrees are clean or acknowledged)
55
+ // Force-abort active members — preserve branches BEFORE aborting
110
56
  if (args.force) {
111
57
  for (const member of active) {
58
+ // Preserve branch before abort — session.abort() may destroy the worktree + branch
59
+ if (member.worktree_branch && !member.worktree_branch.startsWith("ensemble/preserved/")) {
60
+ const safeBranch = preservedBranchName(teamInfo.teamName, member.name)
61
+ const ok = await preserveBranch(member.worktree_branch, safeBranch, deps.directory)
62
+ if (ok) {
63
+ deps.db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?",
64
+ [safeBranch, teamInfo.teamId, member.name])
65
+ member.worktree_branch = safeBranch
66
+ }
67
+ }
112
68
  try {
113
69
  await deps.client.session.abort({ sessionID: member.session_id })
114
70
  } catch { /* best effort */ }
115
71
  }
116
72
  }
117
73
 
118
- // Collect branches and merge BEFORE removing worktrees (worktree.remove may delete the branch ref)
119
- const branches = members
120
- .map(m => m.worktree_branch)
121
- .filter((b): b is string => b !== null)
74
+ // Safety net: merge any remaining unmerged preserved branches
75
+ const unmerged = members.filter(m => m.worktree_branch !== null)
76
+ const merged: string[] = []
77
+ const conflicted: string[] = []
122
78
 
123
- let mergeResult = ""
124
- if (branches.length > 0 && mergeOnCleanup) {
125
- const { merged, conflicted } = await mergeAll(branches, deps.directory)
126
- const parts: string[] = []
127
- if (merged.length > 0) {
128
- parts.push(`Merged ${merged.length} branch(es) into working directory (unstaged). Review changes with: git diff`)
129
- }
130
- if (conflicted.length > 0) {
131
- parts.push(`Could not auto-merge: ${conflicted.join(", ")}. Merge manually with: git merge <branch>`)
79
+ if (unmerged.length > 0 && mergeOnCleanup) {
80
+ for (const member of unmerged) {
81
+ const branch = member.worktree_branch!
82
+ const result = await merge(branch, deps.directory)
83
+ if (result.ok) {
84
+ await delBranch(branch, deps.directory)
85
+ deps.db.run("UPDATE team_member SET worktree_branch = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, member.name])
86
+ merged.push(`${member.name} (${branch})`)
87
+ } else {
88
+ log(`cleanup:merge:conflict member=${member.name} branch=${branch} err=${result.error}`)
89
+ conflicted.push(`${member.name} (${branch})`)
90
+ }
132
91
  }
133
- mergeResult = parts.join("\n")
134
92
  }
135
93
 
136
- // Remove workspaces and worktrees (after merging)
94
+ // Remove workspaces and worktrees
137
95
  for (const member of members) {
138
96
  if (member.workspace_id) {
139
97
  try {
140
98
  await deps.client.workspace.remove({ id: member.workspace_id })
141
99
  deps.db.run("UPDATE team_member SET workspace_id = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, member.name])
142
- } catch { /* best effort — workspace may already be gone */ }
100
+ } catch { /* best effort */ }
143
101
  }
144
102
  if (member.worktree_dir) {
145
103
  try {
146
104
  await deps.client.worktree.remove({ worktreeRemoveInput: { directory: member.worktree_dir } })
147
105
  deps.db.run("UPDATE team_member SET worktree_dir = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, member.name])
148
- } catch { /* best effort — worktree may already be gone */ }
106
+ } catch { /* best effort */ }
149
107
  }
150
108
  }
151
109
 
@@ -156,14 +114,13 @@ export async function executeTeamCleanup(
156
114
  deps.registry.unregisterTeam(teamInfo.teamId)
157
115
  spawnFailures.delete(teamInfo.teamId)
158
116
 
159
- if (mergeResult) {
160
- return `Team "${teamInfo.teamName}" cleaned up.\n${mergeResult}`
117
+ // Build response
118
+ const parts: string[] = [`Team "${teamInfo.teamName}" cleaned up.`]
119
+ if (merged.length > 0) {
120
+ parts.push(`Safety-net merged ${merged.length} unmerged branch(es): ${merged.join(", ")}. Review with: git diff`)
161
121
  }
162
-
163
- if (branches.length > 0) {
164
- const mergeCommands = branches.map(b => ` git merge ${b}`).join("\n")
165
- return `Team "${teamInfo.teamName}" cleaned up. Worktrees removed.\n\nMerge teammate branches:\n${mergeCommands}`
122
+ if (conflicted.length > 0) {
123
+ parts.push(`Could not auto-merge: ${conflicted.join(", ")}. Merge manually.`)
166
124
  }
167
-
168
- return `Team "${teamInfo.teamName}" cleaned up.`
125
+ return parts.join("\n")
169
126
  }
@@ -0,0 +1,49 @@
1
+ import type { ToolDeps } from "../types"
2
+ import { requireLead } from "./shared"
3
+ import { mergeBranch, deleteBranch } from "./merge-helper"
4
+ import type { MergeBranchFn, DeleteBranchFn } from "./merge-helper"
5
+ import { log } from "../log"
6
+
7
+ /**
8
+ * Execute the team_merge tool. Merges a shutdown teammate's preserved
9
+ * branch into the working directory as unstaged changes.
10
+ */
11
+ export async function executeTeamMerge(
12
+ deps: ToolDeps,
13
+ args: { member: string },
14
+ sessionId: string,
15
+ merge: MergeBranchFn = mergeBranch,
16
+ delBranch: DeleteBranchFn = deleteBranch,
17
+ ): Promise<string> {
18
+ const teamInfo = requireLead(deps, sessionId)
19
+
20
+ const member = deps.db.query("SELECT status, worktree_branch FROM team_member WHERE team_id = ? AND name = ?")
21
+ .get(teamInfo.teamId, args.member) as { status: string; worktree_branch: string | null } | null
22
+ if (!member) throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`)
23
+
24
+ if (member.status !== "shutdown" && member.status !== "error") {
25
+ throw new Error(`Teammate "${args.member}" is still active (status: ${member.status}). Shut them down first with team_shutdown.`)
26
+ }
27
+
28
+ if (!member.worktree_branch) {
29
+ throw new Error(`No branch to merge for "${args.member}". They may not have a worktree, or their work was already merged.`)
30
+ }
31
+
32
+ const branch = member.worktree_branch
33
+ log(`merge:start member=${args.member} branch=${branch}`)
34
+
35
+ const result = await merge(branch, deps.directory)
36
+ if (!result.ok) {
37
+ return `Merge conflict with ${args.member}'s branch (${branch}). Resolve manually:\n git merge --squash ${branch}\n\nError: ${result.error}`
38
+ }
39
+
40
+ // Merge succeeded — delete the preserved branch and clear DB
41
+ await delBranch(branch, deps.directory)
42
+ deps.db.run(
43
+ "UPDATE team_member SET worktree_branch = NULL WHERE team_id = ? AND name = ?",
44
+ [teamInfo.teamId, args.member],
45
+ )
46
+
47
+ log(`merge:done member=${args.member} branch=${branch}`)
48
+ return `Merged ${args.member}'s changes into working directory (unstaged). Review with: git diff`
49
+ }