@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.
@@ -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) => `[Message from ${r.from_name}]:\n${r.content}\n`).join("\n")
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
- * Graceful negotiation flow:
9
- * - If member is already shutdown_requested, treat as force (second call).
10
- * - If member is idle or force=true, abort immediately and set status='shutdown'.
11
- * - If member is busy and force=false, send a shutdown message via promptAsync
12
- * and set status='shutdown_requested'. The member finishes work and reports back.
13
- *
14
- * Warns if the member's worktree has uncommitted changes.
11
+ * Before aborting, preserves the worktree branch to a safe ref so
12
+ * session.abort() cannot destroy the agent's committed work.
15
13
  */
16
14
  export async function executeTeamShutdown(
17
15
  deps: ToolDeps,
18
16
  args: { member: string; force?: boolean },
19
17
  sessionId: string,
20
18
  isDirty: IsDirtyFn = checkWorktreeDirty,
19
+ preserve: PreserveBranchFn = preserveBranch,
21
20
  ): Promise<string> {
22
21
  const teamInfo = requireLead(deps, sessionId)
23
22
 
@@ -30,8 +29,8 @@ export async function executeTeamShutdown(
30
29
 
31
30
  // Second call on an already-requested member → force abort
32
31
  if (member.status === "shutdown_requested") {
33
- await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id)
34
- const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : ""
32
+ await preserveAndAbort(deps, teamInfo.teamId, teamInfo.teamName, args.member, member.session_id, member.worktree_branch, preserve)
33
+ const branchInfo = getBranchInfo(deps, teamInfo.teamId, args.member)
35
34
  const dirtyWarning = await getDirtyWarning(member.worktree_dir, args.member, isDirty)
36
35
  return `Force shut down "${args.member}".${branchInfo}${dirtyWarning}`
37
36
  }
@@ -47,21 +46,35 @@ export async function executeTeamShutdown(
47
46
  }
48
47
 
49
48
  if (isIdle || force) {
50
- await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id)
51
- const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : ""
49
+ await preserveAndAbort(deps, teamInfo.teamId, teamInfo.teamName, args.member, member.session_id, member.worktree_branch, preserve)
50
+ const branchInfo = getBranchInfo(deps, teamInfo.teamId, args.member)
52
51
  const dirtyWarning = await getDirtyWarning(member.worktree_dir, args.member, isDirty)
53
52
  return `Teammate "${args.member}" has been shut down.${branchInfo}${dirtyWarning}`
54
53
  }
55
54
 
56
- // Busy + not force → graceful: send shutdown message, set shutdown_requested
55
+ // Busy + not force → graceful: preserve branch first, then send shutdown message
56
+ // Branch must be preserved NOW — if the session crashes during shutdown_requested,
57
+ // the worktree and branch could be lost before force-abort ever runs.
58
+ if (member.worktree_branch) {
59
+ const safeBranch = preservedBranchName(teamInfo.teamName, args.member)
60
+ const ok = await preserve(member.worktree_branch, safeBranch, deps.directory)
61
+ if (ok) {
62
+ deps.db.run(
63
+ "UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?",
64
+ [safeBranch, teamInfo.teamId, args.member],
65
+ )
66
+ log(`shutdown:branch:preserved-graceful src=${member.worktree_branch} target=${safeBranch}`)
67
+ }
68
+ }
69
+
57
70
  try {
58
- await deps.client.session.promptAsync({
71
+ deps.client.session.promptAsync({
59
72
  sessionID: member.session_id,
60
73
  parts: [{
61
74
  type: "text",
62
75
  text: `[Shutdown requested]: The lead has requested you shut down. Finish your current task, send your final findings to the lead via team_message, then stop.`,
63
76
  }],
64
- })
77
+ }).catch(() => { /* fire-and-forget */ })
65
78
  } catch {
66
79
  // promptAsync failed — best effort
67
80
  }
@@ -75,15 +88,35 @@ export async function executeTeamShutdown(
75
88
  }
76
89
 
77
90
  /**
78
- * Abort a member's session and set their status to shutdown.
79
- * Best-effort: if abort fails, we still mark them as shutdown.
91
+ * Preserve the worktree branch, then abort the session and mark shutdown.
92
+ * The branch is copied to ensemble/preserved/{team}/{name} BEFORE abort,
93
+ * so session.abort() cannot destroy the agent's committed work.
80
94
  */
81
- async function abortAndShutdown(
95
+ async function preserveAndAbort(
82
96
  deps: ToolDeps,
83
97
  teamId: string,
98
+ teamName: string,
84
99
  memberName: string,
85
100
  sessionId: string,
101
+ worktreeBranch: string | null,
102
+ preserve: PreserveBranchFn,
86
103
  ): Promise<void> {
104
+ // Preserve the branch BEFORE aborting — session.abort() may delete the worktree + branch
105
+ if (worktreeBranch && !worktreeBranch.startsWith("ensemble/preserved/")) {
106
+ const safeBranch = preservedBranchName(teamName, memberName)
107
+ const ok = await preserve(worktreeBranch, safeBranch, deps.directory)
108
+ if (ok) {
109
+ deps.db.run(
110
+ "UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?",
111
+ [safeBranch, teamId, memberName],
112
+ )
113
+ log(`shutdown:branch:preserved src=${worktreeBranch} target=${safeBranch}`)
114
+ } else {
115
+ log(`shutdown:branch:preserve-failed src=${worktreeBranch} target=${safeBranch}`)
116
+ }
117
+ }
118
+
119
+ // Now safe to abort — the branch is preserved
87
120
  try {
88
121
  await deps.client.session.abort({ sessionID: sessionId })
89
122
  } catch {
@@ -96,6 +129,13 @@ async function abortAndShutdown(
96
129
  )
97
130
  }
98
131
 
132
+ /** Read the current branch from DB (may have been updated to preserved name). */
133
+ function getBranchInfo(deps: ToolDeps, teamId: string, memberName: string): string {
134
+ const row = deps.db.query("SELECT worktree_branch FROM team_member WHERE team_id = ? AND name = ?")
135
+ .get(teamId, memberName) as { worktree_branch: string | null } | null
136
+ return row?.worktree_branch ? ` Changes preserved on branch: ${row.worktree_branch}. Use team_merge to merge.` : ""
137
+ }
138
+
99
139
  /** Build a warning suffix if the member's worktree has uncommitted changes. */
100
140
  async function getDirtyWarning(worktreeDir: string | null, memberName: string, isDirty: IsDirtyFn): Promise<string> {
101
141
  if (!worktreeDir) return ""
@@ -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, send ONE message to the lead describing the specific blocker.",
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 = ?",