@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.
@@ -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 args.tasks) {
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 args.tasks) {
78
+ for (const task of tasks) {
43
79
  const id = generateId("task")
44
- const depsJson = task.depends_on?.length ? JSON.stringify(task.depends_on) : null
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?.length) {
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: string[] = JSON.parse(t.depends_on)
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: { providerID: options.model.providerID, id: options.model.modelID },
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
+ }