@hueyexe/opencode-ensemble 0.9.0 → 0.10.0

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.
@@ -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
+ }
@@ -1,23 +1,22 @@
1
1
  import type { ToolDeps } from "../types"
2
2
  import { requireLead, checkWorktreeDirty } from "./shared"
3
3
  import type { IsDirtyFn } from "./shared"
4
+ import { preserveBranch, preservedBranchName } from "./merge-helper"
5
+ import type { PreserveBranchFn } from "./merge-helper"
6
+ import { log } from "../log"
4
7
 
5
8
  /**
6
9
  * Execute the team_shutdown tool. Requests a teammate to shut down.
7
10
  *
8
- * Graceful negotiation flow:
9
- * - If member is already shutdown_requested, treat as force (second call).
10
- * - If member is idle or force=true, abort immediately and set status='shutdown'.
11
- * - If member is busy and force=false, send a shutdown message via promptAsync
12
- * and set status='shutdown_requested'. The member finishes work and reports back.
13
- *
14
- * Warns if the member's worktree has uncommitted changes.
11
+ * Before aborting, preserves the worktree branch to a safe ref so
12
+ * session.abort() cannot destroy the agent's committed work.
15
13
  */
16
14
  export async function executeTeamShutdown(
17
15
  deps: ToolDeps,
18
16
  args: { member: string; force?: boolean },
19
17
  sessionId: string,
20
18
  isDirty: IsDirtyFn = checkWorktreeDirty,
19
+ preserve: PreserveBranchFn = preserveBranch,
21
20
  ): Promise<string> {
22
21
  const teamInfo = requireLead(deps, sessionId)
23
22
 
@@ -30,8 +29,8 @@ export async function executeTeamShutdown(
30
29
 
31
30
  // Second call on an already-requested member → force abort
32
31
  if (member.status === "shutdown_requested") {
33
- await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id)
34
- const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : ""
32
+ await preserveAndAbort(deps, teamInfo.teamId, teamInfo.teamName, args.member, member.session_id, member.worktree_branch, preserve)
33
+ const branchInfo = getBranchInfo(deps, teamInfo.teamId, args.member)
35
34
  const dirtyWarning = await getDirtyWarning(member.worktree_dir, args.member, isDirty)
36
35
  return `Force shut down "${args.member}".${branchInfo}${dirtyWarning}`
37
36
  }
@@ -47,21 +46,35 @@ export async function executeTeamShutdown(
47
46
  }
48
47
 
49
48
  if (isIdle || force) {
50
- await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id)
51
- const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : ""
49
+ await preserveAndAbort(deps, teamInfo.teamId, teamInfo.teamName, args.member, member.session_id, member.worktree_branch, preserve)
50
+ const branchInfo = getBranchInfo(deps, teamInfo.teamId, args.member)
52
51
  const dirtyWarning = await getDirtyWarning(member.worktree_dir, args.member, isDirty)
53
52
  return `Teammate "${args.member}" has been shut down.${branchInfo}${dirtyWarning}`
54
53
  }
55
54
 
56
- // Busy + not force → graceful: send shutdown message, set shutdown_requested
55
+ // Busy + not force → graceful: preserve branch first, then send shutdown message
56
+ // Branch must be preserved NOW — if the session crashes during shutdown_requested,
57
+ // the worktree and branch could be lost before force-abort ever runs.
58
+ if (member.worktree_branch) {
59
+ const safeBranch = preservedBranchName(teamInfo.teamName, args.member)
60
+ const ok = await preserve(member.worktree_branch, safeBranch, deps.directory)
61
+ if (ok) {
62
+ deps.db.run(
63
+ "UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?",
64
+ [safeBranch, teamInfo.teamId, args.member],
65
+ )
66
+ log(`shutdown:branch:preserved-graceful src=${member.worktree_branch} target=${safeBranch}`)
67
+ }
68
+ }
69
+
57
70
  try {
58
- await deps.client.session.promptAsync({
71
+ deps.client.session.promptAsync({
59
72
  sessionID: member.session_id,
60
73
  parts: [{
61
74
  type: "text",
62
75
  text: `[Shutdown requested]: The lead has requested you shut down. Finish your current task, send your final findings to the lead via team_message, then stop.`,
63
76
  }],
64
- })
77
+ }).catch(() => { /* fire-and-forget */ })
65
78
  } catch {
66
79
  // promptAsync failed — best effort
67
80
  }
@@ -75,15 +88,35 @@ export async function executeTeamShutdown(
75
88
  }
76
89
 
77
90
  /**
78
- * Abort a member's session and set their status to shutdown.
79
- * Best-effort: if abort fails, we still mark them as shutdown.
91
+ * Preserve the worktree branch, then abort the session and mark shutdown.
92
+ * The branch is copied to ensemble/preserved/{team}/{name} BEFORE abort,
93
+ * so session.abort() cannot destroy the agent's committed work.
80
94
  */
81
- async function abortAndShutdown(
95
+ async function preserveAndAbort(
82
96
  deps: ToolDeps,
83
97
  teamId: string,
98
+ teamName: string,
84
99
  memberName: string,
85
100
  sessionId: string,
101
+ worktreeBranch: string | null,
102
+ preserve: PreserveBranchFn,
86
103
  ): Promise<void> {
104
+ // Preserve the branch BEFORE aborting — session.abort() may delete the worktree + branch
105
+ if (worktreeBranch && !worktreeBranch.startsWith("ensemble/preserved/")) {
106
+ const safeBranch = preservedBranchName(teamName, memberName)
107
+ const ok = await preserve(worktreeBranch, safeBranch, deps.directory)
108
+ if (ok) {
109
+ deps.db.run(
110
+ "UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?",
111
+ [safeBranch, teamId, memberName],
112
+ )
113
+ log(`shutdown:branch:preserved src=${worktreeBranch} target=${safeBranch}`)
114
+ } else {
115
+ log(`shutdown:branch:preserve-failed src=${worktreeBranch} target=${safeBranch}`)
116
+ }
117
+ }
118
+
119
+ // Now safe to abort — the branch is preserved
87
120
  try {
88
121
  await deps.client.session.abort({ sessionID: sessionId })
89
122
  } catch {
@@ -96,6 +129,13 @@ async function abortAndShutdown(
96
129
  )
97
130
  }
98
131
 
132
+ /** Read the current branch from DB (may have been updated to preserved name). */
133
+ function getBranchInfo(deps: ToolDeps, teamId: string, memberName: string): string {
134
+ const row = deps.db.query("SELECT worktree_branch FROM team_member WHERE team_id = ? AND name = ?")
135
+ .get(teamId, memberName) as { worktree_branch: string | null } | null
136
+ return row?.worktree_branch ? ` Changes preserved on branch: ${row.worktree_branch}. Use team_merge to merge.` : ""
137
+ }
138
+
99
139
  /** Build a warning suffix if the member's worktree has uncommitted changes. */
100
140
  async function getDirtyWarning(worktreeDir: string | null, memberName: string, isDirty: IsDirtyFn): Promise<string> {
101
141
  if (!worktreeDir) return ""
package/src/watchdog.ts CHANGED
@@ -2,7 +2,9 @@ import type { Database } from "bun:sqlite"
2
2
  import type { PluginClient } from "./types"
3
3
  import type { MemberRegistry } from "./state"
4
4
  import type { ProgressTracker } from "./progress"
5
+ import { preserveBranch, preservedBranchName } from "./tools/merge-helper"
5
6
  import { sendMessage } from "./messaging"
7
+ import { log } from "./log"
6
8
 
7
9
  interface WatchdogOpts {
8
10
  db: Database
@@ -20,6 +22,8 @@ interface WatchdogOpts {
20
22
  stallMinSteps?: number
21
23
  /** Output token threshold for stall detection. */
22
24
  stallTokenThreshold?: number
25
+ /** Project directory for git operations. */
26
+ cwd?: string
23
27
  }
24
28
 
25
29
  /**
@@ -36,6 +40,7 @@ export class Watchdog {
36
40
  private readonly stallThresholdMs: number
37
41
  private readonly stallMinSteps: number
38
42
  private readonly stallTokenThreshold: number
43
+ private readonly cwd?: string
39
44
  private timer: ReturnType<typeof setInterval> | undefined
40
45
 
41
46
  constructor(opts: WatchdogOpts) {
@@ -48,6 +53,7 @@ export class Watchdog {
48
53
  this.stallThresholdMs = opts.stallThresholdMs ?? 0
49
54
  this.stallMinSteps = opts.stallMinSteps ?? 3
50
55
  this.stallTokenThreshold = opts.stallTokenThreshold ?? 500
56
+ this.cwd = opts.cwd
51
57
  }
52
58
 
53
59
  private static STALE_THRESHOLD_MS = Number(process.env.STALE_WORKTREE_THRESHOLD_MS) || 300_000
@@ -135,15 +141,26 @@ export class Watchdog {
135
141
 
136
142
  const cutoff = Date.now() - this.ttlMs
137
143
  const stale = this.db.query(
138
- `SELECT tm.team_id, tm.name, tm.session_id
144
+ `SELECT tm.team_id, tm.name, tm.session_id, tm.worktree_branch, t.name as team_name
139
145
  FROM team_member tm
140
146
  JOIN team t ON tm.team_id = t.id
141
147
  WHERE t.status = 'active'
142
148
  AND tm.status = 'busy'
143
149
  AND tm.time_updated < ?`
144
- ).all(cutoff) as Array<{ team_id: string; name: string; session_id: string }>
150
+ ).all(cutoff) as Array<{ team_id: string; name: string; session_id: string; worktree_branch: string | null; team_name: string }>
145
151
 
146
152
  for (const member of stale) {
153
+ // Preserve branch BEFORE abort — session.abort() may destroy the worktree + branch
154
+ if (this.cwd && member.worktree_branch && !member.worktree_branch.startsWith("ensemble/preserved/")) {
155
+ const safeBranch = preservedBranchName(member.team_name, member.name)
156
+ const ok = await preserveBranch(member.worktree_branch, safeBranch, this.cwd)
157
+ if (ok) {
158
+ this.db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?",
159
+ [safeBranch, member.team_id, member.name])
160
+ log(`watchdog:branch:preserved src=${member.worktree_branch} target=${safeBranch}`)
161
+ }
162
+ }
163
+
147
164
  // Mark as timed out
148
165
  this.db.run(
149
166
  "UPDATE team_member SET status = 'error', execution_status = 'timed_out', time_updated = ? WHERE team_id = ? AND name = ?",