@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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
2
|
import { requireTeamMember } from "./shared"
|
|
3
|
+
import { parseTaskResult, formatTaskResult } from "../result-parser"
|
|
3
4
|
|
|
4
5
|
/** Row shape for unread messages query. */
|
|
5
6
|
interface UnreadMessageRow {
|
|
@@ -39,6 +40,10 @@ export async function executeTeamResults(
|
|
|
39
40
|
const placeholders = ids.map(() => "?").join(", ")
|
|
40
41
|
deps.db.run(`UPDATE team_message SET read = 1 WHERE id IN (${placeholders})`, ids)
|
|
41
42
|
|
|
42
|
-
// Format output
|
|
43
|
-
return rows.map((r) =>
|
|
43
|
+
// Format output — parse structured task results when present
|
|
44
|
+
return rows.map((r) => {
|
|
45
|
+
const parsed = parseTaskResult(r.content)
|
|
46
|
+
if (parsed) return formatTaskResult(r.from_name, parsed)
|
|
47
|
+
return `[Message from ${r.from_name}]:\n${r.content}`
|
|
48
|
+
}).join("\n\n")
|
|
44
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/tools/team-spawn.ts
CHANGED
|
@@ -197,6 +197,14 @@ export async function executeTeamSpawn(
|
|
|
197
197
|
`Your agent type is "${args.agent}".`,
|
|
198
198
|
]
|
|
199
199
|
|
|
200
|
+
// Show other teammates so this agent knows who to message
|
|
201
|
+
const otherMembers = deps.db.query(
|
|
202
|
+
"SELECT name FROM team_member WHERE team_id = ? AND name != ? AND status NOT IN ('shutdown', 'error')"
|
|
203
|
+
).all(teamInfo.teamId, args.name) as Array<{ name: string }>
|
|
204
|
+
if (otherMembers.length > 0) {
|
|
205
|
+
context.push(`Other teammates: ${otherMembers.map(m => m.name).join(", ")}`)
|
|
206
|
+
}
|
|
207
|
+
|
|
200
208
|
if (worktreeBranch && worktreeDir && !workspaceId) {
|
|
201
209
|
// Workspace binding failed — fallback to prompt-based CWD instruction
|
|
202
210
|
context.push(
|
|
@@ -276,7 +284,9 @@ export async function executeTeamSpawn(
|
|
|
276
284
|
context.push(
|
|
277
285
|
`${lastStep}. STOP. Do not send follow-up confirmations, status updates, or 'standing by' messages.`,
|
|
278
286
|
"",
|
|
279
|
-
"If you are blocked
|
|
287
|
+
"If you are blocked:",
|
|
288
|
+
"- Send ONE message to the lead via team_message describing the specific blocker.",
|
|
289
|
+
"- Do NOT attempt workarounds or make assumptions. Wait for the lead's response.",
|
|
280
290
|
"",
|
|
281
291
|
"Your plain text output is NOT visible to the team. You MUST use team_message to communicate.",
|
|
282
292
|
)
|
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 = ?",
|