@hueyexe/opencode-ensemble 0.7.1 → 0.9.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/README.md +80 -15
- package/dist/index.js +349 -25
- package/package.json +1 -1
- package/src/config.ts +75 -0
- package/src/index.ts +34 -13
- package/src/progress.ts +85 -0
- package/src/result-parser.ts +40 -0
- package/src/system-prompt.ts +68 -2
- package/src/tools/team-cleanup.ts +83 -6
- package/src/tools/team-results.ts +7 -2
- package/src/tools/team-spawn.ts +26 -1
- package/src/tools/team-status.ts +25 -3
- package/src/watchdog.ts +67 -0
package/src/tools/team-status.ts
CHANGED
|
@@ -6,6 +6,13 @@ export const lastCallTime = new Map<string, number>()
|
|
|
6
6
|
|
|
7
7
|
const RATE_LIMIT_MS = 30_000
|
|
8
8
|
|
|
9
|
+
/** Format a duration in ms to a human-readable string. */
|
|
10
|
+
export function formatDuration(ms: number): string {
|
|
11
|
+
if (ms < 60_000) return `${Math.floor(ms / 1000)}s`
|
|
12
|
+
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m`
|
|
13
|
+
return `${Math.floor(ms / 3_600_000)}h`
|
|
14
|
+
}
|
|
15
|
+
|
|
9
16
|
/**
|
|
10
17
|
* Execute the team_status tool. Shows team overview with member statuses and task summary.
|
|
11
18
|
*/
|
|
@@ -23,9 +30,9 @@ export async function executeTeamStatus(
|
|
|
23
30
|
lastCallTime.set(teamInfo.teamId, now)
|
|
24
31
|
|
|
25
32
|
const members = deps.db.query(
|
|
26
|
-
"SELECT name, session_id, agent, status, execution_status, worktree_branch, worktree_dir, plan_approval FROM team_member WHERE team_id = ? ORDER BY time_created ASC"
|
|
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"
|
|
27
34
|
).all(teamInfo.teamId) as Array<{
|
|
28
|
-
name: string; session_id: string; agent: string; status: string; execution_status: string; worktree_branch: string | null; worktree_dir: string | null; plan_approval: string
|
|
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
|
|
29
36
|
}>
|
|
30
37
|
|
|
31
38
|
const tasks = deps.db.query(
|
|
@@ -42,9 +49,24 @@ export async function executeTeamStatus(
|
|
|
42
49
|
lines.push("Members:")
|
|
43
50
|
for (const m of members) {
|
|
44
51
|
const statusIcon = m.status === "busy" ? "working" : m.status === "ready" ? "idle" : m.status
|
|
52
|
+
const duration = formatDuration(now - m.time_updated)
|
|
45
53
|
const branch = m.worktree_branch ? ` branch: ${m.worktree_branch}` : ""
|
|
46
54
|
const plan = m.plan_approval !== "none" ? `, plan: ${m.plan_approval}` : ""
|
|
47
|
-
|
|
55
|
+
|
|
56
|
+
// Last message time
|
|
57
|
+
const lastMsg = deps.db.query("SELECT MAX(time_created) as last_msg FROM team_message WHERE team_id = ? AND from_name = ?")
|
|
58
|
+
.get(teamInfo.teamId, m.name) as { last_msg: number | null } | null
|
|
59
|
+
const msgInfo = lastMsg?.last_msg ? `last msg: ${formatDuration(now - lastMsg.last_msg)} ago` : "no messages yet"
|
|
60
|
+
|
|
61
|
+
lines.push(` ${m.name} [${statusIcon} ${duration}, ${msgInfo}${plan}] agent: ${m.agent}${branch}`)
|
|
62
|
+
|
|
63
|
+
// Current task
|
|
64
|
+
const task = deps.db.query("SELECT content FROM team_task WHERE team_id = ? AND assignee = ? AND status = 'in_progress' LIMIT 1")
|
|
65
|
+
.get(teamInfo.teamId, m.name) as { content: string } | null
|
|
66
|
+
if (task) {
|
|
67
|
+
const truncated = task.content.length > 80 ? `${task.content.slice(0, 80)}...` : task.content
|
|
68
|
+
lines.push(` task: ${truncated}`)
|
|
69
|
+
}
|
|
48
70
|
if (m.worktree_dir) {
|
|
49
71
|
lines.push(` worktree: ${m.worktree_dir}`)
|
|
50
72
|
}
|
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
|