@hueyexe/opencode-ensemble 0.20.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 +29 -16
- package/dist/index.js +13617 -13116
- package/package.json +1 -1
- 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/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 +41 -13
- package/src/tools/team-tasks-add.ts +41 -5
- package/src/tools/team-tasks-complete.ts +2 -1
- package/src/types.ts +1 -1
- package/src/v2-client.ts +5 -1
- package/src/v2-setup.ts +2 -2
- package/src/wake.ts +198 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
2
|
import { requireTeamMember } from "./shared"
|
|
3
3
|
import { generateId } from "../util"
|
|
4
|
+
import { inspectTaskGraph, parseTaskDependencies, depthForDependencies } from "../task-graph"
|
|
4
5
|
|
|
5
6
|
interface TaskInput {
|
|
6
7
|
content: string
|
|
@@ -18,13 +19,17 @@ export async function executeTeamTasksAdd(
|
|
|
18
19
|
sessionId: string,
|
|
19
20
|
): Promise<string> {
|
|
20
21
|
const teamInfo = requireTeamMember(deps, sessionId)
|
|
22
|
+
const tasks = args.tasks.map(task => ({
|
|
23
|
+
...task,
|
|
24
|
+
depends_on: [...new Set(task.depends_on ?? [])],
|
|
25
|
+
}))
|
|
21
26
|
|
|
22
27
|
// Reject unknown dependency IDs before inserting anything: a task blocked on
|
|
23
28
|
// an ID that never existed can never unblock (issue #40). IDs are generated
|
|
24
29
|
// here, so new tasks can only depend on existing ones and no cycle can form.
|
|
25
30
|
const unknown = new Set<string>()
|
|
26
|
-
for (const task of
|
|
27
|
-
for (const depId of task.depends_on
|
|
31
|
+
for (const task of tasks) {
|
|
32
|
+
for (const depId of task.depends_on) {
|
|
28
33
|
const exists = deps.db.query("SELECT 1 FROM team_task WHERE id = ? AND team_id = ?").get(depId, teamInfo.teamId)
|
|
29
34
|
if (!exists) unknown.add(depId)
|
|
30
35
|
}
|
|
@@ -36,16 +41,47 @@ export async function executeTeamTasksAdd(
|
|
|
36
41
|
)
|
|
37
42
|
}
|
|
38
43
|
|
|
44
|
+
// Optional depth guard (issue #42). Default is unlimited; when configured,
|
|
45
|
+
// reject the whole batch before any insert so a failed add is atomic.
|
|
46
|
+
const depthLimit = deps.config.taskDepthLimit
|
|
47
|
+
if (depthLimit > 0) {
|
|
48
|
+
const existing = deps.db.query("SELECT id, status, depends_on FROM team_task WHERE team_id = ?")
|
|
49
|
+
.all(teamInfo.teamId) as Array<{ id: string; status: string; depends_on: string | null }>
|
|
50
|
+
const graph = inspectTaskGraph(existing.map(row => ({
|
|
51
|
+
id: row.id,
|
|
52
|
+
status: row.status,
|
|
53
|
+
dependsOn: parseTaskDependencies(row.depends_on),
|
|
54
|
+
})), { activeOnly: true })
|
|
55
|
+
|
|
56
|
+
if (graph.cyclic.length > 0) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Task graph has a dependency cycle (${graph.cyclic.join(", ")}). Repair it before adding more tasks.`,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const task of tasks) {
|
|
63
|
+
const depth = depthForDependencies(task.depends_on, graph)
|
|
64
|
+
if (depth <= depthLimit) continue
|
|
65
|
+
const resolution = deps.config.taskDepthLimitOverflowAction === "user_resolve"
|
|
66
|
+
? "Ask the user whether to split the work into a shallower chain, then retry."
|
|
67
|
+
: "Split the work into shallower batches, add an independent task, or raise taskDepthLimit."
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Task depth ${depth} exceeds taskDepthLimit ${depthLimit}. ` +
|
|
70
|
+
`The new task depends on ${task.depends_on.join(", ")}. ${resolution}`,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
39
75
|
const ids: string[] = []
|
|
40
76
|
const now = Date.now()
|
|
41
77
|
|
|
42
|
-
for (const task of
|
|
78
|
+
for (const task of tasks) {
|
|
43
79
|
const id = generateId("task")
|
|
44
|
-
const depsJson = task.depends_on
|
|
80
|
+
const depsJson = task.depends_on.length ? JSON.stringify(task.depends_on) : null
|
|
45
81
|
|
|
46
82
|
// Determine initial status — blocked if has unresolved dependencies
|
|
47
83
|
let status = "pending"
|
|
48
|
-
if (task.depends_on
|
|
84
|
+
if (task.depends_on.length) {
|
|
49
85
|
const resolved = task.depends_on.every(depId => {
|
|
50
86
|
const dep = deps.db.query("SELECT status FROM team_task WHERE id = ? AND team_id = ?")
|
|
51
87
|
.get(depId, teamInfo.teamId) as { status: string } | null
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
2
|
import { requireTeamMember } from "./shared"
|
|
3
|
+
import { parseTaskDependencies } from "../task-graph"
|
|
3
4
|
import { log } from "../log"
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -51,7 +52,7 @@ export async function executeTeamTasksComplete(
|
|
|
51
52
|
let unblocked = 0
|
|
52
53
|
for (const t of allTasks) {
|
|
53
54
|
if (t.status !== "blocked" || !t.depends_on) continue
|
|
54
|
-
const depIds
|
|
55
|
+
const depIds = parseTaskDependencies(t.depends_on)
|
|
55
56
|
if (!depIds.includes(args.task_id)) continue
|
|
56
57
|
|
|
57
58
|
const allResolved = depIds.every(depId => {
|
package/src/types.ts
CHANGED
|
@@ -50,7 +50,7 @@ export interface PluginClient {
|
|
|
50
50
|
promptAsync(options: {
|
|
51
51
|
sessionID: string
|
|
52
52
|
parts: Array<{ type: "text"; text: string }>
|
|
53
|
-
model?: { providerID: string; modelID: string }
|
|
53
|
+
model?: { providerID: string; modelID: string; variant?: string }
|
|
54
54
|
agent?: string
|
|
55
55
|
tools?: Record<string, boolean>
|
|
56
56
|
system?: string
|
package/src/v2-client.ts
CHANGED
|
@@ -215,7 +215,11 @@ export function createV2Client(ctx: V2Context, deps: V2ClientDeps = {}): PluginC
|
|
|
215
215
|
if (options.model) {
|
|
216
216
|
await ctx.session.switchModel({
|
|
217
217
|
sessionID: options.sessionID,
|
|
218
|
-
model: {
|
|
218
|
+
model: {
|
|
219
|
+
providerID: options.model.providerID,
|
|
220
|
+
id: options.model.modelID,
|
|
221
|
+
...(options.model.variant ? { variant: options.model.variant } : {}),
|
|
222
|
+
},
|
|
219
223
|
})
|
|
220
224
|
}
|
|
221
225
|
const text = joinParts(options.parts)
|
package/src/v2-setup.ts
CHANGED
|
@@ -294,7 +294,7 @@ export async function setupEnsemble(
|
|
|
294
294
|
const prompt =
|
|
295
295
|
teamInfo.role === "lead"
|
|
296
296
|
? buildLeadSystemPrompt(db, teamInfo.teamId, config)
|
|
297
|
-
: buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName ?? "unknown")
|
|
297
|
+
: buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName ?? "unknown", config)
|
|
298
298
|
vlog(`system-prompt:injected role=${teamInfo.role} len=${prompt.length}`)
|
|
299
299
|
event.system.push({ type: "text", text: prompt })
|
|
300
300
|
if (teamInfo.role === "member") {
|
|
@@ -343,7 +343,7 @@ export async function setupEnsemble(
|
|
|
343
343
|
let dashboard: DashboardServer | null = null
|
|
344
344
|
const dashboardPort = options.dashboardPort ?? config.dashboardPort
|
|
345
345
|
if (dashboardPort !== 0 && !isWorktreeInstance(ctx.location.directory)) {
|
|
346
|
-
dashboard = await startDashboard(db, dashboardPort, { activityBuffer, client }).catch((err) => {
|
|
346
|
+
dashboard = await startDashboard(db, dashboardPort, { activityBuffer, client, host: config.dashboardHost }).catch((err) => {
|
|
347
347
|
vlog(`init:dashboard:failed err=${err instanceof Error ? err.message : String(err)}`)
|
|
348
348
|
return null
|
|
349
349
|
})
|
package/src/wake.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { Database } from "./db"
|
|
2
|
+
import type { PluginClient } from "./types"
|
|
3
|
+
import { getSessionSelection } from "./member-model"
|
|
4
|
+
import { log } from "./log"
|
|
5
|
+
|
|
6
|
+
/** Options for a lead wake. */
|
|
7
|
+
export interface WakeLeadOptions {
|
|
8
|
+
/** Send even when the lead session is busy, and skip the cooldown (system/error notifications). */
|
|
9
|
+
force?: boolean
|
|
10
|
+
/** Coalescing window override (tests); defaults to {@link WAKE_COOLDOWN_MS}. */
|
|
11
|
+
cooldownMs?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Per-database coalescing state, so separate plugin instances/DBs don't share it. */
|
|
15
|
+
interface WakeState {
|
|
16
|
+
/** Teams with a wake currently being resolved/sent. */
|
|
17
|
+
inFlight: Set<string>
|
|
18
|
+
/** Trailing retries scheduled after a cooldown skip. */
|
|
19
|
+
timers: Map<string, ReturnType<typeof setTimeout>>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const states = new WeakMap<Database, WakeState>()
|
|
23
|
+
const WAKE_COOLDOWN_MS = 5000
|
|
24
|
+
|
|
25
|
+
function stateFor(db: Database): WakeState {
|
|
26
|
+
let state = states.get(db)
|
|
27
|
+
if (!state) {
|
|
28
|
+
state = { inFlight: new Set(), timers: new Map() }
|
|
29
|
+
states.set(db, state)
|
|
30
|
+
}
|
|
31
|
+
return state
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface PendingLeadWake {
|
|
35
|
+
leadSessionID: string
|
|
36
|
+
count: number
|
|
37
|
+
senders: string[]
|
|
38
|
+
lastWakeAt: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve whether a team still has messages the lead has neither seen in the
|
|
43
|
+
* system prompt (delivered) nor read via team_results (read). Resolving from
|
|
44
|
+
* the DB at fire time is what prevents wakes from landing in an archived or
|
|
45
|
+
* purged team (#48) or after the inbox was drained (#57).
|
|
46
|
+
*/
|
|
47
|
+
function pendingLeadWake(db: Database, teamId: string): PendingLeadWake | undefined {
|
|
48
|
+
const team = db.query("SELECT lead_session_id, last_wake_at FROM team WHERE id = ? AND status = 'active'")
|
|
49
|
+
.get(teamId) as { lead_session_id: string; last_wake_at: number } | null
|
|
50
|
+
if (!team?.lead_session_id) return undefined
|
|
51
|
+
|
|
52
|
+
const row = db.query(
|
|
53
|
+
`SELECT COUNT(*) as c, GROUP_CONCAT(DISTINCT from_name) as senders
|
|
54
|
+
FROM team_message
|
|
55
|
+
WHERE team_id = ? AND to_name = 'lead' AND read = 0 AND delivered = 0`,
|
|
56
|
+
).get(teamId) as { c: number; senders: string | null }
|
|
57
|
+
if (row.c === 0) return undefined
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
leadSessionID: team.lead_session_id,
|
|
61
|
+
count: row.c,
|
|
62
|
+
senders: row.senders ? row.senders.split(",").filter(Boolean) : [],
|
|
63
|
+
lastWakeAt: team.last_wake_at,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Schedule one trailing re-check after a cooldown skip. */
|
|
68
|
+
function scheduleCooldownRetry(
|
|
69
|
+
client: PluginClient,
|
|
70
|
+
db: Database,
|
|
71
|
+
teamId: string,
|
|
72
|
+
options: WakeLeadOptions,
|
|
73
|
+
delayMs: number,
|
|
74
|
+
): void {
|
|
75
|
+
const state = stateFor(db)
|
|
76
|
+
if (state.timers.has(teamId)) return
|
|
77
|
+
const timer = setTimeout(() => {
|
|
78
|
+
state.timers.delete(teamId)
|
|
79
|
+
wakeLeadIfPending(client, db, teamId, options).catch((err) => {
|
|
80
|
+
log(`wake:retry:failed team=${teamId} err=${err instanceof Error ? err.message : String(err)}`)
|
|
81
|
+
})
|
|
82
|
+
}, Math.max(0, delayMs))
|
|
83
|
+
;(timer as { unref?: () => void }).unref?.()
|
|
84
|
+
state.timers.set(teamId, timer)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Best-effort busy check. Unknown status is treated as idle so wakes are not lost. */
|
|
88
|
+
async function isSessionBusy(client: PluginClient, sessionID: string): Promise<boolean> {
|
|
89
|
+
try {
|
|
90
|
+
const status = await client.session.status()
|
|
91
|
+
const entry = status.data?.[sessionID]
|
|
92
|
+
return entry?.type === "busy" || entry?.type === "retry"
|
|
93
|
+
} catch {
|
|
94
|
+
return false
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Wake a team's lead only when the team is still active and has genuinely
|
|
100
|
+
* unseen messages. The team and pending count are resolved at fire time, so a
|
|
101
|
+
* wake scheduled before team_cleanup cannot wake the lead into a dead team.
|
|
102
|
+
*
|
|
103
|
+
* Coalesces bursts: concurrent calls for the same team collapse into one, and
|
|
104
|
+
* a successful wake suppresses further wakes for {@link WAKE_COOLDOWN_MS}.
|
|
105
|
+
* A busy lead is left alone (the idle backstop will wake it) unless
|
|
106
|
+
* `force` is set for system/error notifications.
|
|
107
|
+
*
|
|
108
|
+
* Fire-and-forget by design: callers should not await this in delivery paths.
|
|
109
|
+
* Returns true when a wake was actually sent.
|
|
110
|
+
*/
|
|
111
|
+
export async function wakeLeadIfPending(
|
|
112
|
+
client: PluginClient,
|
|
113
|
+
db: Database,
|
|
114
|
+
teamId: string,
|
|
115
|
+
options: WakeLeadOptions = {},
|
|
116
|
+
): Promise<boolean> {
|
|
117
|
+
const state = stateFor(db)
|
|
118
|
+
if (state.inFlight.has(teamId)) return false
|
|
119
|
+
|
|
120
|
+
const before = pendingLeadWake(db, teamId)
|
|
121
|
+
if (!before) {
|
|
122
|
+
log(`wake:skip team=${teamId} reason=no-unseen`)
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const cooldownMs = options.cooldownMs ?? WAKE_COOLDOWN_MS
|
|
127
|
+
const now = Date.now()
|
|
128
|
+
if (!options.force && now - before.lastWakeAt < cooldownMs) {
|
|
129
|
+
const remaining = cooldownMs - (now - before.lastWakeAt)
|
|
130
|
+
scheduleCooldownRetry(client, db, teamId, options, remaining)
|
|
131
|
+
log(`wake:skip team=${teamId} reason=cooldown retryIn=${remaining}ms`)
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
state.inFlight.add(teamId)
|
|
136
|
+
try {
|
|
137
|
+
// V2 synthetic wakes use queue delivery and are designed to wait for a busy
|
|
138
|
+
// session; V2 also has no lead-idle backstop, so skipping here would strand
|
|
139
|
+
// the message. On V1 the passive idle path retries, so a busy lead is left
|
|
140
|
+
// alone unless the caller forces the wake (system/error notifications).
|
|
141
|
+
const isV2 = Boolean(client.agent)
|
|
142
|
+
if (!options.force && !isV2 && await isSessionBusy(client, before.leadSessionID)) {
|
|
143
|
+
log(`wake:skip team=${teamId} reason=lead-busy`)
|
|
144
|
+
return false
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// On V1 a wake is a normal user turn, so pass the lead's current agent and
|
|
148
|
+
// model explicitly to stop OpenCode falling back to Build/default (#37).
|
|
149
|
+
// V2 wakes are synthetic and ignore both, so skip the lookup there.
|
|
150
|
+
const selection = client.agent
|
|
151
|
+
? {}
|
|
152
|
+
: await getSessionSelection(client, before.leadSessionID).catch(() => ({}))
|
|
153
|
+
|
|
154
|
+
// Re-check after the async lookups: the team may have been archived or
|
|
155
|
+
// purged, or the lead may have drained the inbox in the meantime.
|
|
156
|
+
const after = pendingLeadWake(db, teamId)
|
|
157
|
+
if (!after) {
|
|
158
|
+
log(`wake:skip team=${teamId} reason=drained`)
|
|
159
|
+
return false
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const pendingTimer = state.timers.get(teamId)
|
|
163
|
+
if (pendingTimer) {
|
|
164
|
+
clearTimeout(pendingTimer)
|
|
165
|
+
state.timers.delete(teamId)
|
|
166
|
+
}
|
|
167
|
+
// Persisted, not in-memory: worktree teammate instances share this DB.
|
|
168
|
+
db.run("UPDATE team SET last_wake_at = ? WHERE id = ? AND status = 'active'", [Date.now(), teamId])
|
|
169
|
+
const senderText = after.senders.length > 0 ? ` from ${after.senders.join(", ")}` : ""
|
|
170
|
+
client.session.promptAsync({
|
|
171
|
+
sessionID: after.leadSessionID,
|
|
172
|
+
parts: [{ type: "text", text: `[System: ${after.count} new team message(s)${senderText}] — use team_results to read them` }],
|
|
173
|
+
synthetic: true,
|
|
174
|
+
...(selection.agent ? { agent: selection.agent } : {}),
|
|
175
|
+
...(selection.model ? { model: selection.model } : {}),
|
|
176
|
+
}).catch((err) => {
|
|
177
|
+
log(`wake:send:failed team=${teamId} err=${err instanceof Error ? err.message : String(err)}`)
|
|
178
|
+
})
|
|
179
|
+
log(`wake:sent team=${teamId} count=${after.count}`)
|
|
180
|
+
return true
|
|
181
|
+
} finally {
|
|
182
|
+
state.inFlight.delete(teamId)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Clear wake coalescing state for a database (tests). */
|
|
187
|
+
export function resetWakeState(db: Database): void {
|
|
188
|
+
const state = states.get(db)
|
|
189
|
+
if (state) {
|
|
190
|
+
for (const timer of state.timers.values()) clearTimeout(timer)
|
|
191
|
+
states.delete(db)
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
db.run("UPDATE team SET last_wake_at = 0")
|
|
195
|
+
} catch {
|
|
196
|
+
// The table may not exist yet in a partially initialized test DB.
|
|
197
|
+
}
|
|
198
|
+
}
|