@hueyexe/opencode-ensemble 0.18.0 → 0.21.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 +45 -24
- package/dist/index.js +17224 -30767
- package/package.json +2 -2
- package/src/config.ts +33 -0
- package/src/dashboard-html.ts +34 -1
- package/src/dashboard-js-events.ts +9 -0
- package/src/dashboard-js-graph-layout.ts +40 -0
- package/src/dashboard-js-graph.ts +99 -0
- package/src/dashboard.ts +8 -19
- package/src/hooks.ts +3 -3
- package/src/index.ts +11 -23
- package/src/member-model.ts +107 -3
- package/src/messaging.ts +4 -2
- package/src/notify.ts +9 -18
- package/src/recovery.ts +23 -16
- package/src/schema.ts +4 -0
- package/src/system-prompt.ts +79 -50
- package/src/task-graph.ts +116 -0
- package/src/tool-arg-hint.ts +71 -0
- package/src/tools/team-broadcast.ts +21 -3
- package/src/tools/team-message.ts +4 -5
- package/src/tools/team-results.ts +15 -5
- package/src/tools/team-spawn.ts +68 -16
- package/src/tools/team-tasks-add.ts +56 -3
- package/src/tools/team-tasks-complete.ts +2 -1
- package/src/types.ts +5 -1
- package/src/v2-client.ts +103 -14
- package/src/v2-setup.ts +16 -3
- package/src/wake.ts +198 -0
package/src/messaging.ts
CHANGED
|
@@ -62,11 +62,13 @@ export function broadcastMessage(db: Database, input: BroadcastMessageInput): st
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
|
-
* Get all undelivered messages for a team.
|
|
65
|
+
* Get all undelivered messages for a team. Messages already read via
|
|
66
|
+
* team_results are excluded — re-injecting or re-sending them would duplicate
|
|
67
|
+
* content the recipient has already seen.
|
|
66
68
|
*/
|
|
67
69
|
export function getUndeliveredMessages(db: Database, teamId: string): MessageRow[] {
|
|
68
70
|
return db.query(
|
|
69
|
-
"SELECT * FROM team_message WHERE team_id = ? AND delivered = 0 ORDER BY time_created ASC"
|
|
71
|
+
"SELECT * FROM team_message WHERE team_id = ? AND read = 0 AND delivered = 0 ORDER BY time_created ASC"
|
|
70
72
|
).all(teamId) as MessageRow[]
|
|
71
73
|
}
|
|
72
74
|
|
package/src/notify.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Database } from "./db"
|
|
2
2
|
import type { PluginClient } from "./types"
|
|
3
3
|
import { sendMessage } from "./messaging"
|
|
4
|
+
import { wakeLeadIfPending } from "./wake"
|
|
4
5
|
import { log } from "./log"
|
|
5
6
|
|
|
6
7
|
type TeamEventType = "spawn" | "message" | "completed" | "error" | "shutdown"
|
|
@@ -90,16 +91,14 @@ export async function notifyWorkingProgress(
|
|
|
90
91
|
* so the message is delivered on the lead's next turn.
|
|
91
92
|
*
|
|
92
93
|
* This is the delivery path for system events (teammate errors, timeouts,
|
|
93
|
-
* stalls, spawn failures).
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* message in the DB with delivered=0.
|
|
94
|
+
* stalls, spawn failures). The wake is forced past the busy check and the
|
|
95
|
+
* coalescing cooldown so an errored/aborted teammate is not stranded while
|
|
96
|
+
* the lead is working; the wake itself is still guarded against
|
|
97
|
+
* archived/purged teams (#48, #57).
|
|
98
98
|
*
|
|
99
|
-
* The message is persisted first; the
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* remain available.
|
|
99
|
+
* The message is persisted first; the wake is fire-and-forget (never awaited,
|
|
100
|
+
* per the plugin's promptAsync invariant). If the wake fails, the message is
|
|
101
|
+
* still in the DB and the passive backstop / recovery paths remain available.
|
|
103
102
|
*
|
|
104
103
|
* Returns the persisted message id.
|
|
105
104
|
*/
|
|
@@ -111,18 +110,10 @@ export function notifyLead(
|
|
|
111
110
|
): string {
|
|
112
111
|
const id = sendMessage(db, { teamId, from: "system", to: "lead", content })
|
|
113
112
|
|
|
114
|
-
const team = db.query("SELECT lead_session_id FROM team WHERE id = ?")
|
|
115
|
-
.get(teamId) as { lead_session_id: string } | null
|
|
116
|
-
if (!team?.lead_session_id) return id
|
|
117
|
-
|
|
118
113
|
// Fire-and-forget wake — the system prompt transform delivers the actual
|
|
119
114
|
// content on the lead's next turn. Synthetic: renders as a persistent
|
|
120
115
|
// system entry, not a fake user turn.
|
|
121
|
-
client.
|
|
122
|
-
sessionID: team.lead_session_id,
|
|
123
|
-
parts: [{ type: "text", text: "[System: New team message from system]" }],
|
|
124
|
-
synthetic: true,
|
|
125
|
-
}).catch((err) => {
|
|
116
|
+
wakeLeadIfPending(client, db, teamId, { force: true }).catch((err) => {
|
|
126
117
|
log(`notifyLead:wake:failed team=${teamId} err=${err instanceof Error ? err.message : String(err)}`)
|
|
127
118
|
})
|
|
128
119
|
|
package/src/recovery.ts
CHANGED
|
@@ -192,20 +192,25 @@ export async function recoverOrphanedWorktrees(db: Database, client: PluginClien
|
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
/**
|
|
195
|
-
*
|
|
195
|
+
* Schedule redelivery of undelivered messages (delivered=0) via promptAsync.
|
|
196
196
|
* Resolves recipient session IDs from the member registry or team lead.
|
|
197
|
-
*
|
|
197
|
+
*
|
|
198
|
+
* Delivery is fire-and-forget: promptAsync is never awaited (AGENTS.md
|
|
199
|
+
* invariant), so a slow or broken transport cannot block recovery/init.
|
|
200
|
+
* Messages are marked delivered only after promptAsync resolves; failures
|
|
201
|
+
* leave them undelivered for the next recovery pass. The returned count is
|
|
202
|
+
* the number of deliveries scheduled, not confirmed.
|
|
198
203
|
*/
|
|
199
204
|
export async function recoverUndeliveredMessages(
|
|
200
205
|
db: Database,
|
|
201
206
|
client: PluginClient,
|
|
202
207
|
registry: MemberRegistry,
|
|
203
|
-
): Promise<{
|
|
208
|
+
): Promise<{ scheduled: number }> {
|
|
204
209
|
// Get all active teams
|
|
205
210
|
const teams = db.query("SELECT id, lead_session_id FROM team WHERE status = 'active'")
|
|
206
211
|
.all() as Array<{ id: string; lead_session_id: string }>
|
|
207
212
|
|
|
208
|
-
let
|
|
213
|
+
let scheduled = 0
|
|
209
214
|
|
|
210
215
|
for (const team of teams) {
|
|
211
216
|
const messages = getUndeliveredMessages(db, team.id)
|
|
@@ -233,22 +238,24 @@ export async function recoverUndeliveredMessages(
|
|
|
233
238
|
continue
|
|
234
239
|
}
|
|
235
240
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
241
|
+
const recipientModel = getMemberModel(db, team.id, msg.to_name)
|
|
242
|
+
// Fire-and-forget, NOT awaited: recovery must not block on transport.
|
|
243
|
+
// Mark delivered only on success; failures stay undelivered for the
|
|
244
|
+
// next recovery pass.
|
|
245
|
+
client.session.promptAsync({
|
|
246
|
+
sessionID: recipientSessionId,
|
|
247
|
+
parts: [{ type: "text", text: `[Recovered team message from ${msg.from_name}]: ${msg.content}` }],
|
|
248
|
+
...(recipientModel ? { model: recipientModel } : {}),
|
|
249
|
+
}).then(() => {
|
|
243
250
|
markDelivered(db, msg.id)
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
251
|
+
}).catch((err) => {
|
|
252
|
+
log(`recovery:redeliver:failed team=${team.id} msg=${msg.id} err=${err instanceof Error ? err.message : String(err)}`)
|
|
253
|
+
})
|
|
254
|
+
scheduled++
|
|
248
255
|
}
|
|
249
256
|
}
|
|
250
257
|
|
|
251
|
-
return {
|
|
258
|
+
return { scheduled }
|
|
252
259
|
}
|
|
253
260
|
|
|
254
261
|
/**
|
package/src/schema.ts
CHANGED
|
@@ -204,6 +204,10 @@ export const MIGRATIONS: string[] = [
|
|
|
204
204
|
ALTER TABLE team_member ADD COLUMN retry_attempt INTEGER;
|
|
205
205
|
ALTER TABLE team_member ADD COLUMN retry_provider TEXT;
|
|
206
206
|
ALTER TABLE team_member ADD COLUMN retry_message TEXT;`,
|
|
207
|
+
// Migration 11: Persist the last lead-wake time on the team. Worktree
|
|
208
|
+
// teammate instances share the SQLite file but not module state, so an
|
|
209
|
+
// in-memory cooldown cannot coalesce wakes across instances (issue #57).
|
|
210
|
+
`ALTER TABLE team ADD COLUMN last_wake_at INTEGER NOT NULL DEFAULT 0;`,
|
|
207
211
|
]
|
|
208
212
|
|
|
209
213
|
/**
|
package/src/system-prompt.ts
CHANGED
|
@@ -49,54 +49,72 @@ export function buildLeadSystemPrompt(db: Database, teamId: string, config?: Req
|
|
|
49
49
|
: "Teammates: none"
|
|
50
50
|
|
|
51
51
|
const pendingMessages = db.query(
|
|
52
|
-
"SELECT id, from_name, content FROM team_message WHERE team_id = ? AND to_name = 'lead' AND delivered = 0 ORDER BY time_created ASC"
|
|
52
|
+
"SELECT id, from_name, content FROM team_message WHERE team_id = ? AND to_name = 'lead' AND read = 0 AND delivered = 0 ORDER BY time_created ASC"
|
|
53
53
|
).all(teamId) as Array<{ id: string; from_name: string; content: string }>
|
|
54
54
|
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
55
|
+
const teamStateMode = config?.systemPromptTeamState ?? "full"
|
|
56
|
+
const lines: string[] = []
|
|
57
|
+
|
|
58
|
+
if (teamStateMode === "off") {
|
|
59
|
+
// Stable role line only. Everything else in this block would churn on
|
|
60
|
+
// status/task/message changes and invalidate the provider prefix cache.
|
|
61
|
+
lines.push(`You are leading team "${team.name}".`)
|
|
62
|
+
} else {
|
|
63
|
+
lines.push(
|
|
64
|
+
`You are leading team "${team.name}" with ${members.length} active teammates.`,
|
|
65
|
+
teammateLine,
|
|
66
|
+
`Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`,
|
|
67
|
+
)
|
|
60
68
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
69
|
+
// Inline active and recently completed tasks
|
|
70
|
+
const activeTasks = db.query(
|
|
71
|
+
"SELECT content, assignee FROM team_task WHERE team_id = ? AND status = 'in_progress' ORDER BY time_updated DESC LIMIT 5"
|
|
72
|
+
).all(teamId) as Array<{ content: string; assignee: string | null }>
|
|
65
73
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
74
|
+
const recentCompleted = db.query(
|
|
75
|
+
"SELECT content, assignee FROM team_task WHERE team_id = ? AND status = 'completed' ORDER BY time_updated DESC LIMIT 3"
|
|
76
|
+
).all(teamId) as Array<{ content: string; assignee: string | null }>
|
|
69
77
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
78
|
+
if (activeTasks.length > 0) {
|
|
79
|
+
lines.push("Active tasks:")
|
|
80
|
+
for (const t of activeTasks) {
|
|
81
|
+
lines.push(` [in_progress] ${truncate(t.content, 120)}${t.assignee ? ` → ${t.assignee}` : ""}`)
|
|
82
|
+
}
|
|
74
83
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
84
|
+
if (recentCompleted.length > 0) {
|
|
85
|
+
lines.push("Recently completed:")
|
|
86
|
+
for (const t of recentCompleted) {
|
|
87
|
+
lines.push(` [completed] ${truncate(t.content, 120)}${t.assignee ? ` → ${t.assignee}` : ""}`)
|
|
88
|
+
}
|
|
80
89
|
}
|
|
81
90
|
}
|
|
82
91
|
|
|
83
92
|
if (pendingMessages.length > 0) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
if (teamStateMode === "full") {
|
|
94
|
+
lines.push("", "--- Team Messages ---")
|
|
95
|
+
const MAX_MSG = 500
|
|
96
|
+
for (const msg of pendingMessages) {
|
|
97
|
+
const parsed = parseTaskResult(msg.content)
|
|
98
|
+
if (parsed) {
|
|
99
|
+
// Truncate details for system prompt — full content available via team_results
|
|
100
|
+
const truncatedResult = { ...parsed, details: truncate(parsed.details, 500) }
|
|
101
|
+
lines.push(formatTaskResult(msg.from_name, truncatedResult))
|
|
102
|
+
} else if (msg.content.length > MAX_MSG) {
|
|
103
|
+
lines.push(`[From ${msg.from_name}]: ${msg.content.slice(0, MAX_MSG)}... (use team_results to read full message)`)
|
|
104
|
+
} else {
|
|
105
|
+
lines.push(`[From ${msg.from_name}]: ${msg.content}`)
|
|
106
|
+
}
|
|
96
107
|
}
|
|
97
|
-
|
|
108
|
+
lines.push("--- End Messages ---")
|
|
109
|
+
} else {
|
|
110
|
+
// state-only and off both surface the count (not the payload) so a
|
|
111
|
+
// message arriving mid-turn cannot be silently consumed by the transform.
|
|
112
|
+
lines.push("", `Unread messages: ${pendingMessages.length} — use team_results to read them.`)
|
|
98
113
|
}
|
|
99
|
-
|
|
114
|
+
// Mark delivered in every mode so the wake path does not re-fire for
|
|
115
|
+
// content the lead has already been told about. team_results still
|
|
116
|
+
// returns these until they are read.
|
|
117
|
+
for (const msg of pendingMessages) markDelivered(db, msg.id)
|
|
100
118
|
}
|
|
101
119
|
|
|
102
120
|
// Model selection guidance (only when promptForModels is enabled)
|
|
@@ -140,6 +158,7 @@ export function buildLeadSystemPrompt(db: Database, teamId: string, config?: Req
|
|
|
140
158
|
"",
|
|
141
159
|
"Teammates work asynchronously and message you when done.",
|
|
142
160
|
"Do NOT poll team_status or team_tasks_list repeatedly — wait for messages.",
|
|
161
|
+
"When woken about new team messages, call team_results to read them.",
|
|
143
162
|
"After spawning all teammates, tell the user what you've set up and wait.",
|
|
144
163
|
"When all teammates finish, summarize results and suggest next steps.",
|
|
145
164
|
"",
|
|
@@ -166,34 +185,44 @@ export function buildLeadSystemPrompt(db: Database, teamId: string, config?: Req
|
|
|
166
185
|
* Build the system prompt injected into a teammate's session.
|
|
167
186
|
* Includes role reminder and delivers any pending peer messages.
|
|
168
187
|
*/
|
|
169
|
-
export function buildTeammateSystemPrompt(
|
|
188
|
+
export function buildTeammateSystemPrompt(
|
|
189
|
+
db: Database,
|
|
190
|
+
teamId: string,
|
|
191
|
+
memberName: string,
|
|
192
|
+
config?: Required<EnsembleConfig>,
|
|
193
|
+
): string {
|
|
170
194
|
const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId) as { name: string } | null
|
|
171
195
|
if (!team) return ""
|
|
172
196
|
|
|
197
|
+
const teamStateMode = config?.systemPromptTeamState ?? "full"
|
|
173
198
|
const lines = [
|
|
174
199
|
`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.`,
|
|
175
200
|
]
|
|
176
201
|
|
|
177
202
|
// Deliver pending peer messages addressed to this teammate
|
|
178
203
|
const pendingMessages = db.query(
|
|
179
|
-
"SELECT id, from_name, content FROM team_message WHERE team_id = ? AND to_name = ? AND delivered = 0 ORDER BY time_created ASC"
|
|
204
|
+
"SELECT id, from_name, content FROM team_message WHERE team_id = ? AND to_name = ? AND read = 0 AND delivered = 0 ORDER BY time_created ASC"
|
|
180
205
|
).all(teamId, memberName) as Array<{ id: string; from_name: string; content: string }>
|
|
181
206
|
|
|
182
207
|
if (pendingMessages.length > 0) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
208
|
+
if (teamStateMode === "full") {
|
|
209
|
+
lines.push("", "--- Messages for you ---")
|
|
210
|
+
const MAX_MSG = 500
|
|
211
|
+
for (const msg of pendingMessages) {
|
|
212
|
+
const parsed = parseTaskResult(msg.content)
|
|
213
|
+
if (parsed) {
|
|
214
|
+
lines.push(formatTaskResult(msg.from_name, { ...parsed, details: truncate(parsed.details, MAX_MSG) }))
|
|
215
|
+
} else if (msg.content.length > MAX_MSG) {
|
|
216
|
+
lines.push(`[From ${msg.from_name}]: ${msg.content.slice(0, MAX_MSG)}... (truncated)`)
|
|
217
|
+
} else {
|
|
218
|
+
lines.push(`[From ${msg.from_name}]: ${msg.content}`)
|
|
219
|
+
}
|
|
193
220
|
}
|
|
194
|
-
|
|
221
|
+
lines.push("--- End Messages ---")
|
|
222
|
+
} else {
|
|
223
|
+
lines.push("", `Unread messages: ${pendingMessages.length} — use team_results to read them.`)
|
|
195
224
|
}
|
|
196
|
-
|
|
225
|
+
for (const msg of pendingMessages) markDelivered(db, msg.id)
|
|
197
226
|
}
|
|
198
227
|
|
|
199
228
|
return lines.join("\n")
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/** A task node as needed for dependency graph inspection. */
|
|
2
|
+
export interface TaskGraphNode {
|
|
3
|
+
id: string
|
|
4
|
+
status: string
|
|
5
|
+
dependsOn: string[]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Result of inspecting a team's task dependency graph. */
|
|
9
|
+
export interface TaskGraphInfo {
|
|
10
|
+
/** Longest-prerequisite-path depth per task (roots are 1). */
|
|
11
|
+
depthByTask: Map<string, number>
|
|
12
|
+
/** Tasks that are part of a dependency cycle. */
|
|
13
|
+
cyclic: string[]
|
|
14
|
+
/** Dependency IDs that do not resolve to a task in the graph. */
|
|
15
|
+
missing: string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse a `depends_on` column into a deduplicated list of task IDs.
|
|
20
|
+
* Tolerates malformed/legacy values instead of throwing, so one bad row
|
|
21
|
+
* cannot break task completion or graph rendering.
|
|
22
|
+
*/
|
|
23
|
+
export function parseTaskDependencies(value: string | null): string[] {
|
|
24
|
+
if (!value) return []
|
|
25
|
+
try {
|
|
26
|
+
const parsed: unknown = JSON.parse(value)
|
|
27
|
+
if (Array.isArray(parsed)) {
|
|
28
|
+
return [...new Set(parsed.filter((item): item is string => typeof item === "string" && item.length > 0))]
|
|
29
|
+
}
|
|
30
|
+
if (typeof parsed === "string" && parsed.length > 0) return [parsed]
|
|
31
|
+
} catch {
|
|
32
|
+
// Legacy/hand-edited plain string.
|
|
33
|
+
return [value]
|
|
34
|
+
}
|
|
35
|
+
return []
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Options for graph inspection. */
|
|
39
|
+
export interface TaskGraphOptions {
|
|
40
|
+
/**
|
|
41
|
+
* Ignore edges whose prerequisite is already completed/cancelled. Use this
|
|
42
|
+
* for depth-limit enforcement so finished work does not accumulate into a
|
|
43
|
+
* lifetime chain that blocks follow-up tasks.
|
|
44
|
+
*/
|
|
45
|
+
activeOnly?: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Inspect a task graph. Depth is the longest prerequisite path, with roots at
|
|
50
|
+
* 1 (a four-task chain has depth 4). Cyclic/cycle-affected tasks are reported
|
|
51
|
+
* and omitted from depthByTask so callers can reject additions that would
|
|
52
|
+
* build on corrupt data.
|
|
53
|
+
*/
|
|
54
|
+
export function inspectTaskGraph(tasks: TaskGraphNode[], options: TaskGraphOptions = {}): TaskGraphInfo {
|
|
55
|
+
const byId = new Map(tasks.map(task => [task.id, task]))
|
|
56
|
+
const deps = new Map<string, string[]>()
|
|
57
|
+
const children = new Map<string, string[]>()
|
|
58
|
+
const indegree = new Map<string, number>()
|
|
59
|
+
const missing = new Set<string>()
|
|
60
|
+
|
|
61
|
+
tasks.forEach(task => {
|
|
62
|
+
children.set(task.id, [])
|
|
63
|
+
indegree.set(task.id, 0)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
tasks.forEach(task => {
|
|
67
|
+
const valid: string[] = []
|
|
68
|
+
for (const depId of task.dependsOn) {
|
|
69
|
+
const dependency = byId.get(depId)
|
|
70
|
+
if (!dependency) {
|
|
71
|
+
missing.add(depId)
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
if (options.activeOnly && (dependency.status === "completed" || dependency.status === "cancelled")) continue
|
|
75
|
+
if (valid.includes(depId)) continue
|
|
76
|
+
valid.push(depId)
|
|
77
|
+
children.get(depId)?.push(task.id)
|
|
78
|
+
}
|
|
79
|
+
deps.set(task.id, valid)
|
|
80
|
+
indegree.set(task.id, valid.length)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const queue = tasks.filter(task => indegree.get(task.id) === 0).map(task => task.id)
|
|
84
|
+
const depthByTask = new Map<string, number>()
|
|
85
|
+
let head = 0
|
|
86
|
+
while (head < queue.length) {
|
|
87
|
+
const id = queue[head]
|
|
88
|
+
head++
|
|
89
|
+
if (!id) continue
|
|
90
|
+
const task = byId.get(id)
|
|
91
|
+
const parents = deps.get(id) ?? []
|
|
92
|
+
const resolved = options.activeOnly && (task?.status === "completed" || task?.status === "cancelled")
|
|
93
|
+
depthByTask.set(
|
|
94
|
+
id,
|
|
95
|
+
resolved
|
|
96
|
+
? 0
|
|
97
|
+
: parents.length === 0
|
|
98
|
+
? 1
|
|
99
|
+
: Math.max(...parents.map(parent => depthByTask.get(parent) ?? 1)) + 1,
|
|
100
|
+
)
|
|
101
|
+
for (const child of children.get(id) ?? []) {
|
|
102
|
+
const next = (indegree.get(child) ?? 1) - 1
|
|
103
|
+
indegree.set(child, next)
|
|
104
|
+
if (next === 0) queue.push(child)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const cyclic = tasks.filter(task => !depthByTask.has(task.id)).map(task => task.id)
|
|
109
|
+
return { depthByTask, cyclic, missing: [...missing] }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Depth a new task would have if it depended on `dependencyIds`. */
|
|
113
|
+
export function depthForDependencies(dependencyIds: string[], graph: TaskGraphInfo): number {
|
|
114
|
+
if (dependencyIds.length === 0) return 1
|
|
115
|
+
return Math.max(...dependencyIds.map(id => graph.depthByTask.get(id) ?? 1)) + 1
|
|
116
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Weak-model tool-argument fumble detection and error hints (issue #39).
|
|
3
|
+
*
|
|
4
|
+
* Some models trained on other harness conventions call OpenCode's built-in
|
|
5
|
+
* tools with aliased parameter names (e.g. `cmd` instead of the `shell`
|
|
6
|
+
* tool's `command`). OpenCode V2 rejects the call with a generic validation
|
|
7
|
+
* error that the model may fail to act on. This module detects that exact
|
|
8
|
+
* dead-call shape and produces an explicit hint appended to the error so
|
|
9
|
+
* the model can self-correct. Arguments are never rewritten — the model
|
|
10
|
+
* stays in the correction loop.
|
|
11
|
+
*
|
|
12
|
+
* V2 only: on V1, argument validation happens at the model-stream parse
|
|
13
|
+
* level, so tool hooks never see the failed call (see issue #39 triage).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Canonical parameter plus the aliases weak models emit for it. Only the
|
|
18
|
+
* confirmed failure mode is listed; extend per-tool as reports come in.
|
|
19
|
+
*/
|
|
20
|
+
const ARG_ALIASES: Readonly<Record<string, { param: string; aliases: readonly string[] }>> = {
|
|
21
|
+
shell: { param: "command", aliases: ["cmd", "bash", "script"] },
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Return the alias a weak model used instead of the tool's canonical
|
|
26
|
+
* parameter, or null when the input is not a recognized fumble. A fumble is
|
|
27
|
+
* only recognized when the canonical parameter is absent and a known alias
|
|
28
|
+
* is present as a non-empty string — i.e. only on calls that are already
|
|
29
|
+
* guaranteed to fail validation.
|
|
30
|
+
*/
|
|
31
|
+
export function detectArgFumble(tool: string, input: unknown): string | null {
|
|
32
|
+
const entry = ARG_ALIASES[tool]
|
|
33
|
+
if (!entry) return null
|
|
34
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) return null
|
|
35
|
+
const args = input as Record<string, unknown>
|
|
36
|
+
if (entry.param in args) return null
|
|
37
|
+
for (const alias of entry.aliases) {
|
|
38
|
+
const value = args[alias]
|
|
39
|
+
if (typeof value === "string" && value !== "") return alias
|
|
40
|
+
}
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build the hint text for a fumbled tool call, or null when the input is
|
|
46
|
+
* not a recognized fumble.
|
|
47
|
+
*/
|
|
48
|
+
export function buildArgHint(tool: string, input: unknown): string | null {
|
|
49
|
+
const entry = ARG_ALIASES[tool]
|
|
50
|
+
if (!entry) return null
|
|
51
|
+
const alias = detectArgFumble(tool, input)
|
|
52
|
+
if (!alias) return null
|
|
53
|
+
return (
|
|
54
|
+
`Hint: the "${tool}" tool has no "${alias}" parameter. The correct parameter ` +
|
|
55
|
+
`name is "${entry.param}" — call the tool again with the same arguments, ` +
|
|
56
|
+
`moving the "${alias}" value into "${entry.param}".`
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Append the fumble hint to a failed tool call's error message in place.
|
|
62
|
+
* Returns true when the message was modified. Never throws — a malformed
|
|
63
|
+
* error or input leaves the original message untouched.
|
|
64
|
+
*/
|
|
65
|
+
export function enrichToolError(tool: string, error: { message?: unknown }, input: unknown): boolean {
|
|
66
|
+
if (typeof error.message !== "string") return false
|
|
67
|
+
const hint = buildArgHint(tool, input)
|
|
68
|
+
if (!hint) return false
|
|
69
|
+
error.message = `${error.message}\n\n${hint}`
|
|
70
|
+
return true
|
|
71
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
2
|
import { requireTeamMember } from "./shared"
|
|
3
3
|
import { broadcastMessage, markDelivered, hasReportedCompletion } from "../messaging"
|
|
4
|
+
import { getSessionSelection } from "../member-model"
|
|
4
5
|
import { log } from "../log"
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -24,11 +25,19 @@ export async function executeTeamBroadcast(
|
|
|
24
25
|
// Collect all recipient session IDs (excluding sender)
|
|
25
26
|
const recipients: Array<{ name: string; sessionId: string }> = []
|
|
26
27
|
|
|
27
|
-
// Add lead if sender is not lead
|
|
28
|
+
// Add lead if sender is not lead. Resolve from the DB fresh: the team may
|
|
29
|
+
// have been archived since requireTeamMember() ran.
|
|
30
|
+
let leadSelection: Awaited<ReturnType<typeof getSessionSelection>> = {}
|
|
28
31
|
if (teamInfo.role !== "lead") {
|
|
29
|
-
const leadSession = deps.db.query("SELECT lead_session_id FROM team WHERE id = ?")
|
|
32
|
+
const leadSession = deps.db.query("SELECT lead_session_id FROM team WHERE id = ? AND status = 'active'")
|
|
30
33
|
.get(teamInfo.teamId) as { lead_session_id: string } | null
|
|
31
|
-
if (leadSession)
|
|
34
|
+
if (leadSession?.lead_session_id) {
|
|
35
|
+
recipients.push({ name: "lead", sessionId: leadSession.lead_session_id })
|
|
36
|
+
// V1 broadcasts are normal user turns; preserve the lead's agent/model (#37).
|
|
37
|
+
if (!deps.client.agent) {
|
|
38
|
+
leadSelection = await getSessionSelection(deps.client, leadSession.lead_session_id).catch(() => ({}))
|
|
39
|
+
}
|
|
40
|
+
}
|
|
32
41
|
}
|
|
33
42
|
|
|
34
43
|
// Add all members except sender
|
|
@@ -48,9 +57,18 @@ export async function executeTeamBroadcast(
|
|
|
48
57
|
skipped++
|
|
49
58
|
continue
|
|
50
59
|
}
|
|
60
|
+
if (recipient.name === "lead") {
|
|
61
|
+
const active = deps.db.query("SELECT 1 FROM team WHERE id = ? AND status = 'active'").get(teamInfo.teamId)
|
|
62
|
+
if (!active) {
|
|
63
|
+
skipped++
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
}
|
|
51
67
|
deps.client.session.promptAsync({
|
|
52
68
|
sessionID: recipient.sessionId,
|
|
53
69
|
parts: [{ type: "text", text: `[Team broadcast from ${senderName}]: ${args.text}` }],
|
|
70
|
+
...(recipient.name === "lead" && leadSelection.agent ? { agent: leadSelection.agent } : {}),
|
|
71
|
+
...(recipient.name === "lead" && leadSelection.model ? { model: leadSelection.model } : {}),
|
|
54
72
|
}).then(() => {
|
|
55
73
|
delivered++
|
|
56
74
|
if (delivered === 1) markDelivered(deps.db, msgId)
|
|
@@ -3,6 +3,7 @@ import { resolveRecipientSession } from "../types"
|
|
|
3
3
|
import { requireTeamMember } from "./shared"
|
|
4
4
|
import { sendMessage, markDelivered, hasReportedCompletion } from "../messaging"
|
|
5
5
|
import { parseModelId, getMemberModel } from "../member-model"
|
|
6
|
+
import { wakeLeadIfPending } from "../wake"
|
|
6
7
|
import { log } from "../log"
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -122,11 +123,9 @@ export async function executeTeamMessage(
|
|
|
122
123
|
// because session.idle events are scoped per-instance.
|
|
123
124
|
if (isToLead) {
|
|
124
125
|
log(`team_message:wake-lead from=${senderName} recipientSession=${recipientSessionId}`)
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
synthetic: true,
|
|
129
|
-
}).catch((err) => {
|
|
126
|
+
// Coalesced, guarded wake: skips when the team is gone or the lead has
|
|
127
|
+
// already drained the inbox, and never queues behind a busy lead (#48, #57).
|
|
128
|
+
wakeLeadIfPending(deps.client, deps.db, teamInfo.teamId).catch((err) => {
|
|
130
129
|
log(`team_message:wake-lead:failed from=${senderName} err=${err instanceof Error ? err.message : String(err)}`)
|
|
131
130
|
})
|
|
132
131
|
return `Message sent to ${args.to}.`
|
|
@@ -11,8 +11,13 @@ interface UnreadMessageRow {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* Retrieve unread messages from the
|
|
14
|
+
* Retrieve unread messages from the caller's inbox.
|
|
15
15
|
* Optionally filter by sender name. Marks returned messages as read.
|
|
16
|
+
*
|
|
17
|
+
* The query is recipient-scoped: a teammate calling team_results must not
|
|
18
|
+
* drain messages addressed to the lead (or to another teammate), or those
|
|
19
|
+
* messages would disappear from the recipient's system prompt and wake path.
|
|
20
|
+
* Broadcast rows (to_name IS NULL) are visible to every team member.
|
|
16
21
|
*/
|
|
17
22
|
export async function executeTeamResults(
|
|
18
23
|
deps: ToolDeps,
|
|
@@ -20,18 +25,23 @@ export async function executeTeamResults(
|
|
|
20
25
|
sessionId: string,
|
|
21
26
|
): Promise<string> {
|
|
22
27
|
const team = requireTeamMember(deps, sessionId)
|
|
28
|
+
const inbox = team.role === "lead" ? "lead" : (team.memberName ?? "")
|
|
23
29
|
|
|
24
30
|
const rows = args.from
|
|
25
31
|
? (deps.db
|
|
26
32
|
.query(
|
|
27
|
-
|
|
33
|
+
`SELECT id, from_name, content, time_created FROM team_message
|
|
34
|
+
WHERE team_id = ? AND read = 0 AND (to_name = ? OR to_name IS NULL) AND from_name = ?
|
|
35
|
+
ORDER BY time_created ASC`,
|
|
28
36
|
)
|
|
29
|
-
.all(team.teamId, args.from) as UnreadMessageRow[])
|
|
37
|
+
.all(team.teamId, inbox, args.from) as UnreadMessageRow[])
|
|
30
38
|
: (deps.db
|
|
31
39
|
.query(
|
|
32
|
-
|
|
40
|
+
`SELECT id, from_name, content, time_created FROM team_message
|
|
41
|
+
WHERE team_id = ? AND read = 0 AND (to_name = ? OR to_name IS NULL)
|
|
42
|
+
ORDER BY time_created ASC`,
|
|
33
43
|
)
|
|
34
|
-
.all(team.teamId) as UnreadMessageRow[])
|
|
44
|
+
.all(team.teamId, inbox) as UnreadMessageRow[])
|
|
35
45
|
|
|
36
46
|
if (rows.length === 0) return "No unread messages."
|
|
37
47
|
|