@hueyexe/opencode-ensemble 0.16.0 → 0.17.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/dist/index.js +423 -170
- package/package.json +1 -1
- package/src/dashboard-js-render.ts +5 -2
- package/src/dashboard.ts +62 -5
- package/src/hooks.ts +86 -8
- package/src/index.ts +64 -17
- package/src/member-model.ts +30 -0
- package/src/notify.ts +44 -0
- package/src/progress.ts +26 -7
- package/src/recovery.ts +106 -3
- package/src/schema.ts +18 -0
- package/src/tasks.ts +19 -0
- package/src/tools/team-claim.ts +33 -18
- package/src/tools/team-create.ts +15 -2
- package/src/tools/team-merge.ts +30 -3
- package/src/tools/team-message.ts +54 -6
- package/src/tools/team-shutdown.ts +6 -0
- package/src/tools/team-spawn.ts +56 -24
- package/src/tools/team-status.ts +38 -4
- package/src/tools/team-tasks-complete.ts +27 -3
- package/src/types.ts +3 -0
- package/src/watchdog.ts +91 -17
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/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-claim.ts
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
|
+
import type { Database } from "../db"
|
|
1
2
|
import type { ToolDeps } from "../types"
|
|
2
3
|
import { requireTeamMember } from "./shared"
|
|
3
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Atomically claim a pending task for an assignee. Sets status to
|
|
7
|
+
* 'in_progress' and records the assignee. Throws if the task is missing,
|
|
8
|
+
* blocked, already claimed, or no longer pending. Returns the task content
|
|
9
|
+
* on success.
|
|
10
|
+
*
|
|
11
|
+
* Shared by team_claim and team_spawn's claim_task auto-claim so both paths
|
|
12
|
+
* enforce the same atomic claim invariant.
|
|
13
|
+
*/
|
|
14
|
+
export function claimTask(db: Database, teamId: string, taskId: string, assignee: string): string {
|
|
15
|
+
const task = db.query("SELECT content, status, assignee FROM team_task WHERE id = ? AND team_id = ?")
|
|
16
|
+
.get(taskId, teamId) as { content: string; status: string; assignee: string | null } | null
|
|
17
|
+
if (!task) throw new Error(`Task "${taskId}" not found`)
|
|
18
|
+
if (task.status === "blocked") throw new Error(`Task "${taskId}" is blocked by unresolved dependencies`)
|
|
19
|
+
if (task.status !== "pending") throw new Error(`Task "${taskId}" is not pending (status: ${task.status})`)
|
|
20
|
+
if (task.assignee) throw new Error(`Task "${taskId}" is already claimed by ${task.assignee}`)
|
|
21
|
+
|
|
22
|
+
// Atomic claim: UPDATE only if still pending and unassigned
|
|
23
|
+
const result = db.run(
|
|
24
|
+
"UPDATE team_task SET status = 'in_progress', assignee = ?, time_updated = ? WHERE id = ? AND status = 'pending' AND assignee IS NULL",
|
|
25
|
+
[assignee, Date.now(), taskId]
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if (result.changes === 0) {
|
|
29
|
+
throw new Error(`Task "${taskId}" is already claimed (race condition)`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return task.content
|
|
33
|
+
}
|
|
34
|
+
|
|
4
35
|
/**
|
|
5
36
|
* Execute the team_claim tool. Atomically claims a pending task.
|
|
6
37
|
* Rejects if the task is already claimed, blocked, or not pending.
|
|
@@ -13,23 +44,7 @@ export async function executeTeamClaim(
|
|
|
13
44
|
const teamInfo = requireTeamMember(deps, sessionId)
|
|
14
45
|
|
|
15
46
|
const claimerName = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
|
|
47
|
+
const content = claimTask(deps.db, teamInfo.teamId, args.task_id, claimerName)
|
|
16
48
|
|
|
17
|
-
|
|
18
|
-
.get(args.task_id, teamInfo.teamId) as Record<string, unknown> | null
|
|
19
|
-
if (!task) throw new Error(`Task "${args.task_id}" not found`)
|
|
20
|
-
if (task.status === "blocked") throw new Error(`Task "${args.task_id}" is blocked by unresolved dependencies`)
|
|
21
|
-
if (task.status !== "pending") throw new Error(`Task "${args.task_id}" is not pending (status: ${task.status})`)
|
|
22
|
-
if (task.assignee) throw new Error(`Task "${args.task_id}" is already claimed by ${task.assignee}`)
|
|
23
|
-
|
|
24
|
-
// Atomic claim: UPDATE only if still pending and unassigned
|
|
25
|
-
const result = deps.db.run(
|
|
26
|
-
"UPDATE team_task SET status = 'in_progress', assignee = ?, time_updated = ? WHERE id = ? AND status = 'pending' AND assignee IS NULL",
|
|
27
|
-
[claimerName, Date.now(), args.task_id]
|
|
28
|
-
)
|
|
29
|
-
|
|
30
|
-
if (result.changes === 0) {
|
|
31
|
-
throw new Error(`Task "${args.task_id}" is already claimed (race condition)`)
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
return `Claimed task: ${task.content}`
|
|
49
|
+
return `Claimed task: ${content}`
|
|
35
50
|
}
|
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.")
|
|
@@ -90,16 +131,23 @@ export async function executeTeamMessage(
|
|
|
90
131
|
return `Message sent to ${args.to}.`
|
|
91
132
|
}
|
|
92
133
|
|
|
93
|
-
// Guard: skip promptAsync delivery to teammates who have already reported completion (issue #3)
|
|
94
|
-
|
|
95
|
-
|
|
134
|
+
// Guard: skip promptAsync delivery to teammates who have already reported completion (issue #3),
|
|
135
|
+
// unless the lead explicitly forces it. Forcing wakes the session; the natural busy transition
|
|
136
|
+
// (handleSessionStatusEvent) resets the completion flag when the teammate next goes idle, the
|
|
137
|
+
// same reset that already happens for a normal re-activation — this just gives the lead a way
|
|
138
|
+
// to trigger it deliberately instead of it being unreachable once the guard is set.
|
|
139
|
+
if (hasReportedCompletion(deps.db, teamInfo.teamId, args.to) && !args.force) {
|
|
140
|
+
return `Message stored for ${args.to} (teammate has completed their task — message will not wake them). Pass force:true to re-activate them.`
|
|
96
141
|
}
|
|
97
142
|
|
|
98
143
|
// For member-to-member messages, fire-and-forget delivery is safe.
|
|
144
|
+
// Deliver on the recipient's configured model, if any (#26).
|
|
99
145
|
const deliveryText = `[Team message from ${senderName}]: ${messageText}`
|
|
146
|
+
const recipientModel = getMemberModel(deps.db, teamInfo.teamId, args.to)
|
|
100
147
|
deps.client.session.promptAsync({
|
|
101
148
|
sessionID: recipientSessionId,
|
|
102
149
|
parts: [{ type: "text", text: deliveryText }],
|
|
150
|
+
...(recipientModel ? { model: recipientModel } : {}),
|
|
103
151
|
}).then(() => {
|
|
104
152
|
markDelivered(deps.db, msgId)
|
|
105
153
|
}).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. */
|
package/src/tools/team-spawn.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { ToolDeps, PermissionRule } from "../types"
|
|
2
2
|
import { validateMemberName } from "../util"
|
|
3
3
|
import { requireLead } from "./shared"
|
|
4
|
-
import {
|
|
4
|
+
import { claimTask } from "./team-claim"
|
|
5
|
+
import { notifyLead } from "../notify"
|
|
6
|
+
import { releaseMemberTasks } from "../tasks"
|
|
7
|
+
import { parseModelId } from "../member-model"
|
|
5
8
|
import { log } from "../log"
|
|
6
9
|
import type { EnsembleConfig } from "../config"
|
|
7
10
|
import { getTeamResourceParts, teamWorktreeName } from "./merge-helper"
|
|
@@ -9,13 +12,6 @@ import { getTeamResourceParts, teamWorktreeName } from "./merge-helper"
|
|
|
9
12
|
/** Tracks consecutive spawn failures per team for circuit breaker. */
|
|
10
13
|
export const spawnFailures = new Map<string, { count: number; lastError: string }>()
|
|
11
14
|
|
|
12
|
-
/** Parse "provider/model" string into { providerID, modelID } for the SDK. */
|
|
13
|
-
function parseModelId(model: string): { providerID: string; modelID: string } | undefined {
|
|
14
|
-
const slash = model.indexOf("/")
|
|
15
|
-
if (slash <= 0 || slash === model.length - 1) return undefined
|
|
16
|
-
return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) }
|
|
17
|
-
}
|
|
18
|
-
|
|
19
15
|
/**
|
|
20
16
|
* Resolve which model to use for a spawned agent.
|
|
21
17
|
* Priority: explicit arg > modelsByAgent > rotation/random > defaultModel > undefined.
|
|
@@ -67,9 +63,14 @@ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise
|
|
|
67
63
|
*/
|
|
68
64
|
export async function executeTeamSpawn(
|
|
69
65
|
deps: ToolDeps,
|
|
70
|
-
args: { name: string; agent
|
|
66
|
+
args: { name: string; agent?: string | null; prompt: string; model?: string; claim_task?: string; worktree?: boolean; plan_approval?: boolean },
|
|
71
67
|
sessionId: string,
|
|
72
68
|
): Promise<string> {
|
|
69
|
+
// Normalize agent: the tool schema defaults to "build", but Zod's .default()
|
|
70
|
+
// only fires on undefined — an explicit null slips through and would violate
|
|
71
|
+
// the team_member.agent NOT NULL constraint (issue #28).
|
|
72
|
+
const agent = args.agent ?? "build"
|
|
73
|
+
|
|
73
74
|
const nameError = validateMemberName(args.name)
|
|
74
75
|
if (nameError) throw new Error(nameError)
|
|
75
76
|
|
|
@@ -86,11 +87,11 @@ export async function executeTeamSpawn(
|
|
|
86
87
|
.get(teamInfo.teamId, args.name)
|
|
87
88
|
if (existing) throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`)
|
|
88
89
|
|
|
89
|
-
const isReadOnly =
|
|
90
|
+
const isReadOnly = agent === "plan" || agent === "explore"
|
|
90
91
|
const useWorktree = args.worktree !== false && !isReadOnly && !isWorktreeDirectory(deps.directory)
|
|
91
92
|
const usePlanApproval = args.plan_approval === true
|
|
92
93
|
|
|
93
|
-
log(`spawn:start name=${args.name} agent=${
|
|
94
|
+
log(`spawn:start name=${args.name} agent=${agent} worktree=${useWorktree}`)
|
|
94
95
|
|
|
95
96
|
// Create worktree if enabled
|
|
96
97
|
let worktreeDir: string | null = null
|
|
@@ -177,7 +178,7 @@ export async function executeTeamSpawn(
|
|
|
177
178
|
const createResult = await withTimeout(
|
|
178
179
|
deps.client.session.create({
|
|
179
180
|
parentID: sessionId,
|
|
180
|
-
title: `${args.name} (@${
|
|
181
|
+
title: `${args.name} (@${agent} teammate)`,
|
|
181
182
|
permission,
|
|
182
183
|
...(workspaceId ? { workspaceID: workspaceId } : {}),
|
|
183
184
|
}),
|
|
@@ -216,22 +217,43 @@ export async function executeTeamSpawn(
|
|
|
216
217
|
const now = Date.now()
|
|
217
218
|
// Resolve model before DB insert so the stored value matches what promptAsync uses
|
|
218
219
|
const memberCount = (deps.db.query("SELECT COUNT(*) as c FROM team_member WHERE team_id = ?").get(teamInfo.teamId) as { c: number }).c
|
|
219
|
-
const resolvedModel = resolveModel(args.model,
|
|
220
|
+
const resolvedModel = resolveModel(args.model, agent, memberCount, deps.config)
|
|
220
221
|
if (resolvedModel) log(`spawn:model name=${args.name} model=${resolvedModel}`)
|
|
221
222
|
|
|
222
223
|
deps.db.run(
|
|
223
224
|
`INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, worktree_dir, worktree_branch, workspace_id, plan_approval, time_created, time_updated)
|
|
224
225
|
VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
225
|
-
[teamInfo.teamId, args.name, childSessionId,
|
|
226
|
+
[teamInfo.teamId, args.name, childSessionId, agent, resolvedModel ?? null, args.prompt, worktreeDir, worktreeBranch, workspaceId, planApproval, now, now]
|
|
226
227
|
)
|
|
227
228
|
|
|
229
|
+
// Row is inserted as 'busy' directly -- there is no ready->busy status-event
|
|
230
|
+
// transition for a fresh spawn, so the watchdog's usual hook point never fires.
|
|
231
|
+
// Record the baseline here instead, or a member stalled on its first action
|
|
232
|
+
// is invisible to checkStalled() until its first step-finish event lands.
|
|
233
|
+
deps.progressTracker.recordBusyStart(childSessionId)
|
|
234
|
+
|
|
228
235
|
// Register in memory
|
|
229
236
|
deps.registry.register(teamInfo.teamId, args.name, childSessionId)
|
|
230
237
|
|
|
238
|
+
// Auto-claim a task for this teammate when claim_task is provided (issue #27).
|
|
239
|
+
// Claims atomically so a task is never double-assigned when the lead hands out
|
|
240
|
+
// more tasks than there are teammates. Read-only agents cannot claim tasks.
|
|
241
|
+
let claimedTaskContent: string | null = null
|
|
242
|
+
let claimWarning: string | null = null
|
|
243
|
+
if (args.claim_task && !isReadOnly) {
|
|
244
|
+
try {
|
|
245
|
+
claimedTaskContent = claimTask(deps.db, teamInfo.teamId, args.claim_task, args.name)
|
|
246
|
+
log(`spawn:claim:ok name=${args.name} task=${args.claim_task}`)
|
|
247
|
+
} catch (err) {
|
|
248
|
+
claimWarning = err instanceof Error ? err.message : String(err)
|
|
249
|
+
log(`spawn:claim:failed name=${args.name} task=${args.claim_task} err=${claimWarning}`)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
231
253
|
// Build teammate context message
|
|
232
254
|
const context = [
|
|
233
255
|
`You are "${args.name}", a teammate in team "${teamInfo.teamName}".`,
|
|
234
|
-
`Your agent type is "${
|
|
256
|
+
`Your agent type is "${agent}".`,
|
|
235
257
|
]
|
|
236
258
|
|
|
237
259
|
// Show other teammates so this agent knows who to message
|
|
@@ -347,7 +369,7 @@ export async function executeTeamSpawn(
|
|
|
347
369
|
args.prompt,
|
|
348
370
|
)
|
|
349
371
|
|
|
350
|
-
if (
|
|
372
|
+
if (claimedTaskContent) {
|
|
351
373
|
context.push("", `You have been assigned task ${args.claim_task}. Mark it complete when done.`)
|
|
352
374
|
}
|
|
353
375
|
|
|
@@ -364,7 +386,7 @@ export async function executeTeamSpawn(
|
|
|
364
386
|
deps.client.session.promptAsync({
|
|
365
387
|
sessionID: childSessionId,
|
|
366
388
|
parts: [{ type: "text", text: contextStr }],
|
|
367
|
-
agent
|
|
389
|
+
agent,
|
|
368
390
|
...(modelParam ? { model: modelParam } : {}),
|
|
369
391
|
}).catch((err) => {
|
|
370
392
|
const errMsg = err instanceof Error ? err.message : String(err)
|
|
@@ -372,6 +394,13 @@ export async function executeTeamSpawn(
|
|
|
372
394
|
try {
|
|
373
395
|
deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId])
|
|
374
396
|
deps.registry.unregister(childSessionId)
|
|
397
|
+
// Release any task auto-claimed for this teammate so it returns to the pool.
|
|
398
|
+
if (claimedTaskContent) {
|
|
399
|
+
deps.db.run(
|
|
400
|
+
"UPDATE team_task SET status = 'pending', assignee = NULL, time_updated = ? WHERE id = ? AND assignee = ? AND status = 'in_progress'",
|
|
401
|
+
[Date.now(), args.claim_task, args.name]
|
|
402
|
+
)
|
|
403
|
+
}
|
|
375
404
|
deps.client.session.abort({ sessionID: childSessionId }).catch(() => { /* best effort */ })
|
|
376
405
|
if (workspaceId) {
|
|
377
406
|
deps.client.workspace.remove({ id: workspaceId }).catch(() => { /* best effort */ })
|
|
@@ -386,19 +415,22 @@ export async function executeTeamSpawn(
|
|
|
386
415
|
variant: "error",
|
|
387
416
|
duration: 8000,
|
|
388
417
|
}).catch(() => { /* TUI may not be available */ })
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
418
|
+
notifyLead(
|
|
419
|
+
deps.client,
|
|
420
|
+
deps.db,
|
|
421
|
+
teamInfo.teamId,
|
|
422
|
+
`Teammate "${args.name}" failed to start and was removed${modelInfo}. Error: ${errMsg}. You may retry the spawn.`,
|
|
423
|
+
)
|
|
395
424
|
} catch { /* rollback failed — watchdog will clean up stale member */ }
|
|
396
425
|
})
|
|
397
426
|
|
|
398
427
|
const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : ""
|
|
399
428
|
const planInfo = usePlanApproval ? " [plan mode — will send plan for approval]" : ""
|
|
429
|
+
const claimInfo = claimedTaskContent
|
|
430
|
+
? ` (claimed task: ${args.claim_task})`
|
|
431
|
+
: claimWarning ? ` (could not claim task: ${claimWarning})` : ""
|
|
400
432
|
// Reset circuit breaker on success
|
|
401
433
|
spawnFailures.delete(teamInfo.teamId)
|
|
402
434
|
log(`spawn:done name=${args.name} sessionId=${childSessionId}`)
|
|
403
|
-
return `Teammate "${args.name}" spawned (agent: ${
|
|
435
|
+
return `Teammate "${args.name}" spawned (agent: ${agent})${branchInfo}${planInfo}${claimInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`
|
|
404
436
|
}
|