@hueyexe/opencode-ensemble 0.16.1 → 0.17.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/src/watchdog.ts CHANGED
@@ -3,7 +3,10 @@ import type { PluginClient } from "./types"
3
3
  import type { MemberRegistry } from "./state"
4
4
  import type { ProgressTracker } from "./progress"
5
5
  import { preserveBranch, preservedBranchName } from "./tools/merge-helper"
6
- import { sendMessage } from "./messaging"
6
+ import { releaseMemberTasks } from "./tasks"
7
+ import { hasReportedCompletion } from "./messaging"
8
+ import { getMemberModel } from "./member-model"
9
+ import { notifyLead } from "./notify"
7
10
  import { log } from "./log"
8
11
 
9
12
  interface WatchdogOpts {
@@ -112,22 +115,69 @@ export class Watchdog {
112
115
 
113
116
  if (!tokenStalled && !timeStalled) continue
114
117
 
115
- this.progressTracker.markReported(member.session_id)
118
+ // hasReportedCompletion() is the documented invariant every promptAsync delivery
119
+ // path must honor (see hooks.ts:63). The in-memory isReported() check above only
120
+ // catches this watchdog's own prior nudges — it doesn't know if the member reported
121
+ // completion by some other path while still transiently 'busy' in the DB.
122
+ if (hasReportedCompletion(this.db, member.team_id, member.name)) {
123
+ log(`watchdog:stall:skip member=${member.name} team=${member.team_id} reason=already-reported-completion`)
124
+ continue
125
+ }
126
+
116
127
  const reason = tokenStalled ? "low output tokens" : "no communication"
117
128
 
118
- // Nudge the teammate directly
129
+ // Re-nudge suppression: markReported is deferred until promptAsync delivery
130
+ // succeeds, so a persistently failing nudge (session deleted mid-run while the
131
+ // DB row stays busy, bad stored model) would otherwise re-fire this whole
132
+ // block on every tick — a fresh last_nudged_at write, lead wake, and toast
133
+ // forever. Skip while the previous nudge is recent AND the member has shown
134
+ // no activity since it; genuine activity after a nudge makes them eligible
135
+ // for a fresh evaluation (and a fresh stall verdict) immediately.
136
+ const recentNudgeRow = this.db.query(
137
+ "SELECT last_nudged_at FROM team_member WHERE team_id = ? AND name = ?"
138
+ ).get(member.team_id, member.name) as { last_nudged_at: number | null } | null
139
+ if (recentNudgeRow?.last_nudged_at) {
140
+ const nudgedAt = recentNudgeRow.last_nudged_at
141
+ // >= because Date.now() has ms resolution: activity recorded in the same
142
+ // tick as the nudge write must count as post-nudge, or a member that went
143
+ // idle, worked, and stalled again would stay suppressed forever.
144
+ const activeSinceNudge = (this.progressTracker?.lastActivityAt(member.session_id) ?? 0) >= nudgedAt
145
+ if (!activeSinceNudge && Date.now() - nudgedAt < this.stallThresholdMs) {
146
+ log(`watchdog:stall:skip member=${member.name} team=${member.team_id} reason=recently-nudged`)
147
+ continue
148
+ }
149
+ }
150
+
151
+ // Additive display-staleness signal (does not change team_member.status) — lets
152
+ // the dashboard/team_status annotate "busy, nudged Xs ago" so a soft nudge is
153
+ // visible without inventing a new status value.
154
+ this.db.run(
155
+ "UPDATE team_member SET last_nudged_at = ? WHERE team_id = ? AND name = ?",
156
+ [Date.now(), member.team_id, member.name]
157
+ )
158
+
159
+ // Nudge the teammate directly. markReported() only fires once delivery is
160
+ // confirmed — marking it before delivery is known would permanently and silently
161
+ // orphan the stall state if promptAsync throws (aborted session, invalid ID),
162
+ // since ProgressTracker.reported is in-memory and only cleared by new activity.
163
+ const stallModel = getMemberModel(this.db, member.team_id, member.name)
119
164
  this.client.session.promptAsync({
120
165
  sessionID: member.session_id,
121
166
  parts: [{ type: "text", text: "[System]: You appear stalled — no progress detected. Report your current status to the lead via team_message, or wrap up your work." }],
122
- }).catch(() => { /* best effort */ })
167
+ ...(stallModel ? { model: stallModel } : {}),
168
+ }).then(() => {
169
+ this.progressTracker!.markReported(member.session_id)
170
+ }).catch((err) => {
171
+ log(`watchdog:stall:nudge-failed member=${member.name} team=${member.team_id} session=${member.session_id} err=${err instanceof Error ? err.message : String(err)}`)
172
+ })
123
173
 
124
174
  // Notify the lead
125
- sendMessage(this.db, {
126
- teamId: member.team_id,
127
- from: "system",
128
- to: "lead",
129
- content: `Teammate "${member.name}" appears stalled (${reason}). Consider checking on them via team_message or shutting them down.`,
130
- })
175
+ notifyLead(
176
+ this.client,
177
+ this.db,
178
+ member.team_id,
179
+ `Teammate "${member.name}" appears stalled (${reason}). Consider checking on them via team_message or shutting them down.`,
180
+ )
131
181
 
132
182
  // Toast for the user
133
183
  try {
@@ -156,21 +206,31 @@ export class Watchdog {
156
206
  if (this.progressTracker.isChattyReported(member.session_id)) continue
157
207
  if (!this.progressTracker.isChatty(member.session_id, this.peerMessageLimit, this.peerMessageWindowMs)) continue
158
208
 
209
+ // hasReportedCompletion() guard — same invariant/rationale as checkStalled() above.
210
+ if (hasReportedCompletion(this.db, member.team_id, member.name)) {
211
+ log(`watchdog:chatty:skip member=${member.name} team=${member.team_id} reason=already-reported-completion`)
212
+ continue
213
+ }
214
+
159
215
  this.progressTracker.markChattyReported(member.session_id)
160
216
 
161
217
  // Nudge the agent
218
+ const chattyModel = getMemberModel(this.db, member.team_id, member.name)
162
219
  this.client.session.promptAsync({
163
220
  sessionID: member.session_id,
164
221
  parts: [{ type: "text", text: "[System]: You've sent several messages to teammates. Focus on completing your task and send your results to the lead via team_message." }],
165
- }).catch(() => { /* best effort */ })
222
+ ...(chattyModel ? { model: chattyModel } : {}),
223
+ }).catch((err) => {
224
+ log(`watchdog:chatty:nudge-failed member=${member.name} team=${member.team_id} session=${member.session_id} err=${err instanceof Error ? err.message : String(err)}`)
225
+ })
166
226
 
167
227
  // Notify the lead
168
- sendMessage(this.db, {
169
- teamId: member.team_id,
170
- from: "system",
171
- to: "lead",
172
- content: `Agent "${member.name}" is sending many peer messages and may be over-coordinating. Consider checking on them.`,
173
- })
228
+ notifyLead(
229
+ this.client,
230
+ this.db,
231
+ member.team_id,
232
+ `Agent "${member.name}" is sending many peer messages and may be over-coordinating. Consider checking on them.`,
233
+ )
174
234
 
175
235
  log(`watchdog:chatty member=${member.name} limit=${this.peerMessageLimit}`)
176
236
  }
@@ -212,6 +272,20 @@ export class Watchdog {
212
272
  [Date.now(), member.team_id, member.name]
213
273
  )
214
274
 
275
+ // Release the timed-out member's in_progress tasks back to the pool (issue #27)
276
+ const released = releaseMemberTasks(this.db, member.team_id, member.name)
277
+ if (released > 0) log(`watchdog:tasks:released name=${member.name} count=${released}`)
278
+
279
+ // Notify AND wake the lead — a timed-out teammate is otherwise a silent
280
+ // failure. This is the "leave recovery to the lead" path: we surface the
281
+ // timeout, we do not auto-resume the teammate.
282
+ notifyLead(
283
+ this.client,
284
+ this.db,
285
+ member.team_id,
286
+ `Teammate "${member.name}" timed out after exceeding the busy time limit and was aborted. Their in-progress work has been released. Review their session, then re-spawn or reassign the task if needed.`,
287
+ )
288
+
215
289
  // Abort session (best effort)
216
290
  try {
217
291
  await this.client.session.abort({ sessionID: member.session_id })