@hueyexe/opencode-ensemble 0.8.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/README.md +21 -10
- package/dist/index.js +335 -96
- package/package.json +1 -1
- package/src/index.ts +43 -8
- package/src/recovery.ts +69 -6
- package/src/result-parser.ts +40 -0
- package/src/system-prompt.ts +13 -1
- package/src/tools/merge-helper.ts +126 -0
- package/src/tools/team-cleanup.ts +44 -87
- package/src/tools/team-merge.ts +49 -0
- package/src/tools/team-results.ts +7 -2
- package/src/tools/team-shutdown.ts +57 -17
- package/src/tools/team-spawn.ts +11 -1
- package/src/watchdog.ts +19 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hueyexe/opencode-ensemble",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Agent teams for OpenCode — parallel agents with peer-to-peer communication, shared tasks, and coordinated execution",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"main": "dist/index.js",
|
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
|
-
|
|
154
|
-
if
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Structured result parsed from `<task-result>` XML. */
|
|
2
|
+
export interface TaskResult {
|
|
3
|
+
status: string
|
|
4
|
+
summary: string
|
|
5
|
+
details: string
|
|
6
|
+
branch?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse `<task-result>` XML from message content.
|
|
11
|
+
* Returns null if not found or missing required fields.
|
|
12
|
+
*/
|
|
13
|
+
export function parseTaskResult(content: string): TaskResult | null {
|
|
14
|
+
const match = content.match(/<task-result>([\s\S]*?)<\/task-result>/)
|
|
15
|
+
if (!match) return null
|
|
16
|
+
|
|
17
|
+
const inner = match[1]!
|
|
18
|
+
const status = inner.match(/<status>([\s\S]*?)<\/status>/)?.[1]?.trim()
|
|
19
|
+
const summary = inner.match(/<summary>([\s\S]*?)<\/summary>/)?.[1]?.trim()
|
|
20
|
+
const details = inner.match(/<details>([\s\S]*?)<\/details>/)?.[1]?.trim()
|
|
21
|
+
const branch = inner.match(/<branch>([\s\S]*?)<\/branch>/)?.[1]?.trim()
|
|
22
|
+
|
|
23
|
+
if (!status || !summary || !details) return null
|
|
24
|
+
|
|
25
|
+
return { status, summary, details, branch: branch || undefined }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Format a TaskResult as clean readable text.
|
|
30
|
+
*/
|
|
31
|
+
export function formatTaskResult(from: string, result: TaskResult): string {
|
|
32
|
+
const lines = [
|
|
33
|
+
`[Result from ${from}]:`,
|
|
34
|
+
` Status: ${result.status}`,
|
|
35
|
+
` Summary: ${result.summary}`,
|
|
36
|
+
` Details: ${result.details}`,
|
|
37
|
+
]
|
|
38
|
+
if (result.branch) lines.push(` Branch: ${result.branch}`)
|
|
39
|
+
return lines.join("\n")
|
|
40
|
+
}
|
package/src/system-prompt.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Database } from "bun:sqlite"
|
|
2
2
|
import { markDelivered } from "./messaging"
|
|
3
|
+
import { parseTaskResult, formatTaskResult } from "./result-parser"
|
|
3
4
|
|
|
4
5
|
/** Truncate a string to maxLen chars, appending "..." if truncated. */
|
|
5
6
|
function truncate(s: string, maxLen: number): string {
|
|
@@ -82,7 +83,12 @@ export function buildLeadSystemPrompt(db: Database, teamId: string): string {
|
|
|
82
83
|
lines.push("", "--- Team Messages ---")
|
|
83
84
|
const MAX_MSG = 500
|
|
84
85
|
for (const msg of pendingMessages) {
|
|
85
|
-
|
|
86
|
+
const parsed = parseTaskResult(msg.content)
|
|
87
|
+
if (parsed) {
|
|
88
|
+
// Truncate details for system prompt — full content available via team_results
|
|
89
|
+
const truncatedResult = { ...parsed, details: truncate(parsed.details, 500) }
|
|
90
|
+
lines.push(formatTaskResult(msg.from_name, truncatedResult))
|
|
91
|
+
} else if (msg.content.length > MAX_MSG) {
|
|
86
92
|
lines.push(`[From ${msg.from_name}]: ${msg.content.slice(0, MAX_MSG)}... (use team_results to read full message)`)
|
|
87
93
|
} else {
|
|
88
94
|
lines.push(`[From ${msg.from_name}]: ${msg.content}`)
|
|
@@ -103,6 +109,12 @@ export function buildLeadSystemPrompt(db: Database, teamId: string): string {
|
|
|
103
109
|
"After spawning all teammates, tell the user what you've set up and wait.",
|
|
104
110
|
"When all teammates finish, summarize results and suggest next steps.",
|
|
105
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
|
+
"",
|
|
106
118
|
"Before calling team_cleanup, verify teammates have committed their work.",
|
|
107
119
|
"team_shutdown will warn you if a teammate has uncommitted changes.",
|
|
108
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
|
-
*
|
|
65
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
+
}
|