@hueyexe/opencode-ensemble 0.7.1 → 0.8.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
@@ -1,6 +1,8 @@
1
1
  import type { Database } from "bun:sqlite"
2
2
  import type { PluginClient } from "./types"
3
3
  import type { MemberRegistry } from "./state"
4
+ import type { ProgressTracker } from "./progress"
5
+ import { sendMessage } from "./messaging"
4
6
 
5
7
  interface WatchdogOpts {
6
8
  db: Database
@@ -10,6 +12,14 @@ interface WatchdogOpts {
10
12
  ttlMs: number
11
13
  /** How often to run the check. Defaults to 60s. */
12
14
  checkIntervalMs?: number
15
+ /** Progress tracker for stall detection. */
16
+ progressTracker?: ProgressTracker
17
+ /** Stall detection threshold in ms. 0 disables. */
18
+ stallThresholdMs?: number
19
+ /** Min steps before token-based stall check. */
20
+ stallMinSteps?: number
21
+ /** Output token threshold for stall detection. */
22
+ stallTokenThreshold?: number
13
23
  }
14
24
 
15
25
  /**
@@ -22,6 +32,10 @@ export class Watchdog {
22
32
  private readonly registry: MemberRegistry
23
33
  private readonly ttlMs: number
24
34
  private readonly checkIntervalMs: number
35
+ private readonly progressTracker?: ProgressTracker
36
+ private readonly stallThresholdMs: number
37
+ private readonly stallMinSteps: number
38
+ private readonly stallTokenThreshold: number
25
39
  private timer: ReturnType<typeof setInterval> | undefined
26
40
 
27
41
  constructor(opts: WatchdogOpts) {
@@ -30,6 +44,10 @@ export class Watchdog {
30
44
  this.registry = opts.registry
31
45
  this.ttlMs = opts.ttlMs
32
46
  this.checkIntervalMs = opts.checkIntervalMs ?? 60_000
47
+ this.progressTracker = opts.progressTracker
48
+ this.stallThresholdMs = opts.stallThresholdMs ?? 0
49
+ this.stallMinSteps = opts.stallMinSteps ?? 3
50
+ this.stallTokenThreshold = opts.stallTokenThreshold ?? 500
33
51
  }
34
52
 
35
53
  private static STALE_THRESHOLD_MS = Number(process.env.STALE_WORKTREE_THRESHOLD_MS) || 300_000
@@ -61,9 +79,58 @@ export class Watchdog {
61
79
  }
62
80
  }
63
81
 
82
+ /** Check for stalled busy members and escalate to lead + nudge teammate. */
83
+ async checkStalled(): Promise<void> {
84
+ if (!this.progressTracker || this.stallThresholdMs === 0) return
85
+
86
+ const busy = this.db.query(
87
+ `SELECT tm.team_id, tm.name, tm.session_id
88
+ FROM team_member tm
89
+ JOIN team t ON tm.team_id = t.id
90
+ WHERE t.status = 'active' AND tm.status = 'busy'`
91
+ ).all() as Array<{ team_id: string; name: string; session_id: string }>
92
+
93
+ for (const member of busy) {
94
+ if (this.progressTracker.isReported(member.session_id)) continue
95
+
96
+ const tokenStalled = this.progressTracker.isTokenStalled(member.session_id, this.stallMinSteps, this.stallTokenThreshold)
97
+ const timeStalled = this.progressTracker.isTimeStalled(member.session_id, this.stallThresholdMs)
98
+
99
+ if (!tokenStalled && !timeStalled) continue
100
+
101
+ this.progressTracker.markReported(member.session_id)
102
+ const reason = tokenStalled ? "low output tokens" : "no communication"
103
+
104
+ // Nudge the teammate directly
105
+ this.client.session.promptAsync({
106
+ sessionID: member.session_id,
107
+ 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." }],
108
+ }).catch(() => { /* best effort */ })
109
+
110
+ // Notify the lead
111
+ sendMessage(this.db, {
112
+ teamId: member.team_id,
113
+ from: "system",
114
+ to: "lead",
115
+ content: `Teammate "${member.name}" appears stalled (${reason}). Consider checking on them via team_message or shutting them down.`,
116
+ })
117
+
118
+ // Toast for the user
119
+ try {
120
+ await this.client.tui.showToast({
121
+ title: "Team",
122
+ message: `${member.name} appears stalled`,
123
+ variant: "warning",
124
+ duration: 5000,
125
+ })
126
+ } catch { /* TUI may not be available */ }
127
+ }
128
+ }
129
+
64
130
  /** Run a single check for stale busy members. */
65
131
  async check(): Promise<void> {
66
132
  await this.cleanupStaleWorktrees()
133
+ await this.checkStalled()
67
134
  if (this.ttlMs === 0) return
68
135
 
69
136
  const cutoff = Date.now() - this.ttlMs