@hueyexe/opencode-ensemble 0.16.0 → 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.
@@ -4,8 +4,28 @@ import { requireTeamMember } from "./shared"
4
4
  /** Tracks the last time team_status was called per team ID (epoch ms). */
5
5
  export const lastCallTime = new Map<string, number>()
6
6
 
7
+ /** Tracks the state marker (see computeStateMarker) observed at the last real read per team. */
8
+ export const lastKnownState = new Map<string, number>()
9
+
7
10
  const RATE_LIMIT_MS = 30_000
8
11
 
12
+ /**
13
+ * Cheap per-team "has anything changed" fingerprint — the max time_updated/time_created
14
+ * across the three tables team_status reports on. One MAX() query, no hashing: the
15
+ * throttle only needs to detect "something changed," not what, so this is deliberately
16
+ * the simplest sufficient version (see Fix 3 in the spec).
17
+ */
18
+ function computeStateMarker(deps: ToolDeps, teamId: string): number {
19
+ const row = deps.db.query(
20
+ `SELECT MAX(x) as marker FROM (
21
+ SELECT MAX(time_updated) as x FROM team_member WHERE team_id = ?
22
+ UNION SELECT MAX(time_updated) FROM team_task WHERE team_id = ?
23
+ UNION SELECT MAX(time_created) FROM team_message WHERE team_id = ?
24
+ )`
25
+ ).get(teamId, teamId, teamId) as { marker: number | null } | null
26
+ return row?.marker ?? 0
27
+ }
28
+
9
29
  /** Format a duration in ms to a human-readable string. */
10
30
  export function formatDuration(ms: number): string {
11
31
  if (ms < 60_000) return `${Math.floor(ms / 1000)}s`
@@ -24,15 +44,20 @@ export async function executeTeamStatus(
24
44
 
25
45
  const now = Date.now()
26
46
  const last = lastCallTime.get(teamInfo.teamId)
27
- if (last !== undefined && (now - last) < RATE_LIMIT_MS) {
47
+ const currentMarker = computeStateMarker(deps, teamInfo.teamId)
48
+ // The 30s throttle's purpose is preventing hammering, not lying about state — only
49
+ // short-circuit when nothing has actually changed since the last real read.
50
+ if (last !== undefined && (now - last) < RATE_LIMIT_MS && lastKnownState.get(teamInfo.teamId) === currentMarker) {
28
51
  return "No changes since last check."
29
52
  }
30
53
  lastCallTime.set(teamInfo.teamId, now)
54
+ lastKnownState.set(teamInfo.teamId, currentMarker)
31
55
 
32
56
  const members = deps.db.query(
33
- "SELECT name, session_id, agent, status, execution_status, worktree_branch, worktree_dir, plan_approval, time_updated FROM team_member WHERE team_id = ? ORDER BY time_created ASC"
57
+ "SELECT name, session_id, agent, status, execution_status, worktree_branch, worktree_dir, plan_approval, time_updated, last_nudged_at, retry_until, retry_attempt FROM team_member WHERE team_id = ? ORDER BY time_created ASC"
34
58
  ).all(teamInfo.teamId) as Array<{
35
- name: string; session_id: string; agent: string; status: string; execution_status: string; worktree_branch: string | null; worktree_dir: string | null; plan_approval: string; time_updated: number
59
+ name: string; session_id: string; agent: string; status: string; execution_status: string; worktree_branch: string | null; worktree_dir: string | null; plan_approval: string; time_updated: number; last_nudged_at: number | null
60
+ retry_until: number | null; retry_attempt: number | null
36
61
  }>
37
62
 
38
63
  const tasks = deps.db.query(
@@ -52,13 +77,22 @@ export async function executeTeamStatus(
52
77
  const duration = formatDuration(now - m.time_updated)
53
78
  const branch = m.worktree_branch ? ` branch: ${m.worktree_branch}` : ""
54
79
  const plan = m.plan_approval !== "none" ? `, plan: ${m.plan_approval}` : ""
80
+ // Additive display-staleness annotation (Fix 1) — surfaces a soft stall-nudge
81
+ // without inventing a new status value. Annotates the existing status, doesn't
82
+ // replace it.
83
+ const nudged = m.last_nudged_at ? `, nudged ${formatDuration(now - m.last_nudged_at)} ago` : ""
84
+ // Additive provider-retry annotation (Fix 4) — derived at read time from the
85
+ // retry_until TTL (Fix 3), same annotation pattern as `nudged` above. Generic
86
+ // "retrying" label, no rate-limit-specific wording (Fix 2).
87
+ const isRetrying = m.retry_until !== null && m.retry_until > now
88
+ const retrying = isRetrying ? `, retrying (attempt ${m.retry_attempt}, ~${formatDuration(m.retry_until! - now)})` : ""
55
89
 
56
90
  // Last message time
57
91
  const lastMsg = deps.db.query("SELECT MAX(time_created) as last_msg FROM team_message WHERE team_id = ? AND from_name = ?")
58
92
  .get(teamInfo.teamId, m.name) as { last_msg: number | null } | null
59
93
  const msgInfo = lastMsg?.last_msg ? `last msg: ${formatDuration(now - lastMsg.last_msg)} ago` : "no messages yet"
60
94
 
61
- lines.push(` ${m.name} [${statusIcon} ${duration}, ${msgInfo}${plan}] agent: ${m.agent}${branch}`)
95
+ lines.push(` ${m.name} [${statusIcon} ${duration}, ${msgInfo}${plan}${nudged}${retrying}] agent: ${m.agent}${branch}`)
62
96
 
63
97
  // Current task
64
98
  const task = deps.db.query("SELECT content FROM team_task WHERE team_id = ? AND assignee = ? AND status = 'in_progress' LIMIT 1")
@@ -5,6 +5,12 @@ import { log } from "../log"
5
5
  /**
6
6
  * Execute the team_tasks_complete tool. Marks a task as completed
7
7
  * and unblocks any dependent tasks.
8
+ *
9
+ * Enforces task-board accuracy (issue #27):
10
+ * - Rejects completing an already-completed or cancelled task.
11
+ * - Rejects completing a blocked task (dependencies must resolve first).
12
+ * - Only the assignee (or the lead) may complete a claimed task.
13
+ * - Completing an unclaimed task attributes it to the caller atomically.
8
14
  */
9
15
  export async function executeTeamTasksComplete(
10
16
  deps: ToolDeps,
@@ -12,13 +18,31 @@ export async function executeTeamTasksComplete(
12
18
  sessionId: string,
13
19
  ): Promise<string> {
14
20
  const teamInfo = requireTeamMember(deps, sessionId)
21
+ const caller = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
15
22
 
16
- const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?")
17
- .get(args.task_id, teamInfo.teamId) as Record<string, unknown> | null
23
+ const task = deps.db.query("SELECT id, content, status, assignee FROM team_task WHERE id = ? AND team_id = ?")
24
+ .get(args.task_id, teamInfo.teamId) as { id: string; content: string; status: string; assignee: string | null } | null
18
25
  if (!task) throw new Error(`Task "${args.task_id}" not found`)
26
+ if (task.status === "completed") throw new Error(`Task "${args.task_id}" is already completed`)
27
+ if (task.status === "cancelled") throw new Error(`Task "${args.task_id}" was cancelled`)
28
+ if (task.status === "blocked") throw new Error(`Task "${args.task_id}" is blocked by unresolved dependencies`)
29
+ if (task.status === "in_progress" && task.assignee && teamInfo.role !== "lead" && caller !== task.assignee) {
30
+ throw new Error(`Task "${args.task_id}" is claimed by ${task.assignee}`)
31
+ }
19
32
 
20
33
  const now = Date.now()
21
- deps.db.run("UPDATE team_task SET status = 'completed', time_updated = ? WHERE id = ?", [now, args.task_id])
34
+ if (task.assignee) {
35
+ deps.db.run("UPDATE team_task SET status = 'completed', time_updated = ? WHERE id = ?", [now, args.task_id])
36
+ } else {
37
+ // Attribute an unclaimed task to the caller so the board reflects who did it.
38
+ const result = deps.db.run(
39
+ "UPDATE team_task SET status = 'completed', assignee = ?, time_updated = ? WHERE id = ? AND assignee IS NULL",
40
+ [caller, now, args.task_id]
41
+ )
42
+ if (result.changes === 0) {
43
+ throw new Error(`Task "${args.task_id}" was just completed by another teammate`)
44
+ }
45
+ }
22
46
 
23
47
  // Unblock dependent tasks
24
48
  const allTasks = deps.db.query("SELECT id, depends_on, status FROM team_task WHERE team_id = ?")
package/src/types.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Database } from "./db"
2
2
  import type { MemberRegistry, DescendantTracker, PendingPurgeApprovals } from "./state"
3
3
  import type { EnsembleConfig } from "./config"
4
+ import type { ProgressTracker } from "./progress"
4
5
 
5
6
  /**
6
7
  * Shared dependencies injected into every tool's execute function.
@@ -17,6 +18,8 @@ export interface ToolDeps {
17
18
  directory: string
18
19
  /** Plugin configuration. */
19
20
  config: Required<EnsembleConfig>
21
+ /** Stall/activity tracker — shared with the Watchdog. */
22
+ progressTracker: ProgressTracker
20
23
  }
21
24
 
22
25
  /** A single permission rule for session-level enforcement. */
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 })