@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/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
+ }