@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
|
@@ -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
|
+
}
|
|
@@ -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
|
-
*
|
|
9
|
-
*
|
|
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
|
|
34
|
-
const branchInfo =
|
|
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
|
|
51
|
-
const branchInfo =
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
79
|
-
*
|
|
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
|
|
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 = ?",
|