@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.
- package/README.md +36 -8
- package/dist/index.js +879 -92
- package/package.json +1 -1
- package/src/config.ts +4 -0
- package/src/dashboard-html.ts +91 -0
- package/src/dashboard-js-part1.ts +97 -0
- package/src/dashboard-js-part2.ts +232 -0
- package/src/dashboard-js-part3.ts +50 -0
- package/src/dashboard.ts +166 -0
- package/src/index.ts +51 -8
- package/src/recovery.ts +69 -6
- package/src/system-prompt.ts +6 -0
- 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-shutdown.ts +57 -17
- package/src/watchdog.ts +19 -2
package/src/dashboard.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
import { DASHBOARD_HEAD } from "./dashboard-html"
|
|
3
|
+
import { DASHBOARD_JS_PART1 } from "./dashboard-js-part1"
|
|
4
|
+
import { DASHBOARD_JS_PART2 } from "./dashboard-js-part2"
|
|
5
|
+
import { DASHBOARD_JS_PART3 } from "./dashboard-js-part3"
|
|
6
|
+
import { log } from "./log"
|
|
7
|
+
|
|
8
|
+
/** Assemble the full dashboard HTML from parts. */
|
|
9
|
+
const DASHBOARD_HTML = DASHBOARD_HEAD + "\n<script>" + DASHBOARD_JS_PART1 + DASHBOARD_JS_PART2 + DASHBOARD_JS_PART3 + "<\/script>\n</body></html>"
|
|
10
|
+
|
|
11
|
+
interface TeamRow {
|
|
12
|
+
id: string
|
|
13
|
+
name: string
|
|
14
|
+
status: string
|
|
15
|
+
lead_agent: string | null
|
|
16
|
+
time_created: number
|
|
17
|
+
time_updated: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface MemberRow {
|
|
21
|
+
name: string
|
|
22
|
+
agent: string
|
|
23
|
+
status: string
|
|
24
|
+
execution_status: string
|
|
25
|
+
worktree_branch: string | null
|
|
26
|
+
prompt: string | null
|
|
27
|
+
model: string | null
|
|
28
|
+
plan_approval: string
|
|
29
|
+
time_created: number
|
|
30
|
+
time_updated: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface TaskRow {
|
|
34
|
+
id: string
|
|
35
|
+
content: string
|
|
36
|
+
status: string
|
|
37
|
+
priority: string
|
|
38
|
+
assignee: string | null
|
|
39
|
+
depends_on: string | null
|
|
40
|
+
time_created: number
|
|
41
|
+
time_updated: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface MessageRow {
|
|
45
|
+
id: string
|
|
46
|
+
from_name: string
|
|
47
|
+
to_name: string | null
|
|
48
|
+
content: string
|
|
49
|
+
delivered: number
|
|
50
|
+
read: number
|
|
51
|
+
time_created: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function jsonResponse(data: unknown, status = 200): Response {
|
|
55
|
+
return new Response(JSON.stringify(data), {
|
|
56
|
+
status,
|
|
57
|
+
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function buildState(db: Database): { teams: unknown[] } {
|
|
62
|
+
const teams = db.query("SELECT id, name, status, lead_agent, time_created, time_updated FROM team ORDER BY time_created DESC").all() as TeamRow[]
|
|
63
|
+
const memberStmt = db.query("SELECT name, agent, status, execution_status, worktree_branch, prompt, model, plan_approval, time_created, time_updated FROM team_member WHERE team_id = ?")
|
|
64
|
+
const taskStmt = db.query("SELECT id, content, status, priority, assignee, depends_on, time_created, time_updated FROM team_task WHERE team_id = ?")
|
|
65
|
+
const msgStmt = db.query("SELECT id, from_name, to_name, content, delivered, read, time_created FROM team_message WHERE team_id = ? ORDER BY time_created DESC LIMIT 50")
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
teams: teams.map((t) => ({
|
|
69
|
+
id: t.id,
|
|
70
|
+
name: t.name,
|
|
71
|
+
status: t.status,
|
|
72
|
+
leadAgent: t.lead_agent,
|
|
73
|
+
timeCreated: t.time_created,
|
|
74
|
+
timeUpdated: t.time_updated,
|
|
75
|
+
members: (memberStmt.all(t.id) as MemberRow[]).map((m) => ({
|
|
76
|
+
name: m.name,
|
|
77
|
+
agent: m.agent,
|
|
78
|
+
status: m.status,
|
|
79
|
+
executionStatus: m.execution_status,
|
|
80
|
+
worktreeBranch: m.worktree_branch,
|
|
81
|
+
prompt: m.prompt,
|
|
82
|
+
model: m.model,
|
|
83
|
+
planApproval: m.plan_approval,
|
|
84
|
+
timeCreated: m.time_created,
|
|
85
|
+
timeUpdated: m.time_updated,
|
|
86
|
+
})),
|
|
87
|
+
tasks: (taskStmt.all(t.id) as TaskRow[]).map((tk) => ({
|
|
88
|
+
id: tk.id,
|
|
89
|
+
content: tk.content,
|
|
90
|
+
status: tk.status,
|
|
91
|
+
priority: tk.priority,
|
|
92
|
+
assignee: tk.assignee,
|
|
93
|
+
dependsOn: tk.depends_on,
|
|
94
|
+
timeCreated: tk.time_created,
|
|
95
|
+
timeUpdated: tk.time_updated,
|
|
96
|
+
})),
|
|
97
|
+
messages: (msgStmt.all(t.id) as MessageRow[]).map((msg) => ({
|
|
98
|
+
id: msg.id,
|
|
99
|
+
fromName: msg.from_name,
|
|
100
|
+
toName: msg.to_name,
|
|
101
|
+
content: msg.content,
|
|
102
|
+
delivered: msg.delivered === 1,
|
|
103
|
+
read: msg.read === 1,
|
|
104
|
+
timeCreated: msg.time_created,
|
|
105
|
+
})),
|
|
106
|
+
})),
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Start the dashboard HTTP server.
|
|
112
|
+
* Serves a JSON API for team state and the dashboard HTML.
|
|
113
|
+
* Singleton: if the port is already in use by another ensemble instance, skips silently.
|
|
114
|
+
* Returns the server instance, or null if skipped.
|
|
115
|
+
*/
|
|
116
|
+
export async function startDashboard(db: Database, port: number): Promise<ReturnType<typeof Bun.serve> | null> {
|
|
117
|
+
try {
|
|
118
|
+
const server = Bun.serve({
|
|
119
|
+
port,
|
|
120
|
+
fetch(req) {
|
|
121
|
+
const url = new URL(req.url)
|
|
122
|
+
|
|
123
|
+
if (url.pathname === "/api/health") {
|
|
124
|
+
return jsonResponse({ ensemble: true, pid: process.pid })
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (url.pathname === "/api/state") {
|
|
128
|
+
return jsonResponse(buildState(db))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (url.pathname === "/") {
|
|
132
|
+
return new Response(DASHBOARD_HTML, {
|
|
133
|
+
headers: { "Content-Type": "text/html" },
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return new Response("Not Found", { status: 404 })
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
log(`dashboard:started port=${port} url=http://localhost:${port}`)
|
|
141
|
+
return server
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if (err && typeof err === "object" && "code" in err && err.code === "EADDRINUSE") {
|
|
144
|
+
try {
|
|
145
|
+
const res = await fetch(`http://localhost:${port}/api/health`)
|
|
146
|
+
const data = await res.json() as { ensemble?: boolean; pid?: number }
|
|
147
|
+
if (data.ensemble && data.pid) {
|
|
148
|
+
// Check if the other process is still alive
|
|
149
|
+
let alive = false
|
|
150
|
+
try { process.kill(data.pid, 0); alive = true } catch { /* process is dead */ }
|
|
151
|
+
if (alive && data.pid !== process.pid) {
|
|
152
|
+
log(`dashboard:already-running port=${port} pid=${data.pid}`)
|
|
153
|
+
return null
|
|
154
|
+
}
|
|
155
|
+
// Stale server from a dead process — warn the user
|
|
156
|
+
log(`dashboard:stale-server port=${port} stale-pid=${data.pid} — run: kill -9 ${data.pid} || lsof -ti:${port} | xargs kill -9`)
|
|
157
|
+
return null
|
|
158
|
+
}
|
|
159
|
+
} catch { /* health check failed — port held by something else */ }
|
|
160
|
+
log(`dashboard:port-in-use port=${port} (not an ensemble instance)`)
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
log(`dashboard:failed err=${err instanceof Error ? err.message : String(err)}`)
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
}
|
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"
|
|
@@ -15,12 +15,14 @@ import { log, initLog } from "./log"
|
|
|
15
15
|
import { findTeamBySession } from "./types"
|
|
16
16
|
import { loadConfig } from "./config"
|
|
17
17
|
import { ProgressTracker } from "./progress"
|
|
18
|
+
import { startDashboard } from "./dashboard"
|
|
18
19
|
import { executeTeamCreate } from "./tools/team-create"
|
|
19
20
|
import { executeTeamSpawn } from "./tools/team-spawn"
|
|
20
21
|
import { executeTeamMessage } from "./tools/team-message"
|
|
21
22
|
import { executeTeamBroadcast } from "./tools/team-broadcast"
|
|
22
23
|
import { executeTeamShutdown } from "./tools/team-shutdown"
|
|
23
24
|
import { executeTeamCleanup } from "./tools/team-cleanup"
|
|
25
|
+
import { executeTeamMerge } from "./tools/team-merge"
|
|
24
26
|
import { executeTeamTasksList } from "./tools/team-tasks-list"
|
|
25
27
|
import { executeTeamTasksAdd } from "./tools/team-tasks-add"
|
|
26
28
|
import { executeTeamTasksComplete } from "./tools/team-tasks-complete"
|
|
@@ -71,7 +73,7 @@ const plugin: Plugin = async (input) => {
|
|
|
71
73
|
// calls back to the server, which deadlocks because the server is still handling session.create.
|
|
72
74
|
if (!isWorktreeInstance(input.directory)) {
|
|
73
75
|
log("init:recovery:start (main instance)")
|
|
74
|
-
const recovery = await recoverStaleMembers(db, client)
|
|
76
|
+
const recovery = await recoverStaleMembers(db, client, input.directory)
|
|
75
77
|
if (recovery.interrupted > 0) {
|
|
76
78
|
log(`init:recovery:interrupted=${recovery.interrupted}`)
|
|
77
79
|
const members = db.query(
|
|
@@ -88,7 +90,17 @@ const plugin: Plugin = async (input) => {
|
|
|
88
90
|
recoverOrphanedWorktrees(db, client).catch((err) => {
|
|
89
91
|
log(`init:recover-worktrees:failed err=${err instanceof Error ? err.message : String(err)}`)
|
|
90
92
|
})
|
|
93
|
+
recoverOrphanedBranches(db, input.directory).catch((err) => {
|
|
94
|
+
log(`init:recover-branches:failed err=${err instanceof Error ? err.message : String(err)}`)
|
|
95
|
+
})
|
|
91
96
|
log("init:recovery:done")
|
|
97
|
+
|
|
98
|
+
// Start dashboard server (main instance only, not worktree instances)
|
|
99
|
+
if (config.dashboardPort !== 0) {
|
|
100
|
+
startDashboard(db, config.dashboardPort).catch((err) => {
|
|
101
|
+
log(`init:dashboard:failed err=${err instanceof Error ? err.message : String(err)}`)
|
|
102
|
+
})
|
|
103
|
+
}
|
|
92
104
|
} else {
|
|
93
105
|
log(`init:skip-recovery (worktree instance: ${input.directory})`)
|
|
94
106
|
}
|
|
@@ -109,6 +121,7 @@ const plugin: Plugin = async (input) => {
|
|
|
109
121
|
stallThresholdMs: config.stallThresholdMs,
|
|
110
122
|
stallMinSteps: config.stallMinSteps,
|
|
111
123
|
stallTokenThreshold: config.stallTokenThreshold,
|
|
124
|
+
cwd: input.directory,
|
|
112
125
|
})
|
|
113
126
|
watchdog.start()
|
|
114
127
|
|
|
@@ -150,12 +163,27 @@ const plugin: Plugin = async (input) => {
|
|
|
150
163
|
} catch { /* TUI may not be available */ }
|
|
151
164
|
} else if (transition.to === "busy_while_shutdown") {
|
|
152
165
|
// Session went busy after shutdown was requested — re-issue abort
|
|
153
|
-
|
|
154
|
-
if
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
166
|
+
// Branch should already be preserved by the graceful shutdown path,
|
|
167
|
+
// but verify and re-preserve if needed
|
|
168
|
+
const member = deps.db.query(
|
|
169
|
+
"SELECT worktree_branch, name, team_id FROM team_member WHERE session_id = ?"
|
|
170
|
+
).get(sessionID) as { worktree_branch: string | null; name: string; team_id: string } | null
|
|
171
|
+
if (member?.worktree_branch && !member.worktree_branch.startsWith("ensemble/preserved/")) {
|
|
172
|
+
const teamRow = deps.db.query("SELECT name FROM team WHERE id = ?").get(member.team_id) as { name: string } | null
|
|
173
|
+
if (teamRow) {
|
|
174
|
+
const { preserveBranch: preserve, preservedBranchName: branchName } = await import("./tools/merge-helper")
|
|
175
|
+
const safeBranch = branchName(teamRow.name, member.name)
|
|
176
|
+
const ok = await preserve(member.worktree_branch, safeBranch, deps.directory)
|
|
177
|
+
if (ok) {
|
|
178
|
+
deps.db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?",
|
|
179
|
+
[safeBranch, member.team_id, member.name])
|
|
180
|
+
log(`busy_while_shutdown:branch:preserved src=${member.worktree_branch} target=${safeBranch}`)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
158
183
|
}
|
|
184
|
+
try {
|
|
185
|
+
await client.session.abort({ sessionID })
|
|
186
|
+
} catch { /* best effort */ }
|
|
159
187
|
}
|
|
160
188
|
|
|
161
189
|
// Show working progress after every transition so the user sees who's still active
|
|
@@ -406,13 +434,28 @@ const plugin: Plugin = async (input) => {
|
|
|
406
434
|
acknowledge_uncommitted: tool.schema.boolean().default(false),
|
|
407
435
|
},
|
|
408
436
|
async execute(args, ctx) {
|
|
409
|
-
const result = await executeTeamCleanup(deps, args, ctx.sessionID, undefined, undefined, config.mergeOnCleanup)
|
|
437
|
+
const result = await executeTeamCleanup(deps, args, ctx.sessionID, undefined, undefined, undefined, config.mergeOnCleanup)
|
|
410
438
|
const blocked = result.includes("uncommitted")
|
|
411
439
|
ctx.metadata({ title: blocked ? "Cleanup blocked — uncommitted changes" : "Team cleaned up" })
|
|
412
440
|
return result
|
|
413
441
|
},
|
|
414
442
|
}),
|
|
415
443
|
|
|
444
|
+
team_merge: tool({
|
|
445
|
+
description: "Merge a shutdown teammate's branch into the working directory as unstaged changes. " +
|
|
446
|
+
"Use this after team_shutdown to review and integrate a teammate's work. " +
|
|
447
|
+
"The teammate must be shut down first.",
|
|
448
|
+
args: {
|
|
449
|
+
member: tool.schema.string().describe("Teammate name whose branch to merge"),
|
|
450
|
+
},
|
|
451
|
+
async execute(args, ctx) {
|
|
452
|
+
const result = await executeTeamMerge(deps, args, ctx.sessionID)
|
|
453
|
+
const conflict = result.includes("conflict")
|
|
454
|
+
ctx.metadata({ title: conflict ? `Merge conflict: ${args.member}` : `Merged ${args.member}` })
|
|
455
|
+
return result
|
|
456
|
+
},
|
|
457
|
+
}),
|
|
458
|
+
|
|
416
459
|
team_status: tool({
|
|
417
460
|
description: "View team members with their current status, agent type, and session IDs. " +
|
|
418
461
|
"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
|
+
}
|
package/src/system-prompt.ts
CHANGED
|
@@ -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
|
+
}
|