@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/dist/index.js +356 -140
- package/package.json +1 -1
- package/src/dashboard-js-render.ts +5 -2
- package/src/dashboard.ts +62 -5
- package/src/hooks.ts +86 -8
- package/src/index.ts +64 -17
- package/src/member-model.ts +30 -0
- package/src/notify.ts +44 -0
- package/src/progress.ts +26 -7
- package/src/recovery.ts +106 -3
- package/src/schema.ts +18 -0
- package/src/tasks.ts +19 -0
- package/src/tools/team-create.ts +15 -2
- package/src/tools/team-merge.ts +30 -3
- package/src/tools/team-message.ts +54 -6
- package/src/tools/team-shutdown.ts +6 -0
- package/src/tools/team-spawn.ts +16 -15
- package/src/tools/team-status.ts +38 -4
- package/src/types.ts +3 -0
- package/src/watchdog.ts +91 -17
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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 })
|