@hueyexe/opencode-ensemble 0.9.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,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 ""
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 = ?",