@hueyexe/opencode-ensemble 0.16.1 → 0.18.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 +42 -28
- package/dist/index.js +43016 -19579
- package/package.json +18 -5
- package/src/dashboard-js-render.ts +5 -2
- package/src/dashboard.ts +62 -5
- package/src/hooks.ts +86 -8
- package/src/index.ts +90 -20
- package/src/log.ts +9 -0
- package/src/member-model.ts +30 -0
- package/src/notify.ts +46 -0
- package/src/progress.ts +26 -7
- package/src/recovery.ts +106 -3
- package/src/rpc.ts +99 -0
- 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 +55 -6
- package/src/tools/team-shutdown.ts +6 -0
- package/src/tools/team-spawn.ts +37 -16
- package/src/tools/team-status.ts +38 -4
- package/src/tools/team-view.ts +10 -4
- package/src/tui-view.ts +50 -0
- package/src/tui.tsx +101 -0
- package/src/types.ts +5 -0
- package/src/v2-client.ts +227 -0
- package/src/v2-events.ts +73 -0
- package/src/v2-rpc.ts +24 -0
- package/src/v2-session.ts +56 -0
- package/src/v2-setup.ts +349 -0
- package/src/v2-tools.ts +349 -0
- package/src/watchdog.ts +91 -17
package/src/v2-tools.ts
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import type { ToolDeps } from "./types"
|
|
2
|
+
import { executeTeamCreate } from "./tools/team-create"
|
|
3
|
+
import { executeTeamSpawn } from "./tools/team-spawn"
|
|
4
|
+
import { executeTeamMessage } from "./tools/team-message"
|
|
5
|
+
import { executeTeamBroadcast } from "./tools/team-broadcast"
|
|
6
|
+
import { executeTeamTasksList } from "./tools/team-tasks-list"
|
|
7
|
+
import { executeTeamTasksAdd } from "./tools/team-tasks-add"
|
|
8
|
+
import { executeTeamTasksComplete } from "./tools/team-tasks-complete"
|
|
9
|
+
import { executeTeamClaim } from "./tools/team-claim"
|
|
10
|
+
import { executeTeamResults } from "./tools/team-results"
|
|
11
|
+
import { executeTeamShutdown } from "./tools/team-shutdown"
|
|
12
|
+
import { executeTeamCleanup } from "./tools/team-cleanup"
|
|
13
|
+
import { executeTeamMerge } from "./tools/team-merge"
|
|
14
|
+
import { executeTeamStatus } from "./tools/team-status"
|
|
15
|
+
import { executeTeamView } from "./tools/team-view"
|
|
16
|
+
|
|
17
|
+
/** V2 tool definition (JSON Schema input, structured content output). */
|
|
18
|
+
export interface V2ToolDef {
|
|
19
|
+
name: string
|
|
20
|
+
description: string
|
|
21
|
+
input: Record<string, unknown>
|
|
22
|
+
execute(input: unknown, context: { sessionID: string }): Promise<{ content: string }>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Minimal structural subset of the V2 tool domain. */
|
|
26
|
+
export interface V2ToolDomain {
|
|
27
|
+
transform(cb: (editor: { add(def: V2ToolDef): void }) => void): Promise<{ dispose(): Promise<void> }>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** JSON Schema string property. */
|
|
31
|
+
function str(description: string): Record<string, unknown> {
|
|
32
|
+
return { type: "string", description }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** JSON Schema boolean property with default. */
|
|
36
|
+
function bool(description: string, fallback: boolean): Record<string, unknown> {
|
|
37
|
+
return { type: "boolean", description, default: fallback }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Task priority enum shared by the schema and the runtime validator. */
|
|
41
|
+
const TASK_PRIORITIES = ["high", "medium", "low"] as const
|
|
42
|
+
type TaskPriority = (typeof TASK_PRIORITIES)[number]
|
|
43
|
+
|
|
44
|
+
/** JSON Schema enum property for task priority. */
|
|
45
|
+
function priority(): Record<string, unknown> {
|
|
46
|
+
return { type: "string", description: "Task priority", enum: [...TASK_PRIORITIES], default: "medium" }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Throw on unknown priority — fail fast instead of writing garbage to SQLite. */
|
|
50
|
+
export function normalizePriority(value: string | undefined): TaskPriority {
|
|
51
|
+
if (value === undefined) return "medium"
|
|
52
|
+
if ((TASK_PRIORITIES as readonly string[]).includes(value)) return value as TaskPriority
|
|
53
|
+
throw new Error(`Invalid priority "${value}" — expected one of: high, medium, low.`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Register all 14 team tools on a V2 context. Each execute delegates to the
|
|
58
|
+
* same implementation V1 uses (DRY) — only arg transport changes from Zod to
|
|
59
|
+
* JSON Schema. V1 applied schema defaults before execute; V2 may not, so
|
|
60
|
+
* priority is normalized here (team_tasks_add writes it straight to SQLite).
|
|
61
|
+
*
|
|
62
|
+
* Purge approval (team_cleanup) has no approver on V2 yet — the question-tool
|
|
63
|
+
* flow it used is TUI-side. Preview works; confirmed purge needs the forms
|
|
64
|
+
* slice. team_view resolves without navigating (no server-side TUI select).
|
|
65
|
+
*/
|
|
66
|
+
export async function registerV2Tools(domain: V2ToolDomain, deps: ToolDeps): Promise<void> {
|
|
67
|
+
const run = <A>(
|
|
68
|
+
fn: (deps: ToolDeps, args: A, sessionID: string) => Promise<string>,
|
|
69
|
+
track?: (sessionID: string, args: A) => void,
|
|
70
|
+
) => async (input: unknown, context: { sessionID: string }) => {
|
|
71
|
+
const args = input as A
|
|
72
|
+
track?.(context.sessionID, args)
|
|
73
|
+
return { content: await fn(deps, args, context.sessionID) }
|
|
74
|
+
}
|
|
75
|
+
const runSession = (fn: (deps: ToolDeps, sessionID: string) => Promise<string>) => async (
|
|
76
|
+
_input: unknown,
|
|
77
|
+
context: { sessionID: string },
|
|
78
|
+
) => ({ content: await fn(deps, context.sessionID) })
|
|
79
|
+
|
|
80
|
+
await domain.transform((editor) => {
|
|
81
|
+
editor.add({
|
|
82
|
+
name: "team_create",
|
|
83
|
+
description: "Create a new agent team. You become the team lead. Use this before spawning teammates.",
|
|
84
|
+
input: {
|
|
85
|
+
type: "object",
|
|
86
|
+
properties: {
|
|
87
|
+
name: str("Team name (lowercase alphanumeric with hyphens, 1-64 chars)"),
|
|
88
|
+
project_name: str("Project display name for first use of this working directory."),
|
|
89
|
+
},
|
|
90
|
+
required: ["name"],
|
|
91
|
+
additionalProperties: false,
|
|
92
|
+
},
|
|
93
|
+
execute: run(executeTeamCreate),
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
editor.add({
|
|
97
|
+
name: "team_spawn",
|
|
98
|
+
description:
|
|
99
|
+
"Spawn a new teammate that works in parallel. The teammate starts immediately with the given prompt. " +
|
|
100
|
+
"Each teammate gets their own git worktree for file isolation. " +
|
|
101
|
+
"Teammates work asynchronously and will message you when done. Do not poll for their status.",
|
|
102
|
+
input: {
|
|
103
|
+
type: "object",
|
|
104
|
+
properties: {
|
|
105
|
+
name: str("Teammate name (lowercase alphanumeric with hyphens)"),
|
|
106
|
+
agent: str("Agent type (e.g. 'build', 'plan', 'explore')"),
|
|
107
|
+
prompt: str("Task instructions for the teammate"),
|
|
108
|
+
model: str("Model in provider/model format (optional, uses default)"),
|
|
109
|
+
claim_task: str("Task ID to auto-claim for this teammate (optional)"),
|
|
110
|
+
worktree: bool("Create a git worktree for file isolation (default: true, set false for read-only agents)", true),
|
|
111
|
+
plan_approval: bool("Require teammate to send a plan for approval before writing files (default: false)", false),
|
|
112
|
+
},
|
|
113
|
+
required: ["name", "prompt"],
|
|
114
|
+
additionalProperties: false,
|
|
115
|
+
},
|
|
116
|
+
execute: run(executeTeamSpawn),
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
editor.add({
|
|
120
|
+
name: "team_message",
|
|
121
|
+
description:
|
|
122
|
+
"Send a message to a specific teammate or to the lead. Use 'lead' to message the team lead. " +
|
|
123
|
+
"Lead only: pass 'model' (provider/model) to update a teammate's model in-place.",
|
|
124
|
+
input: {
|
|
125
|
+
type: "object",
|
|
126
|
+
properties: {
|
|
127
|
+
to: str("Recipient name ('lead' or teammate name)"),
|
|
128
|
+
text: str("Message content (max 10KB). Optional only when 'model' is provided."),
|
|
129
|
+
approve: bool("Approve a teammate's plan (only when recipient has plan_approval='pending')", false),
|
|
130
|
+
reject: str("Reject a teammate's plan with reason (only when recipient has plan_approval='pending')"),
|
|
131
|
+
force: bool("Lead only. Re-activate a teammate who already reported task completion.", false),
|
|
132
|
+
model: str("Lead only: update the recipient teammate's model in-place, in 'provider/model' format."),
|
|
133
|
+
},
|
|
134
|
+
required: ["to"],
|
|
135
|
+
additionalProperties: false,
|
|
136
|
+
},
|
|
137
|
+
execute: run(executeTeamMessage, (sessionID, args) => {
|
|
138
|
+
const to = args.to ?? ""
|
|
139
|
+
deps.progressTracker.recordMessage(sessionID)
|
|
140
|
+
if (to !== "lead") deps.progressTracker.recordPeerMessage(sessionID)
|
|
141
|
+
}),
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
editor.add({
|
|
145
|
+
name: "team_broadcast",
|
|
146
|
+
description: "Send a message to all teammates and the lead (excluding yourself).",
|
|
147
|
+
input: {
|
|
148
|
+
type: "object",
|
|
149
|
+
properties: { text: str("Message content (max 10KB)") },
|
|
150
|
+
required: ["text"],
|
|
151
|
+
additionalProperties: false,
|
|
152
|
+
},
|
|
153
|
+
execute: run(executeTeamBroadcast, (sessionID) => {
|
|
154
|
+
deps.progressTracker.recordMessage(sessionID)
|
|
155
|
+
deps.progressTracker.recordPeerMessage(sessionID)
|
|
156
|
+
},
|
|
157
|
+
),
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
editor.add({
|
|
161
|
+
name: "team_tasks_list",
|
|
162
|
+
description:
|
|
163
|
+
"View the shared team task board. Use this to check task status, not to wait for teammates. Teammates will message you when done.",
|
|
164
|
+
input: { type: "object", properties: {}, additionalProperties: false },
|
|
165
|
+
execute: runSession(executeTeamTasksList),
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
editor.add({
|
|
169
|
+
name: "team_tasks_add",
|
|
170
|
+
description: "Add tasks to the shared team task board so teammates can see what work is available and claim it.",
|
|
171
|
+
input: {
|
|
172
|
+
type: "object",
|
|
173
|
+
properties: {
|
|
174
|
+
tasks: {
|
|
175
|
+
type: "array",
|
|
176
|
+
description: "Tasks to add",
|
|
177
|
+
items: {
|
|
178
|
+
type: "object",
|
|
179
|
+
properties: {
|
|
180
|
+
content: str("Task description"),
|
|
181
|
+
priority: priority(),
|
|
182
|
+
depends_on: {
|
|
183
|
+
type: "array",
|
|
184
|
+
items: { type: "string" },
|
|
185
|
+
description: "Task IDs this depends on",
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
required: ["content"],
|
|
189
|
+
additionalProperties: false,
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
required: ["tasks"],
|
|
194
|
+
additionalProperties: false,
|
|
195
|
+
},
|
|
196
|
+
execute: async (input: unknown, context: { sessionID: string }) => {
|
|
197
|
+
const args = input as {
|
|
198
|
+
tasks: Array<{ content: string; priority?: string; depends_on?: string[] }>
|
|
199
|
+
}
|
|
200
|
+
const content = await executeTeamTasksAdd(
|
|
201
|
+
deps,
|
|
202
|
+
{
|
|
203
|
+
tasks: args.tasks.map((task) => ({
|
|
204
|
+
content: task.content,
|
|
205
|
+
priority: normalizePriority(task.priority),
|
|
206
|
+
...(task.depends_on ? { depends_on: task.depends_on } : {}),
|
|
207
|
+
})),
|
|
208
|
+
},
|
|
209
|
+
context.sessionID,
|
|
210
|
+
)
|
|
211
|
+
return { content }
|
|
212
|
+
},
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
editor.add({
|
|
216
|
+
name: "team_tasks_complete",
|
|
217
|
+
description: "Mark a task as completed on the shared board. This unblocks any tasks that depend on it.",
|
|
218
|
+
input: {
|
|
219
|
+
type: "object",
|
|
220
|
+
properties: { task_id: str("ID of the task to mark complete") },
|
|
221
|
+
required: ["task_id"],
|
|
222
|
+
additionalProperties: false,
|
|
223
|
+
},
|
|
224
|
+
execute: run(executeTeamTasksComplete, (sessionID) =>
|
|
225
|
+
deps.progressTracker.recordTaskComplete(sessionID),
|
|
226
|
+
),
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
editor.add({
|
|
230
|
+
name: "team_claim",
|
|
231
|
+
description: "Claim a pending task from the shared task list. Only unclaimed, unblocked tasks can be claimed.",
|
|
232
|
+
input: {
|
|
233
|
+
type: "object",
|
|
234
|
+
properties: { task_id: str("ID of the task to claim") },
|
|
235
|
+
required: ["task_id"],
|
|
236
|
+
additionalProperties: false,
|
|
237
|
+
},
|
|
238
|
+
execute: run(executeTeamClaim),
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
editor.add({
|
|
242
|
+
name: "team_results",
|
|
243
|
+
description:
|
|
244
|
+
"Retrieve full message content from teammates. Returns unread messages and marks them as read. Use this after receiving a truncated message notification.",
|
|
245
|
+
input: {
|
|
246
|
+
type: "object",
|
|
247
|
+
properties: { from: str("Filter messages by sender name (optional, returns all if omitted)") },
|
|
248
|
+
additionalProperties: false,
|
|
249
|
+
},
|
|
250
|
+
execute: run(executeTeamResults),
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
editor.add({
|
|
254
|
+
name: "team_shutdown",
|
|
255
|
+
description:
|
|
256
|
+
"Request a teammate to shut down. The teammate finishes current work then stops. Pass force: true to abort immediately without waiting.",
|
|
257
|
+
input: {
|
|
258
|
+
type: "object",
|
|
259
|
+
properties: {
|
|
260
|
+
member: str("Teammate name to shut down"),
|
|
261
|
+
force: bool("Force immediate abort without waiting for current work to finish", false),
|
|
262
|
+
},
|
|
263
|
+
required: ["member"],
|
|
264
|
+
additionalProperties: false,
|
|
265
|
+
},
|
|
266
|
+
execute: run(executeTeamShutdown),
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
editor.add({
|
|
270
|
+
name: "team_cleanup",
|
|
271
|
+
description:
|
|
272
|
+
"Clean up the current team, or purge archived teams after human approval. Omit purge for normal cleanup.",
|
|
273
|
+
input: {
|
|
274
|
+
type: "object",
|
|
275
|
+
properties: {
|
|
276
|
+
force: bool("Force cleanup even if members are active (will abort them)", false),
|
|
277
|
+
acknowledge_uncommitted: bool("Acknowledge uncommitted changes", false),
|
|
278
|
+
purge: {
|
|
279
|
+
type: "array",
|
|
280
|
+
items: { type: "string" },
|
|
281
|
+
description: "Archived team names to permanently delete, or ['*'] for all archived teams.",
|
|
282
|
+
},
|
|
283
|
+
confirm_purge: bool("Set true only after the user explicitly approves the purge preview.", false),
|
|
284
|
+
confirm_token: str("Confirmation token from the purge preview."),
|
|
285
|
+
},
|
|
286
|
+
additionalProperties: false,
|
|
287
|
+
},
|
|
288
|
+
execute: async (input: unknown, context: { sessionID: string }) => {
|
|
289
|
+
const args = input as {
|
|
290
|
+
force?: boolean
|
|
291
|
+
acknowledge_uncommitted?: boolean
|
|
292
|
+
purge?: string[]
|
|
293
|
+
confirm_purge?: boolean
|
|
294
|
+
confirm_token?: string
|
|
295
|
+
}
|
|
296
|
+
const content = await executeTeamCleanup(
|
|
297
|
+
deps,
|
|
298
|
+
{
|
|
299
|
+
force: args.force ?? false,
|
|
300
|
+
acknowledge_uncommitted: args.acknowledge_uncommitted,
|
|
301
|
+
purge: args.purge,
|
|
302
|
+
confirm_purge: args.confirm_purge,
|
|
303
|
+
confirm_token: args.confirm_token,
|
|
304
|
+
},
|
|
305
|
+
context.sessionID,
|
|
306
|
+
)
|
|
307
|
+
return { content }
|
|
308
|
+
},
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
editor.add({
|
|
312
|
+
name: "team_merge",
|
|
313
|
+
description:
|
|
314
|
+
"Merge a shutdown teammate's branch into the working directory as unstaged changes. Use this after team_shutdown to review and integrate a teammate's work. The teammate must be shut down first.",
|
|
315
|
+
input: {
|
|
316
|
+
type: "object",
|
|
317
|
+
properties: { member: str("Teammate name whose branch to merge") },
|
|
318
|
+
required: ["member"],
|
|
319
|
+
additionalProperties: false,
|
|
320
|
+
},
|
|
321
|
+
execute: run(executeTeamMerge),
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
editor.add({
|
|
325
|
+
name: "team_status",
|
|
326
|
+
description:
|
|
327
|
+
"View team members with their current status, agent type, and session IDs. Use this to check who is working, idle, or shut down. Includes a task summary.",
|
|
328
|
+
input: { type: "object", properties: {}, additionalProperties: false },
|
|
329
|
+
execute: runSession(executeTeamStatus),
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
editor.add({
|
|
333
|
+
name: "team_view",
|
|
334
|
+
description:
|
|
335
|
+
"Resolve a teammate's session so the user can inspect it. Default reports the session ID + status without switching. " +
|
|
336
|
+
"navigate: true switches the user's client — ONLY when the user explicitly asked to view the teammate's session.",
|
|
337
|
+
input: {
|
|
338
|
+
type: "object",
|
|
339
|
+
properties: {
|
|
340
|
+
member: str("Teammate name to view"),
|
|
341
|
+
navigate: bool("Switch the user's client to this session. Only when the user explicitly asked.", false),
|
|
342
|
+
},
|
|
343
|
+
required: ["member"],
|
|
344
|
+
additionalProperties: false,
|
|
345
|
+
},
|
|
346
|
+
execute: run(executeTeamView),
|
|
347
|
+
})
|
|
348
|
+
})
|
|
349
|
+
}
|
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 })
|