@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/notify.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Database } from "./db"
|
|
2
2
|
import type { PluginClient } from "./types"
|
|
3
|
+
import { sendMessage } from "./messaging"
|
|
4
|
+
import { log } from "./log"
|
|
3
5
|
|
|
4
6
|
type TeamEventType = "spawn" | "message" | "completed" | "error" | "shutdown"
|
|
5
7
|
|
|
@@ -82,3 +84,47 @@ export async function notifyWorkingProgress(
|
|
|
82
84
|
// TUI may not be available — silently ignore
|
|
83
85
|
}
|
|
84
86
|
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Persist a system-generated message to the lead AND actively wake the lead
|
|
90
|
+
* so the message is delivered on the lead's next turn.
|
|
91
|
+
*
|
|
92
|
+
* This is the delivery path for system events (teammate errors, timeouts,
|
|
93
|
+
* stalls, spawn failures). Unlike the passive wake-lead path in the event
|
|
94
|
+
* hook, this ALWAYS fires a promptAsync regardless of whether teammates are
|
|
95
|
+
* still busy — an errored/aborted teammate is precisely the case where the
|
|
96
|
+
* "all teammates done" gate would otherwise suppress the wake and strand the
|
|
97
|
+
* message in the DB with delivered=0.
|
|
98
|
+
*
|
|
99
|
+
* The message is persisted first; the promptAsync wake is fire-and-forget
|
|
100
|
+
* (never awaited, per the plugin's promptAsync invariant). If the wake fails,
|
|
101
|
+
* the message is still in the DB and the passive backstop / recovery paths
|
|
102
|
+
* remain available.
|
|
103
|
+
*
|
|
104
|
+
* Returns the persisted message id.
|
|
105
|
+
*/
|
|
106
|
+
export function notifyLead(
|
|
107
|
+
client: PluginClient,
|
|
108
|
+
db: Database,
|
|
109
|
+
teamId: string,
|
|
110
|
+
content: string,
|
|
111
|
+
): string {
|
|
112
|
+
const id = sendMessage(db, { teamId, from: "system", to: "lead", content })
|
|
113
|
+
|
|
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
|
+
// Fire-and-forget wake — the system prompt transform delivers the actual
|
|
119
|
+
// content on the lead's next turn. Synthetic: renders as a persistent
|
|
120
|
+
// system entry, not a fake user turn.
|
|
121
|
+
client.session.promptAsync({
|
|
122
|
+
sessionID: team.lead_session_id,
|
|
123
|
+
parts: [{ type: "text", text: "[System: New team message from system]" }],
|
|
124
|
+
synthetic: true,
|
|
125
|
+
}).catch((err) => {
|
|
126
|
+
log(`notifyLead:wake:failed team=${teamId} err=${err instanceof Error ? err.message : String(err)}`)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
return id
|
|
130
|
+
}
|
package/src/progress.ts
CHANGED
|
@@ -12,6 +12,7 @@ export class ProgressTracker {
|
|
|
12
12
|
private steps = new Map<string, StepRecord[]>()
|
|
13
13
|
private lastMessageAt = new Map<string, number>()
|
|
14
14
|
private lastTaskAt = new Map<string, number>()
|
|
15
|
+
private busySince = new Map<string, number>()
|
|
15
16
|
private peerMessages = new Map<string, number[]>()
|
|
16
17
|
private reported = new Set<string>()
|
|
17
18
|
private chattyReported = new Set<string>()
|
|
@@ -83,15 +84,32 @@ export class ProgressTracker {
|
|
|
83
84
|
return recent.every(r => r.outputTokens < threshold)
|
|
84
85
|
}
|
|
85
86
|
|
|
86
|
-
/**
|
|
87
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Record that this member transitioned to busy. Gives isTimeStalled a baseline
|
|
89
|
+
* independent of step records, so a member whose first (or only) action is one
|
|
90
|
+
* long-running tool call is still subject to time-based stall detection before
|
|
91
|
+
* any step-finish event has landed.
|
|
92
|
+
*/
|
|
93
|
+
recordBusyStart(sessionId: string): void {
|
|
94
|
+
this.busySince.set(sessionId, Date.now())
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Latest activity timestamp across all tracked signals (0 when nothing recorded). */
|
|
98
|
+
lastActivityAt(sessionId: string): number {
|
|
88
99
|
const records = this.steps.get(sessionId)
|
|
89
|
-
|
|
100
|
+
const lastStepAt = records && records.length > 0 ? records[records.length - 1]!.timestamp : 0
|
|
101
|
+
return Math.max(
|
|
102
|
+
this.lastMessageAt.get(sessionId) ?? 0,
|
|
103
|
+
this.lastTaskAt.get(sessionId) ?? 0,
|
|
104
|
+
lastStepAt,
|
|
105
|
+
this.busySince.get(sessionId) ?? 0,
|
|
106
|
+
)
|
|
107
|
+
}
|
|
90
108
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
109
|
+
/** Time-based stall: no message, task completion, step, or busy-transition within `thresholdMs` of now. */
|
|
110
|
+
isTimeStalled(sessionId: string, thresholdMs: number): boolean {
|
|
111
|
+
const baseline = this.lastActivityAt(sessionId)
|
|
112
|
+
if (baseline === 0) return false // no activity or busy signal recorded at all yet
|
|
95
113
|
|
|
96
114
|
return Date.now() - baseline >= thresholdMs
|
|
97
115
|
}
|
|
@@ -116,6 +134,7 @@ export class ProgressTracker {
|
|
|
116
134
|
this.steps.delete(sessionId)
|
|
117
135
|
this.lastMessageAt.delete(sessionId)
|
|
118
136
|
this.lastTaskAt.delete(sessionId)
|
|
137
|
+
this.busySince.delete(sessionId)
|
|
119
138
|
this.peerMessages.delete(sessionId)
|
|
120
139
|
this.reported.delete(sessionId)
|
|
121
140
|
this.chattyReported.delete(sessionId)
|
package/src/recovery.ts
CHANGED
|
@@ -2,18 +2,104 @@ import type { Database } from "./db"
|
|
|
2
2
|
import type { PluginClient } from "./types"
|
|
3
3
|
import type { MemberRegistry } from "./state"
|
|
4
4
|
import { getUndeliveredMessages, markDelivered, hasReportedCompletion } from "./messaging"
|
|
5
|
+
import { releaseMemberTasks } from "./tasks"
|
|
6
|
+
import { getMemberModel } from "./member-model"
|
|
5
7
|
import { preserveBranch, preservedBranchName, teamResourceSegment } from "./tools/merge-helper"
|
|
6
8
|
import { log } from "./log"
|
|
7
9
|
import { runCommand } from "./process"
|
|
8
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Whether a session still exists in OpenCode. `client.session.get` throws for a
|
|
13
|
+
* deleted/unknown session rather than returning empty data -- see dashboard.ts's
|
|
14
|
+
* existing best-effort usage of the same call.
|
|
15
|
+
*
|
|
16
|
+
* Bounded by a timeout: a `session.get` call made from within this plugin's own
|
|
17
|
+
* init sequence, before the server has finished bootstrapping, can hang the
|
|
18
|
+
* request indefinitely rather than resolve or reject -- the same class of
|
|
19
|
+
* self-referential-HTTP-call deadlock team-spawn.ts's own `withTimeout` exists
|
|
20
|
+
* to guard against for `worktree.create`/`session.create` ("makes HTTP calls
|
|
21
|
+
* back to the server, which deadlocks because the server is still handling
|
|
22
|
+
* session.create"). On timeout, treat liveness as UNKNOWN (return true, i.e.
|
|
23
|
+
* "assume alive, don't archive") rather than assuming dead -- a false negative
|
|
24
|
+
* here just means the orphan isn't reconciled this pass; a false positive means
|
|
25
|
+
* archiving a team that might still be genuinely active.
|
|
26
|
+
*/
|
|
27
|
+
export async function isSessionAlive(client: PluginClient, sessionId: string, timeoutMs = 5000): Promise<boolean> {
|
|
28
|
+
try {
|
|
29
|
+
await Promise.race([
|
|
30
|
+
client.session.get({ sessionID: sessionId }),
|
|
31
|
+
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("isSessionAlive timed out")), timeoutMs)),
|
|
32
|
+
])
|
|
33
|
+
return true
|
|
34
|
+
} catch (err) {
|
|
35
|
+
if (err instanceof Error && err.message === "isSessionAlive timed out") {
|
|
36
|
+
log(`recovery:team:liveness-check-timeout session=${sessionId}`)
|
|
37
|
+
return true
|
|
38
|
+
}
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Reconcile 'active' teams whose lead session no longer exists in OpenCode --
|
|
45
|
+
* e.g. the user deleted the session via OpenCode's own session UI/API, not via
|
|
46
|
+
* team_cleanup. Ensemble's own bookkeeping has no way to learn about that on its
|
|
47
|
+
* own: nothing tells it the session is gone, so the team row stays 'active'
|
|
48
|
+
* forever, blocking team_create ("already exists") and team_cleanup's purge path
|
|
49
|
+
* ("cannot purge active team" -- purge only operates on archived teams) for that
|
|
50
|
+
* name indefinitely. A team with no live lead session is unrecoverable through
|
|
51
|
+
* any legitimate path (there is no "reassign lead" tool), so archiving it directly
|
|
52
|
+
* is safe regardless of member state -- the existing recoverOrphanedWorktrees/
|
|
53
|
+
* recoverOrphanedBranches passes then naturally sweep up its resources on the same
|
|
54
|
+
* recovery cycle, since those already scope to archived teams with no active
|
|
55
|
+
* members.
|
|
56
|
+
*
|
|
57
|
+
* Scoped by cwd the same way recoverStaleMembers is, to avoid reconciling other
|
|
58
|
+
* projects' teams when multiple projects share one DB.
|
|
59
|
+
*/
|
|
60
|
+
export async function recoverOrphanedTeams(
|
|
61
|
+
db: Database,
|
|
62
|
+
client: PluginClient,
|
|
63
|
+
cwd?: string,
|
|
64
|
+
registry?: MemberRegistry,
|
|
65
|
+
): Promise<{ archived: number }> {
|
|
66
|
+
const active = db.query(
|
|
67
|
+
`SELECT id, lead_session_id FROM team
|
|
68
|
+
WHERE status = 'active' AND (? IS NULL OR project_id = ? OR project_id = 'default')`
|
|
69
|
+
).all(cwd ?? null, cwd ?? null) as Array<{ id: string; lead_session_id: string }>
|
|
70
|
+
|
|
71
|
+
let archived = 0
|
|
72
|
+
for (const team of active) {
|
|
73
|
+
if (await isSessionAlive(client, team.lead_session_id)) continue
|
|
74
|
+
|
|
75
|
+
db.run("UPDATE team SET status = 'archived', time_updated = ? WHERE id = ?", [Date.now(), team.id])
|
|
76
|
+
registry?.unregisterTeam(team.id)
|
|
77
|
+
archived++
|
|
78
|
+
log(`recovery:team:orphaned team_id=${team.id} lead_session=${team.lead_session_id}`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { archived }
|
|
82
|
+
}
|
|
83
|
+
|
|
9
84
|
/**
|
|
10
85
|
* Scan for team members stuck in 'busy' status (stale from a crash)
|
|
11
86
|
* and mark them as 'error' with execution_status 'idle'.
|
|
12
87
|
* Preserves worktree branches before aborting orphaned sessions.
|
|
13
88
|
* Only processes members in active teams.
|
|
14
89
|
* Returns the count of interrupted members.
|
|
90
|
+
*
|
|
91
|
+
* `abortTimeoutMs` bounds each `client.session.abort()` call -- the same
|
|
92
|
+
* self-referential-HTTP-call-back-to-this-server shape as
|
|
93
|
+
* `isSessionAlive()`'s `client.session.get()` (see that function's doc
|
|
94
|
+
* comment for the full deadlock mechanism, confirmed live 2026-08-17). This
|
|
95
|
+
* function is awaited synchronously in plugin init (unlike
|
|
96
|
+
* `recoverOrphanedTeams`, which is fire-and-forget) because
|
|
97
|
+
* `rehydrateRegistry` depends on it completing first, so a bounded timeout on
|
|
98
|
+
* the network call -- not restructuring the await -- is the fix here: the
|
|
99
|
+
* DB-only work (marking members 'error') always completes and is reflected in
|
|
100
|
+
* the returned count regardless of whether any individual abort call settles.
|
|
15
101
|
*/
|
|
16
|
-
export async function recoverStaleMembers(db: Database, client?: PluginClient, cwd?: string): Promise<{ interrupted: number }> {
|
|
102
|
+
export async function recoverStaleMembers(db: Database, client?: PluginClient, cwd?: string, abortTimeoutMs = 5000): Promise<{ interrupted: number }> {
|
|
17
103
|
// Find stale members with branch info so we can preserve before aborting
|
|
18
104
|
const stale = db.query(
|
|
19
105
|
`SELECT tm.session_id, tm.worktree_branch, tm.name, tm.team_id, t.name as team_name, p.name as project_name
|
|
@@ -31,6 +117,13 @@ export async function recoverStaleMembers(db: Database, client?: PluginClient, c
|
|
|
31
117
|
[Date.now(), cwd ?? null, cwd ?? null]
|
|
32
118
|
)
|
|
33
119
|
|
|
120
|
+
// Release each stale member's in_progress tasks back to the pool so their
|
|
121
|
+
// unfinished work is reclaimable after a crash (issue #27).
|
|
122
|
+
for (const member of stale) {
|
|
123
|
+
const released = releaseMemberTasks(db, member.team_id, member.name)
|
|
124
|
+
if (released > 0) log(`recovery:tasks:released name=${member.name} count=${released}`)
|
|
125
|
+
}
|
|
126
|
+
|
|
34
127
|
// Preserve branches then abort orphaned sessions
|
|
35
128
|
if (client) {
|
|
36
129
|
for (const member of stale) {
|
|
@@ -45,8 +138,16 @@ export async function recoverStaleMembers(db: Database, client?: PluginClient, c
|
|
|
45
138
|
}
|
|
46
139
|
}
|
|
47
140
|
try {
|
|
48
|
-
await
|
|
49
|
-
|
|
141
|
+
await Promise.race([
|
|
142
|
+
client.session.abort({ sessionID: member.session_id }),
|
|
143
|
+
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("session.abort timed out")), abortTimeoutMs)),
|
|
144
|
+
])
|
|
145
|
+
} catch (err) {
|
|
146
|
+
if (err instanceof Error && err.message === "session.abort timed out") {
|
|
147
|
+
log(`recovery:stale-members:abort-timeout session=${member.session_id}`)
|
|
148
|
+
}
|
|
149
|
+
/* best effort */
|
|
150
|
+
}
|
|
50
151
|
}
|
|
51
152
|
}
|
|
52
153
|
|
|
@@ -133,9 +234,11 @@ export async function recoverUndeliveredMessages(
|
|
|
133
234
|
}
|
|
134
235
|
|
|
135
236
|
try {
|
|
237
|
+
const recipientModel = getMemberModel(db, team.id, msg.to_name)
|
|
136
238
|
await client.session.promptAsync({
|
|
137
239
|
sessionID: recipientSessionId,
|
|
138
240
|
parts: [{ type: "text", text: `[Recovered team message from ${msg.from_name}]: ${msg.content}` }],
|
|
241
|
+
...(recipientModel ? { model: recipientModel } : {}),
|
|
139
242
|
})
|
|
140
243
|
markDelivered(db, msg.id)
|
|
141
244
|
redelivered++
|
package/src/rpc.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Rpc } from "@opencode/plugin/rpc"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RPC contract between the server plugin and terminal companions.
|
|
5
|
+
* Published as `./rpc` so companions import the shape without the
|
|
6
|
+
* implementation. Companion behavior (toasts, navigation) is enhancement
|
|
7
|
+
* only — every client works fully without it.
|
|
8
|
+
*/
|
|
9
|
+
export const EnsembleRpc = Rpc.define({
|
|
10
|
+
id: "ensemble",
|
|
11
|
+
methods: {
|
|
12
|
+
summary: {
|
|
13
|
+
input: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: { team: { type: "string" } },
|
|
16
|
+
required: ["team"],
|
|
17
|
+
additionalProperties: false,
|
|
18
|
+
},
|
|
19
|
+
output: {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: { text: { type: "string" } },
|
|
22
|
+
required: ["text"],
|
|
23
|
+
additionalProperties: false,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
teamContext: {
|
|
27
|
+
input: {
|
|
28
|
+
type: "object",
|
|
29
|
+
properties: { sessionID: { type: "string" } },
|
|
30
|
+
required: ["sessionID"],
|
|
31
|
+
additionalProperties: false,
|
|
32
|
+
},
|
|
33
|
+
output: {
|
|
34
|
+
type: "object",
|
|
35
|
+
properties: {
|
|
36
|
+
team: { type: ["string", "null"] },
|
|
37
|
+
members: {
|
|
38
|
+
type: "array",
|
|
39
|
+
items: {
|
|
40
|
+
type: "object",
|
|
41
|
+
properties: { name: { type: "string" }, status: { type: "string" } },
|
|
42
|
+
required: ["name", "status"],
|
|
43
|
+
additionalProperties: false,
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
tasks: {
|
|
47
|
+
type: "object",
|
|
48
|
+
properties: {
|
|
49
|
+
pending: { type: "number" },
|
|
50
|
+
done: { type: "number" },
|
|
51
|
+
},
|
|
52
|
+
required: ["pending", "done"],
|
|
53
|
+
additionalProperties: false,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
required: ["team", "members", "tasks"],
|
|
57
|
+
additionalProperties: false,
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
events: {
|
|
62
|
+
member: {
|
|
63
|
+
schema: {
|
|
64
|
+
type: "object",
|
|
65
|
+
properties: {
|
|
66
|
+
memberName: { type: "string" },
|
|
67
|
+
teamId: { type: "string" },
|
|
68
|
+
from: { type: "string" },
|
|
69
|
+
to: { type: "string" },
|
|
70
|
+
},
|
|
71
|
+
required: ["memberName", "teamId", "from", "to"],
|
|
72
|
+
additionalProperties: false,
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
notice: {
|
|
76
|
+
schema: {
|
|
77
|
+
type: "object",
|
|
78
|
+
properties: {
|
|
79
|
+
title: { type: "string" },
|
|
80
|
+
message: { type: "string" },
|
|
81
|
+
variant: { type: "string" },
|
|
82
|
+
},
|
|
83
|
+
required: ["message"],
|
|
84
|
+
additionalProperties: false,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
view: {
|
|
88
|
+
schema: {
|
|
89
|
+
type: "object",
|
|
90
|
+
properties: {
|
|
91
|
+
sessionID: { type: "string" },
|
|
92
|
+
memberName: { type: "string" },
|
|
93
|
+
},
|
|
94
|
+
required: ["sessionID"],
|
|
95
|
+
additionalProperties: false,
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
})
|
package/src/schema.ts
CHANGED
|
@@ -186,6 +186,24 @@ export const MIGRATIONS: string[] = [
|
|
|
186
186
|
CREATE INDEX IF NOT EXISTS team_message_undelivered_idx ON team_message(team_id, delivered) WHERE delivered = 0;
|
|
187
187
|
CREATE INDEX IF NOT EXISTS team_message_unread_idx ON team_message(team_id, read) WHERE read = 0;
|
|
188
188
|
PRAGMA foreign_keys=ON;`,
|
|
189
|
+
// Migration 9: Add last_nudged_at to team_member — additive-only display-staleness
|
|
190
|
+
// signal for the watchdog's soft stall-nudge path. Deliberately NOT a new status
|
|
191
|
+
// enum value (see checkStalled() in watchdog.ts for the rationale): a 6th CHECK
|
|
192
|
+
// constraint literal would require a table-rebuild migration and touch every
|
|
193
|
+
// consumer that switches on the 5 known status strings. This column is additive —
|
|
194
|
+
// existing consumers that don't know about it simply don't render it.
|
|
195
|
+
`ALTER TABLE team_member ADD COLUMN last_nudged_at INTEGER;`,
|
|
196
|
+
// Migration 10: Add retry_* columns to team_member — additive-only provider-retry
|
|
197
|
+
// display signal (see hooks.ts's "retry" branch). Same non-negotiable as Migration
|
|
198
|
+
// 9: no 6th status/execution_status literal, no table rebuild. retry_until is a
|
|
199
|
+
// TTL, not a stored enum — "currently retrying" is derived at read time
|
|
200
|
+
// (retry_until > Date.now()), so there is no explicit clear-write anywhere in this
|
|
201
|
+
// fix; it simply becomes stale and gets superseded by real activity or by time
|
|
202
|
+
// elapsing.
|
|
203
|
+
`ALTER TABLE team_member ADD COLUMN retry_until INTEGER;
|
|
204
|
+
ALTER TABLE team_member ADD COLUMN retry_attempt INTEGER;
|
|
205
|
+
ALTER TABLE team_member ADD COLUMN retry_provider TEXT;
|
|
206
|
+
ALTER TABLE team_member ADD COLUMN retry_message TEXT;`,
|
|
189
207
|
]
|
|
190
208
|
|
|
191
209
|
/**
|
package/src/tasks.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Database } from "./db"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Release all in-progress tasks assigned to a departing member back to the
|
|
5
|
+
* shared pool. Sets status to 'pending' and clears the assignee so another
|
|
6
|
+
* teammate can claim the work. Completed, cancelled, and blocked tasks are
|
|
7
|
+
* left untouched. Returns the number of tasks released.
|
|
8
|
+
*
|
|
9
|
+
* Call this whenever a member reaches a terminal state (shutdown, timeout,
|
|
10
|
+
* crash recovery, or spawn rollback) so their unfinished work is not stranded
|
|
11
|
+
* as permanently in_progress with a dead assignee (issue #27).
|
|
12
|
+
*/
|
|
13
|
+
export function releaseMemberTasks(db: Database, teamId: string, memberName: string): number {
|
|
14
|
+
const result = db.run(
|
|
15
|
+
"UPDATE team_task SET status = 'pending', assignee = NULL, time_updated = ? WHERE team_id = ? AND assignee = ? AND status = 'in_progress'",
|
|
16
|
+
[Date.now(), teamId, memberName],
|
|
17
|
+
)
|
|
18
|
+
return result.changes
|
|
19
|
+
}
|
package/src/tools/team-create.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
2
|
import { generateId, generateProjectName, validateProjectName, validateTeamName } from "../util"
|
|
3
3
|
import { findTeamBySession } from "../types"
|
|
4
|
+
import { isSessionAlive } from "../recovery"
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Execute the team_create tool. Creates a new team with the caller as lead.
|
|
@@ -19,8 +20,20 @@ export async function executeTeamCreate(
|
|
|
19
20
|
|
|
20
21
|
// Check if team name already exists
|
|
21
22
|
const projectId = deps.directory
|
|
22
|
-
const existing = deps.db.query("SELECT id FROM team WHERE name = ? AND project_id = ? AND status = 'active'")
|
|
23
|
-
|
|
23
|
+
const existing = deps.db.query("SELECT id, lead_session_id FROM team WHERE name = ? AND project_id = ? AND status = 'active'")
|
|
24
|
+
.get(args.name, projectId) as { id: string; lead_session_id: string } | undefined
|
|
25
|
+
if (existing) {
|
|
26
|
+
// The existing team's lead session may have been deleted externally (via
|
|
27
|
+
// OpenCode's own session UI/API, not team_cleanup) rather than genuinely
|
|
28
|
+
// still being in use. Reconcile on demand rather than block this call on a
|
|
29
|
+
// team nobody can ever be lead of again -- don't wait for the next plugin
|
|
30
|
+
// restart's periodic recoverOrphanedTeams pass to notice.
|
|
31
|
+
if (await isSessionAlive(deps.client, existing.lead_session_id)) {
|
|
32
|
+
throw new Error(`Team "${args.name}" already exists`)
|
|
33
|
+
}
|
|
34
|
+
deps.db.run("UPDATE team SET status = 'archived', time_updated = ? WHERE id = ?", [Date.now(), existing.id])
|
|
35
|
+
deps.registry.unregisterTeam(existing.id)
|
|
36
|
+
}
|
|
24
37
|
|
|
25
38
|
// Check if session already leads a team
|
|
26
39
|
const lead = findTeamBySession(deps.db, deps.registry, sessionId)
|
package/src/tools/team-merge.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
|
-
import { requireLead } from "./shared"
|
|
2
|
+
import { requireLead, checkWorktreeDirty, countBranchCommits } from "./shared"
|
|
3
|
+
import type { IsDirtyFn, CommitCountFn } from "./shared"
|
|
3
4
|
import { mergeBranch, deleteBranch, getOverlappingFiles } from "./merge-helper"
|
|
4
5
|
import type { MergeBranchFn, DeleteBranchFn, OverlapCheckFn } from "./merge-helper"
|
|
5
6
|
import { log } from "../log"
|
|
@@ -15,11 +16,13 @@ export async function executeTeamMerge(
|
|
|
15
16
|
merge: MergeBranchFn = mergeBranch,
|
|
16
17
|
delBranch: DeleteBranchFn = deleteBranch,
|
|
17
18
|
overlapCheck: OverlapCheckFn = getOverlappingFiles,
|
|
19
|
+
commitCount: CommitCountFn = countBranchCommits,
|
|
20
|
+
isDirty: IsDirtyFn = checkWorktreeDirty,
|
|
18
21
|
): Promise<string> {
|
|
19
22
|
const teamInfo = requireLead(deps, sessionId)
|
|
20
23
|
|
|
21
|
-
const member = deps.db.query("SELECT status, worktree_branch FROM team_member WHERE team_id = ? AND name = ?")
|
|
22
|
-
.get(teamInfo.teamId, args.member) as { status: string; worktree_branch: string | null } | null
|
|
24
|
+
const member = deps.db.query("SELECT status, worktree_branch, worktree_dir FROM team_member WHERE team_id = ? AND name = ?")
|
|
25
|
+
.get(teamInfo.teamId, args.member) as { status: string; worktree_branch: string | null; worktree_dir: string | null } | null
|
|
23
26
|
if (!member) throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`)
|
|
24
27
|
|
|
25
28
|
if (member.status !== "shutdown" && member.status !== "error") {
|
|
@@ -33,6 +36,30 @@ export async function executeTeamMerge(
|
|
|
33
36
|
const branch = member.worktree_branch
|
|
34
37
|
log(`merge:start member=${args.member} branch=${branch}`)
|
|
35
38
|
|
|
39
|
+
// A branch with zero new commits produces a trivially "successful" no-op squash merge --
|
|
40
|
+
// there is genuinely nothing to apply. Reporting that as "Merged ... changes" is
|
|
41
|
+
// misleading: if the teammate's worktree still has uncommitted work, that work was never
|
|
42
|
+
// captured on the branch at all and is about to be permanently lost the moment the
|
|
43
|
+
// worktree is removed (e.g. by team_cleanup). Say so plainly instead of a false success,
|
|
44
|
+
// and don't tear down the branch/worktree while it may be the only copy of the work.
|
|
45
|
+
// A commit-count check failure returns -1 (unknown) -- never treat that as "nothing to
|
|
46
|
+
// merge"; fall through to the real merge attempt so a check failure can't silently skip
|
|
47
|
+
// real work.
|
|
48
|
+
const commits = await commitCount(branch, deps.directory)
|
|
49
|
+
if (commits === 0) {
|
|
50
|
+
const dirty = member.worktree_dir ? await isDirty(member.worktree_dir).catch(() => false) : false
|
|
51
|
+
if (dirty) {
|
|
52
|
+
return [
|
|
53
|
+
`Nothing to merge for "${args.member}" — their branch (${branch}) has no commits.`,
|
|
54
|
+
`Their worktree still has uncommitted changes that were never captured on the branch.`,
|
|
55
|
+
`This work will be permanently lost if the worktree is removed (e.g. via team_cleanup).`,
|
|
56
|
+
member.worktree_dir ? `Worktree: ${member.worktree_dir}` : ``,
|
|
57
|
+
`Recover manually (copy files out of the worktree) before shutting down/cleaning up, or re-spawn and instruct them to commit their changes before reporting done.`,
|
|
58
|
+
].filter(Boolean).join("\n")
|
|
59
|
+
}
|
|
60
|
+
return `Nothing to merge for "${args.member}" — their branch (${branch}) has no commits and no uncommitted changes. They made no changes.`
|
|
61
|
+
}
|
|
62
|
+
|
|
36
63
|
// Block merge if lead has local changes to files the agent also modified
|
|
37
64
|
try {
|
|
38
65
|
const overlap = await overlapCheck(branch, deps.directory)
|
|
@@ -2,21 +2,62 @@ import type { ToolDeps } from "../types"
|
|
|
2
2
|
import { resolveRecipientSession } from "../types"
|
|
3
3
|
import { requireTeamMember } from "./shared"
|
|
4
4
|
import { sendMessage, markDelivered, hasReportedCompletion } from "../messaging"
|
|
5
|
+
import { parseModelId, getMemberModel } from "../member-model"
|
|
5
6
|
import { log } from "../log"
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Execute the team_message tool. Sends a direct message to a teammate or lead.
|
|
9
10
|
* Optionally approves or rejects a teammate's plan (lead only).
|
|
11
|
+
* Optionally forces delivery to a teammate who already reported completion (lead only) —
|
|
12
|
+
* see the `force` guard below.
|
|
13
|
+
* Optionally updates a teammate's model in-place (lead only) via the `model`
|
|
14
|
+
* param — see #26. When `model` is set, `text` is optional: if omitted, the
|
|
15
|
+
* model is updated without delivering a message.
|
|
10
16
|
*/
|
|
11
17
|
export async function executeTeamMessage(
|
|
12
18
|
deps: ToolDeps,
|
|
13
|
-
args: { to: string; text
|
|
19
|
+
args: { to: string; text?: string; approve?: boolean; reject?: string; force?: boolean; model?: string },
|
|
14
20
|
sessionId: string,
|
|
15
21
|
): Promise<string> {
|
|
16
22
|
const teamInfo = requireTeamMember(deps, sessionId)
|
|
17
23
|
|
|
24
|
+
if (args.force && teamInfo.role !== "lead") {
|
|
25
|
+
throw new Error("Only the lead can force-deliver a message to a completed teammate.")
|
|
26
|
+
}
|
|
27
|
+
|
|
18
28
|
const senderName = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
|
|
19
29
|
|
|
30
|
+
// In-place model update (lead only). Handled before recipient session
|
|
31
|
+
// resolution so it works even when the member is idle/not in the registry,
|
|
32
|
+
// and rejects terminal members explicitly. See #26.
|
|
33
|
+
if (args.model !== undefined) {
|
|
34
|
+
if (teamInfo.role !== "lead") throw new Error("Only the lead can update a teammate's model.")
|
|
35
|
+
const parsed = parseModelId(args.model)
|
|
36
|
+
if (!parsed) throw new Error(`Invalid model "${args.model}" — expected "provider/model" format (e.g. "anthropic/claude-sonnet").`)
|
|
37
|
+
const member = deps.db.query("SELECT status FROM team_member WHERE team_id = ? AND name = ?")
|
|
38
|
+
.get(teamInfo.teamId, args.to) as { status: string } | null
|
|
39
|
+
if (!member) throw new Error(`Teammate "${args.to}" not found in team "${teamInfo.teamName}".`)
|
|
40
|
+
if (member.status === "shutdown" || member.status === "error") {
|
|
41
|
+
throw new Error(`Teammate "${args.to}" is shut down — spawn a new teammate instead of updating the model.`)
|
|
42
|
+
}
|
|
43
|
+
deps.db.run(
|
|
44
|
+
"UPDATE team_member SET model = ?, time_updated = ? WHERE team_id = ? AND name = ?",
|
|
45
|
+
[args.model, Date.now(), teamInfo.teamId, args.to],
|
|
46
|
+
)
|
|
47
|
+
log(`team_message:model-update to=${args.to} model=${args.model}`)
|
|
48
|
+
|
|
49
|
+
// If there's no accompanying message, we're done — the new model applies
|
|
50
|
+
// on the teammate's next natural delivery.
|
|
51
|
+
if (!args.text) {
|
|
52
|
+
return `Updated ${args.to}'s model to ${args.model}. It applies on their next turn.`
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (args.text === undefined) {
|
|
57
|
+
throw new Error("team_message requires either 'text' or 'model'.")
|
|
58
|
+
}
|
|
59
|
+
const text = args.text
|
|
60
|
+
|
|
20
61
|
const recipientSessionId = resolveRecipientSession(deps.db, deps.registry, teamInfo.teamId, args.to)
|
|
21
62
|
|
|
22
63
|
// If recipient not found, store the message for later delivery (they may not be spawned yet)
|
|
@@ -29,7 +70,7 @@ export async function executeTeamMessage(
|
|
|
29
70
|
teamId: teamInfo.teamId,
|
|
30
71
|
from: senderName,
|
|
31
72
|
to: args.to,
|
|
32
|
-
content:
|
|
73
|
+
content: text,
|
|
33
74
|
})
|
|
34
75
|
log(`team_message:queued from=${senderName} to=${args.to} (recipient not yet spawned)`)
|
|
35
76
|
return `Message queued for ${args.to} — they haven't been spawned yet. It will be delivered when they join the team.`
|
|
@@ -37,7 +78,7 @@ export async function executeTeamMessage(
|
|
|
37
78
|
if (!recipientSessionId) throw new Error(`Recipient "${args.to}" not found in team "${teamInfo.teamName}"`)
|
|
38
79
|
|
|
39
80
|
// Handle plan approval/rejection
|
|
40
|
-
let messageText =
|
|
81
|
+
let messageText = text
|
|
41
82
|
if (args.approve || args.reject) {
|
|
42
83
|
if (args.approve && args.reject) {
|
|
43
84
|
throw new Error("Cannot both approve and reject a plan.")
|
|
@@ -84,22 +125,30 @@ export async function executeTeamMessage(
|
|
|
84
125
|
deps.client.session.promptAsync({
|
|
85
126
|
sessionID: recipientSessionId,
|
|
86
127
|
parts: [{ type: "text", text: `[System: New team message from ${senderName}]` }],
|
|
128
|
+
synthetic: true,
|
|
87
129
|
}).catch((err) => {
|
|
88
130
|
log(`team_message:wake-lead:failed from=${senderName} err=${err instanceof Error ? err.message : String(err)}`)
|
|
89
131
|
})
|
|
90
132
|
return `Message sent to ${args.to}.`
|
|
91
133
|
}
|
|
92
134
|
|
|
93
|
-
// Guard: skip promptAsync delivery to teammates who have already reported completion (issue #3)
|
|
94
|
-
|
|
95
|
-
|
|
135
|
+
// Guard: skip promptAsync delivery to teammates who have already reported completion (issue #3),
|
|
136
|
+
// unless the lead explicitly forces it. Forcing wakes the session; the natural busy transition
|
|
137
|
+
// (handleSessionStatusEvent) resets the completion flag when the teammate next goes idle, the
|
|
138
|
+
// same reset that already happens for a normal re-activation — this just gives the lead a way
|
|
139
|
+
// to trigger it deliberately instead of it being unreachable once the guard is set.
|
|
140
|
+
if (hasReportedCompletion(deps.db, teamInfo.teamId, args.to) && !args.force) {
|
|
141
|
+
return `Message stored for ${args.to} (teammate has completed their task — message will not wake them). Pass force:true to re-activate them.`
|
|
96
142
|
}
|
|
97
143
|
|
|
98
144
|
// For member-to-member messages, fire-and-forget delivery is safe.
|
|
145
|
+
// Deliver on the recipient's configured model, if any (#26).
|
|
99
146
|
const deliveryText = `[Team message from ${senderName}]: ${messageText}`
|
|
147
|
+
const recipientModel = getMemberModel(deps.db, teamInfo.teamId, args.to)
|
|
100
148
|
deps.client.session.promptAsync({
|
|
101
149
|
sessionID: recipientSessionId,
|
|
102
150
|
parts: [{ type: "text", text: deliveryText }],
|
|
151
|
+
...(recipientModel ? { model: recipientModel } : {}),
|
|
103
152
|
}).then(() => {
|
|
104
153
|
markDelivered(deps.db, msgId)
|
|
105
154
|
}).catch((err) => {
|
|
@@ -3,6 +3,7 @@ import { requireLead, checkWorktreeDirty, countBranchCommits } from "./shared"
|
|
|
3
3
|
import type { IsDirtyFn, CommitCountFn } from "./shared"
|
|
4
4
|
import { getTeamResourceParts, preserveBranch, preservedBranchName } from "./merge-helper"
|
|
5
5
|
import type { PreserveBranchFn } from "./merge-helper"
|
|
6
|
+
import { releaseMemberTasks } from "../tasks"
|
|
6
7
|
import { log } from "../log"
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -127,6 +128,11 @@ async function preserveAndAbort(
|
|
|
127
128
|
"UPDATE team_member SET status = 'shutdown', execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?",
|
|
128
129
|
[Date.now(), teamId, memberName],
|
|
129
130
|
)
|
|
131
|
+
|
|
132
|
+
// Release any tasks this member was working — they are gone now, so their
|
|
133
|
+
// in_progress work must return to the pool for another teammate (issue #27).
|
|
134
|
+
const released = releaseMemberTasks(deps.db, teamId, memberName)
|
|
135
|
+
if (released > 0) log(`shutdown:tasks:released name=${memberName} count=${released}`)
|
|
130
136
|
}
|
|
131
137
|
|
|
132
138
|
/** Build a status line describing the teammate's work: commit count, dirty state, next step. */
|