@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/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 client.session.abort({ sessionID: member.session_id })
49
- } catch { /* best effort */ }
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
+ }
@@ -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
- const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?")
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
  }
@@ -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'").get(args.name, projectId)
23
- if (existing) throw new Error(`Team "${args.name}" already exists`)
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)
@@ -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: string; approve?: boolean; reject?: string },
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: args.text,
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 = args.text
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
- if (hasReportedCompletion(deps.db, teamInfo.teamId, args.to)) {
95
- return `Message stored for ${args.to} (teammate has completed their task — message will not wake them).`
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. */
@@ -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 { sendMessage } from "../messaging"
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: string; prompt: string; model?: string; claim_task?: string; worktree?: boolean; plan_approval?: boolean },
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 = args.agent === "plan" || args.agent === "explore"
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=${args.agent} worktree=${useWorktree}`)
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} (@${args.agent} teammate)`,
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, args.agent, memberCount, deps.config)
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, args.agent, resolvedModel ?? null, args.prompt, worktreeDir, worktreeBranch, workspaceId, planApproval, now, now]
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 "${args.agent}".`,
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 (args.claim_task) {
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: args.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
- sendMessage(deps.db, {
390
- teamId: teamInfo.teamId,
391
- from: "system",
392
- to: "lead",
393
- content: `Teammate "${args.name}" failed to start and was removed${modelInfo}. Error: ${errMsg}. You may retry the spawn.`,
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: ${args.agent})${branchInfo}${planInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`
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
  }