@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.
- package/dist/index.js +423 -170
- 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-claim.ts +33 -18
- 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 +56 -24
- package/src/tools/team-status.ts +38 -4
- package/src/tools/team-tasks-complete.ts +27 -3
- package/src/types.ts +3 -0
- package/src/watchdog.ts +91 -17
package/src/tools/team-status.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
17
|
-
.get(args.task_id, teamInfo.teamId) as
|
|
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
|
-
|
|
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 {
|
|
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 })
|