@hueyexe/opencode-ensemble 0.9.1 → 0.11.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 +33 -5
- package/dist/index.js +715 -3
- package/package.json +1 -1
- package/src/config.ts +12 -0
- package/src/dashboard-html.ts +91 -0
- package/src/dashboard-js-part1.ts +97 -0
- package/src/dashboard-js-part2.ts +233 -0
- package/src/dashboard-js-part3.ts +50 -0
- package/src/dashboard.ts +166 -0
- package/src/index.ts +35 -0
- package/src/progress.ts +38 -0
- package/src/system-prompt.ts +28 -2
- package/src/tools/team-message.ts +16 -0
- package/src/tools/team-spawn.ts +13 -0
- package/src/watchdog.ts +44 -0
package/src/dashboard.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
import { DASHBOARD_HEAD } from "./dashboard-html"
|
|
3
|
+
import { DASHBOARD_JS_PART1 } from "./dashboard-js-part1"
|
|
4
|
+
import { DASHBOARD_JS_PART2 } from "./dashboard-js-part2"
|
|
5
|
+
import { DASHBOARD_JS_PART3 } from "./dashboard-js-part3"
|
|
6
|
+
import { log } from "./log"
|
|
7
|
+
|
|
8
|
+
/** Assemble the full dashboard HTML from parts. */
|
|
9
|
+
const DASHBOARD_HTML = DASHBOARD_HEAD + "\n<script>" + DASHBOARD_JS_PART1 + DASHBOARD_JS_PART2 + DASHBOARD_JS_PART3 + "<\/script>\n</body></html>"
|
|
10
|
+
|
|
11
|
+
interface TeamRow {
|
|
12
|
+
id: string
|
|
13
|
+
name: string
|
|
14
|
+
status: string
|
|
15
|
+
lead_agent: string | null
|
|
16
|
+
time_created: number
|
|
17
|
+
time_updated: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface MemberRow {
|
|
21
|
+
name: string
|
|
22
|
+
agent: string
|
|
23
|
+
status: string
|
|
24
|
+
execution_status: string
|
|
25
|
+
worktree_branch: string | null
|
|
26
|
+
prompt: string | null
|
|
27
|
+
model: string | null
|
|
28
|
+
plan_approval: string
|
|
29
|
+
time_created: number
|
|
30
|
+
time_updated: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface TaskRow {
|
|
34
|
+
id: string
|
|
35
|
+
content: string
|
|
36
|
+
status: string
|
|
37
|
+
priority: string
|
|
38
|
+
assignee: string | null
|
|
39
|
+
depends_on: string | null
|
|
40
|
+
time_created: number
|
|
41
|
+
time_updated: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface MessageRow {
|
|
45
|
+
id: string
|
|
46
|
+
from_name: string
|
|
47
|
+
to_name: string | null
|
|
48
|
+
content: string
|
|
49
|
+
delivered: number
|
|
50
|
+
read: number
|
|
51
|
+
time_created: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function jsonResponse(data: unknown, status = 200): Response {
|
|
55
|
+
return new Response(JSON.stringify(data), {
|
|
56
|
+
status,
|
|
57
|
+
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function buildState(db: Database): { teams: unknown[] } {
|
|
62
|
+
const teams = db.query("SELECT id, name, status, lead_agent, time_created, time_updated FROM team ORDER BY time_created DESC").all() as TeamRow[]
|
|
63
|
+
const memberStmt = db.query("SELECT name, agent, status, execution_status, worktree_branch, prompt, model, plan_approval, time_created, time_updated FROM team_member WHERE team_id = ?")
|
|
64
|
+
const taskStmt = db.query("SELECT id, content, status, priority, assignee, depends_on, time_created, time_updated FROM team_task WHERE team_id = ?")
|
|
65
|
+
const msgStmt = db.query("SELECT id, from_name, to_name, content, delivered, read, time_created FROM team_message WHERE team_id = ? ORDER BY time_created DESC LIMIT 50")
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
teams: teams.map((t) => ({
|
|
69
|
+
id: t.id,
|
|
70
|
+
name: t.name,
|
|
71
|
+
status: t.status,
|
|
72
|
+
leadAgent: t.lead_agent,
|
|
73
|
+
timeCreated: t.time_created,
|
|
74
|
+
timeUpdated: t.time_updated,
|
|
75
|
+
members: (memberStmt.all(t.id) as MemberRow[]).map((m) => ({
|
|
76
|
+
name: m.name,
|
|
77
|
+
agent: m.agent,
|
|
78
|
+
status: m.status,
|
|
79
|
+
executionStatus: m.execution_status,
|
|
80
|
+
worktreeBranch: m.worktree_branch,
|
|
81
|
+
prompt: m.prompt,
|
|
82
|
+
model: m.model,
|
|
83
|
+
planApproval: m.plan_approval,
|
|
84
|
+
timeCreated: m.time_created,
|
|
85
|
+
timeUpdated: m.time_updated,
|
|
86
|
+
})),
|
|
87
|
+
tasks: (taskStmt.all(t.id) as TaskRow[]).map((tk) => ({
|
|
88
|
+
id: tk.id,
|
|
89
|
+
content: tk.content,
|
|
90
|
+
status: tk.status,
|
|
91
|
+
priority: tk.priority,
|
|
92
|
+
assignee: tk.assignee,
|
|
93
|
+
dependsOn: tk.depends_on,
|
|
94
|
+
timeCreated: tk.time_created,
|
|
95
|
+
timeUpdated: tk.time_updated,
|
|
96
|
+
})),
|
|
97
|
+
messages: (msgStmt.all(t.id) as MessageRow[]).map((msg) => ({
|
|
98
|
+
id: msg.id,
|
|
99
|
+
fromName: msg.from_name,
|
|
100
|
+
toName: msg.to_name,
|
|
101
|
+
content: msg.content,
|
|
102
|
+
delivered: msg.delivered === 1,
|
|
103
|
+
read: msg.read === 1,
|
|
104
|
+
timeCreated: msg.time_created,
|
|
105
|
+
})),
|
|
106
|
+
})),
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Start the dashboard HTTP server.
|
|
112
|
+
* Serves a JSON API for team state and the dashboard HTML.
|
|
113
|
+
* Singleton: if the port is already in use by another ensemble instance, skips silently.
|
|
114
|
+
* Returns the server instance, or null if skipped.
|
|
115
|
+
*/
|
|
116
|
+
export async function startDashboard(db: Database, port: number): Promise<ReturnType<typeof Bun.serve> | null> {
|
|
117
|
+
try {
|
|
118
|
+
const server = Bun.serve({
|
|
119
|
+
port,
|
|
120
|
+
fetch(req) {
|
|
121
|
+
const url = new URL(req.url)
|
|
122
|
+
|
|
123
|
+
if (url.pathname === "/api/health") {
|
|
124
|
+
return jsonResponse({ ensemble: true, pid: process.pid })
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (url.pathname === "/api/state") {
|
|
128
|
+
return jsonResponse(buildState(db))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (url.pathname === "/") {
|
|
132
|
+
return new Response(DASHBOARD_HTML, {
|
|
133
|
+
headers: { "Content-Type": "text/html" },
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return new Response("Not Found", { status: 404 })
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
log(`dashboard:started port=${port} url=http://localhost:${port}`)
|
|
141
|
+
return server
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if (err && typeof err === "object" && "code" in err && err.code === "EADDRINUSE") {
|
|
144
|
+
try {
|
|
145
|
+
const res = await fetch(`http://localhost:${port}/api/health`)
|
|
146
|
+
const data = await res.json() as { ensemble?: boolean; pid?: number }
|
|
147
|
+
if (data.ensemble && data.pid) {
|
|
148
|
+
// Check if the other process is still alive
|
|
149
|
+
let alive = false
|
|
150
|
+
try { process.kill(data.pid, 0); alive = true } catch { /* process is dead */ }
|
|
151
|
+
if (alive && data.pid !== process.pid) {
|
|
152
|
+
log(`dashboard:already-running port=${port} pid=${data.pid}`)
|
|
153
|
+
return null
|
|
154
|
+
}
|
|
155
|
+
// Stale server from a dead process — warn the user
|
|
156
|
+
log(`dashboard:stale-server port=${port} stale-pid=${data.pid} — run: kill -9 ${data.pid} || lsof -ti:${port} | xargs kill -9`)
|
|
157
|
+
return null
|
|
158
|
+
}
|
|
159
|
+
} catch { /* health check failed — port held by something else */ }
|
|
160
|
+
log(`dashboard:port-in-use port=${port} (not an ensemble instance)`)
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
log(`dashboard:failed err=${err instanceof Error ? err.message : String(err)}`)
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { log, initLog } from "./log"
|
|
|
15
15
|
import { findTeamBySession } from "./types"
|
|
16
16
|
import { loadConfig } from "./config"
|
|
17
17
|
import { ProgressTracker } from "./progress"
|
|
18
|
+
import { startDashboard } from "./dashboard"
|
|
18
19
|
import { executeTeamCreate } from "./tools/team-create"
|
|
19
20
|
import { executeTeamSpawn } from "./tools/team-spawn"
|
|
20
21
|
import { executeTeamMessage } from "./tools/team-message"
|
|
@@ -93,6 +94,13 @@ const plugin: Plugin = async (input) => {
|
|
|
93
94
|
log(`init:recover-branches:failed err=${err instanceof Error ? err.message : String(err)}`)
|
|
94
95
|
})
|
|
95
96
|
log("init:recovery:done")
|
|
97
|
+
|
|
98
|
+
// Start dashboard server (main instance only, not worktree instances)
|
|
99
|
+
if (config.dashboardPort !== 0) {
|
|
100
|
+
startDashboard(db, config.dashboardPort).catch((err) => {
|
|
101
|
+
log(`init:dashboard:failed err=${err instanceof Error ? err.message : String(err)}`)
|
|
102
|
+
})
|
|
103
|
+
}
|
|
96
104
|
} else {
|
|
97
105
|
log(`init:skip-recovery (worktree instance: ${input.directory})`)
|
|
98
106
|
}
|
|
@@ -114,6 +122,8 @@ const plugin: Plugin = async (input) => {
|
|
|
114
122
|
stallMinSteps: config.stallMinSteps,
|
|
115
123
|
stallTokenThreshold: config.stallTokenThreshold,
|
|
116
124
|
cwd: input.directory,
|
|
125
|
+
peerMessageLimit: config.peerMessageLimit,
|
|
126
|
+
peerMessageWindowMs: config.peerMessageWindowMs,
|
|
117
127
|
})
|
|
118
128
|
watchdog.start()
|
|
119
129
|
|
|
@@ -198,6 +208,29 @@ const plugin: Plugin = async (input) => {
|
|
|
198
208
|
})
|
|
199
209
|
}
|
|
200
210
|
}
|
|
211
|
+
|
|
212
|
+
// Also flush peer messages for teammates that just went idle
|
|
213
|
+
// Only flush messages older than 5s to avoid double-delivery with the direct promptAsync path
|
|
214
|
+
const member = db.query(
|
|
215
|
+
`SELECT tm.team_id, tm.name FROM team_member tm
|
|
216
|
+
JOIN team t ON tm.team_id = t.id
|
|
217
|
+
WHERE tm.session_id = ? AND t.status = 'active'`
|
|
218
|
+
).get(sessionID) as { team_id: string; name: string } | null
|
|
219
|
+
if (member) {
|
|
220
|
+
const staleThreshold = Date.now() - 5000
|
|
221
|
+
const peerMsgs = db.query(
|
|
222
|
+
"SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND to_name = ? AND delivered = 0 AND time_created < ?"
|
|
223
|
+
).get(member.team_id, member.name, staleThreshold) as { c: number }
|
|
224
|
+
if (peerMsgs.c > 0) {
|
|
225
|
+
log(`wake-peer: ${member.name} has ${peerMsgs.c} pending peer messages`)
|
|
226
|
+
client.session.promptAsync({
|
|
227
|
+
sessionID,
|
|
228
|
+
parts: [{ type: "text", text: `[System: ${peerMsgs.c} new message(s) from teammates]` }],
|
|
229
|
+
}).catch((err) => {
|
|
230
|
+
log(`wake-peer:failed err=${err instanceof Error ? err.message : String(err)}`)
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
}
|
|
201
234
|
}
|
|
202
235
|
}
|
|
203
236
|
|
|
@@ -317,6 +350,8 @@ const plugin: Plugin = async (input) => {
|
|
|
317
350
|
const result = await executeTeamMessage(deps, args, ctx.sessionID)
|
|
318
351
|
// Track message activity for stall detection
|
|
319
352
|
progressTracker.recordMessage(ctx.sessionID)
|
|
353
|
+
// Track peer messages for chatty detection
|
|
354
|
+
if (args.to !== "lead") progressTracker.recordPeerMessage(ctx.sessionID)
|
|
320
355
|
ctx.metadata({ title: `Message → ${args.to}` })
|
|
321
356
|
return result
|
|
322
357
|
},
|
package/src/progress.ts
CHANGED
|
@@ -12,7 +12,9 @@ export class ProgressTracker {
|
|
|
12
12
|
private steps = new Map<string, StepRecord[]>()
|
|
13
13
|
private lastMessageAt = new Map<string, number>()
|
|
14
14
|
private lastTaskAt = new Map<string, number>()
|
|
15
|
+
private peerMessages = new Map<string, number[]>()
|
|
15
16
|
private reported = new Set<string>()
|
|
17
|
+
private chattyReported = new Set<string>()
|
|
16
18
|
private readonly maxSteps: number
|
|
17
19
|
|
|
18
20
|
constructor(maxSteps = 10) {
|
|
@@ -27,6 +29,40 @@ export class ProgressTracker {
|
|
|
27
29
|
this.steps.set(sessionId, records)
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
/** Record a peer message (not to lead). Used for chatty detection. */
|
|
33
|
+
recordPeerMessage(sessionId: string): void {
|
|
34
|
+
const timestamps = this.peerMessages.get(sessionId) ?? []
|
|
35
|
+
timestamps.push(Date.now())
|
|
36
|
+
this.peerMessages.set(sessionId, timestamps)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Check if agent is chatty: more than `limit` peer messages within `windowMs`. */
|
|
40
|
+
isChatty(sessionId: string, limit: number, windowMs: number): boolean {
|
|
41
|
+
if (limit === 0) return false
|
|
42
|
+
const timestamps = this.peerMessages.get(sessionId)
|
|
43
|
+
if (!timestamps) return false
|
|
44
|
+
const cutoff = Date.now() - windowMs
|
|
45
|
+
const recent = timestamps.filter(t => t >= cutoff)
|
|
46
|
+
// Prune old timestamps
|
|
47
|
+
this.peerMessages.set(sessionId, recent)
|
|
48
|
+
return recent.length >= limit
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Mark chatty as reported (avoid spam). */
|
|
52
|
+
markChattyReported(sessionId: string): void {
|
|
53
|
+
this.chattyReported.add(sessionId)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Check if chatty already reported. */
|
|
57
|
+
isChattyReported(sessionId: string): boolean {
|
|
58
|
+
return this.chattyReported.has(sessionId)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Clear chatty report (e.g., when agent sends result to lead). */
|
|
62
|
+
clearChattyReport(sessionId: string): void {
|
|
63
|
+
this.chattyReported.delete(sessionId)
|
|
64
|
+
}
|
|
65
|
+
|
|
30
66
|
/** Record that this member sent a team_message. Clears stall report. */
|
|
31
67
|
recordMessage(sessionId: string): void {
|
|
32
68
|
this.lastMessageAt.set(sessionId, Date.now())
|
|
@@ -80,6 +116,8 @@ export class ProgressTracker {
|
|
|
80
116
|
this.steps.delete(sessionId)
|
|
81
117
|
this.lastMessageAt.delete(sessionId)
|
|
82
118
|
this.lastTaskAt.delete(sessionId)
|
|
119
|
+
this.peerMessages.delete(sessionId)
|
|
83
120
|
this.reported.delete(sessionId)
|
|
121
|
+
this.chattyReported.delete(sessionId)
|
|
84
122
|
}
|
|
85
123
|
}
|
package/src/system-prompt.ts
CHANGED
|
@@ -125,13 +125,39 @@ export function buildLeadSystemPrompt(db: Database, teamId: string): string {
|
|
|
125
125
|
|
|
126
126
|
/**
|
|
127
127
|
* Build the system prompt injected into a teammate's session.
|
|
128
|
-
*
|
|
128
|
+
* Includes role reminder and delivers any pending peer messages.
|
|
129
129
|
*/
|
|
130
130
|
export function buildTeammateSystemPrompt(db: Database, teamId: string, memberName: string): string {
|
|
131
131
|
const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId) as { name: string } | null
|
|
132
132
|
if (!team) return ""
|
|
133
133
|
|
|
134
|
-
|
|
134
|
+
const lines = [
|
|
135
|
+
`You are "${memberName}", a teammate in team "${team.name}". Use team_message to communicate. You MUST send your results to the lead via team_message before stopping.`,
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
// Deliver pending peer messages addressed to this teammate
|
|
139
|
+
const pendingMessages = db.query(
|
|
140
|
+
"SELECT id, from_name, content FROM team_message WHERE team_id = ? AND to_name = ? AND delivered = 0 ORDER BY time_created ASC"
|
|
141
|
+
).all(teamId, memberName) as Array<{ id: string; from_name: string; content: string }>
|
|
142
|
+
|
|
143
|
+
if (pendingMessages.length > 0) {
|
|
144
|
+
lines.push("", "--- Messages for you ---")
|
|
145
|
+
const MAX_MSG = 500
|
|
146
|
+
for (const msg of pendingMessages) {
|
|
147
|
+
const parsed = parseTaskResult(msg.content)
|
|
148
|
+
if (parsed) {
|
|
149
|
+
lines.push(formatTaskResult(msg.from_name, { ...parsed, details: truncate(parsed.details, MAX_MSG) }))
|
|
150
|
+
} else if (msg.content.length > MAX_MSG) {
|
|
151
|
+
lines.push(`[From ${msg.from_name}]: ${msg.content.slice(0, MAX_MSG)}... (truncated)`)
|
|
152
|
+
} else {
|
|
153
|
+
lines.push(`[From ${msg.from_name}]: ${msg.content}`)
|
|
154
|
+
}
|
|
155
|
+
markDelivered(db, msg.id)
|
|
156
|
+
}
|
|
157
|
+
lines.push("--- End Messages ---")
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return lines.join("\n")
|
|
135
161
|
}
|
|
136
162
|
|
|
137
163
|
/**
|
|
@@ -18,6 +18,22 @@ export async function executeTeamMessage(
|
|
|
18
18
|
const senderName = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
|
|
19
19
|
|
|
20
20
|
const recipientSessionId = resolveRecipientSession(deps.db, deps.registry, teamInfo.teamId, args.to)
|
|
21
|
+
|
|
22
|
+
// If recipient not found, store the message for later delivery (they may not be spawned yet)
|
|
23
|
+
// Reject approve/reject flags for unspawned recipients — plan approval requires the member to exist
|
|
24
|
+
if (!recipientSessionId && args.to !== "lead") {
|
|
25
|
+
if (args.approve || args.reject) {
|
|
26
|
+
throw new Error(`Cannot approve/reject plan for "${args.to}" — they haven't been spawned yet.`)
|
|
27
|
+
}
|
|
28
|
+
sendMessage(deps.db, {
|
|
29
|
+
teamId: teamInfo.teamId,
|
|
30
|
+
from: senderName,
|
|
31
|
+
to: args.to,
|
|
32
|
+
content: args.text,
|
|
33
|
+
})
|
|
34
|
+
log(`team_message:queued from=${senderName} to=${args.to} (recipient not yet spawned)`)
|
|
35
|
+
return `Message queued for ${args.to} — they haven't been spawned yet. It will be delivered when they join the team.`
|
|
36
|
+
}
|
|
21
37
|
if (!recipientSessionId) throw new Error(`Recipient "${args.to}" not found in team "${teamInfo.teamName}"`)
|
|
22
38
|
|
|
23
39
|
// Handle plan approval/rejection
|
package/src/tools/team-spawn.ts
CHANGED
|
@@ -253,6 +253,19 @@ export async function executeTeamSpawn(
|
|
|
253
253
|
)
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
// Collaboration guidance for peer-to-peer communication
|
|
257
|
+
if (otherMembers.length > 0) {
|
|
258
|
+
context.push(
|
|
259
|
+
"",
|
|
260
|
+
"Collaboration:",
|
|
261
|
+
"- Check team_tasks_list to see what other teammates are working on.",
|
|
262
|
+
"- If you need information another teammate has, message them directly via team_message.",
|
|
263
|
+
"- If you discover something relevant to another teammate's task, share it with them.",
|
|
264
|
+
"- Use team_broadcast for updates that affect the whole team.",
|
|
265
|
+
"- Keep peer messages focused and actionable — coordinate, don't chat.",
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
|
|
256
269
|
context.push("", "When you finish your task:")
|
|
257
270
|
if (!isReadOnly && worktreeBranch) {
|
|
258
271
|
context.push(`1. Commit your changes: git add -A && git commit -m "your summary"`)
|
package/src/watchdog.ts
CHANGED
|
@@ -24,6 +24,10 @@ interface WatchdogOpts {
|
|
|
24
24
|
stallTokenThreshold?: number
|
|
25
25
|
/** Project directory for git operations. */
|
|
26
26
|
cwd?: string
|
|
27
|
+
/** Max peer messages per agent per window before nudge. 0 disables. */
|
|
28
|
+
peerMessageLimit?: number
|
|
29
|
+
/** Time window for peer message rate limiting in ms. */
|
|
30
|
+
peerMessageWindowMs?: number
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
/**
|
|
@@ -41,6 +45,8 @@ export class Watchdog {
|
|
|
41
45
|
private readonly stallMinSteps: number
|
|
42
46
|
private readonly stallTokenThreshold: number
|
|
43
47
|
private readonly cwd?: string
|
|
48
|
+
private readonly peerMessageLimit: number
|
|
49
|
+
private readonly peerMessageWindowMs: number
|
|
44
50
|
private timer: ReturnType<typeof setInterval> | undefined
|
|
45
51
|
|
|
46
52
|
constructor(opts: WatchdogOpts) {
|
|
@@ -54,6 +60,8 @@ export class Watchdog {
|
|
|
54
60
|
this.stallMinSteps = opts.stallMinSteps ?? 3
|
|
55
61
|
this.stallTokenThreshold = opts.stallTokenThreshold ?? 500
|
|
56
62
|
this.cwd = opts.cwd
|
|
63
|
+
this.peerMessageLimit = opts.peerMessageLimit ?? 0
|
|
64
|
+
this.peerMessageWindowMs = opts.peerMessageWindowMs ?? 300_000
|
|
57
65
|
}
|
|
58
66
|
|
|
59
67
|
private static STALE_THRESHOLD_MS = Number(process.env.STALE_WORKTREE_THRESHOLD_MS) || 300_000
|
|
@@ -133,10 +141,46 @@ export class Watchdog {
|
|
|
133
141
|
}
|
|
134
142
|
}
|
|
135
143
|
|
|
144
|
+
/** Check for chatty agents sending too many peer messages. */
|
|
145
|
+
async checkChatty(): Promise<void> {
|
|
146
|
+
if (!this.progressTracker || this.peerMessageLimit === 0) return
|
|
147
|
+
|
|
148
|
+
const busy = this.db.query(
|
|
149
|
+
`SELECT tm.team_id, tm.name, tm.session_id
|
|
150
|
+
FROM team_member tm
|
|
151
|
+
JOIN team t ON tm.team_id = t.id
|
|
152
|
+
WHERE t.status = 'active' AND tm.status = 'busy'`
|
|
153
|
+
).all() as Array<{ team_id: string; name: string; session_id: string }>
|
|
154
|
+
|
|
155
|
+
for (const member of busy) {
|
|
156
|
+
if (this.progressTracker.isChattyReported(member.session_id)) continue
|
|
157
|
+
if (!this.progressTracker.isChatty(member.session_id, this.peerMessageLimit, this.peerMessageWindowMs)) continue
|
|
158
|
+
|
|
159
|
+
this.progressTracker.markChattyReported(member.session_id)
|
|
160
|
+
|
|
161
|
+
// Nudge the agent
|
|
162
|
+
this.client.session.promptAsync({
|
|
163
|
+
sessionID: member.session_id,
|
|
164
|
+
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 */ })
|
|
166
|
+
|
|
167
|
+
// 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
|
+
})
|
|
174
|
+
|
|
175
|
+
log(`watchdog:chatty member=${member.name} limit=${this.peerMessageLimit}`)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
136
179
|
/** Run a single check for stale busy members. */
|
|
137
180
|
async check(): Promise<void> {
|
|
138
181
|
await this.cleanupStaleWorktrees()
|
|
139
182
|
await this.checkStalled()
|
|
183
|
+
await this.checkChatty()
|
|
140
184
|
if (this.ttlMs === 0) return
|
|
141
185
|
|
|
142
186
|
const cutoff = Date.now() - this.ttlMs
|