@hueyexe/opencode-ensemble 0.16.0 → 0.16.1

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 CHANGED
@@ -19773,6 +19773,30 @@ function requireCanPurgeArchivedTeams(deps, sessionId) {
19773
19773
  }
19774
19774
  }
19775
19775
 
19776
+ // src/tools/team-claim.ts
19777
+ function claimTask(db, teamId, taskId, assignee) {
19778
+ const task = db.query("SELECT content, status, assignee FROM team_task WHERE id = ? AND team_id = ?").get(taskId, teamId);
19779
+ if (!task)
19780
+ throw new Error(`Task "${taskId}" not found`);
19781
+ if (task.status === "blocked")
19782
+ throw new Error(`Task "${taskId}" is blocked by unresolved dependencies`);
19783
+ if (task.status !== "pending")
19784
+ throw new Error(`Task "${taskId}" is not pending (status: ${task.status})`);
19785
+ if (task.assignee)
19786
+ throw new Error(`Task "${taskId}" is already claimed by ${task.assignee}`);
19787
+ const result = db.run("UPDATE team_task SET status = 'in_progress', assignee = ?, time_updated = ? WHERE id = ? AND status = 'pending' AND assignee IS NULL", [assignee, Date.now(), taskId]);
19788
+ if (result.changes === 0) {
19789
+ throw new Error(`Task "${taskId}" is already claimed (race condition)`);
19790
+ }
19791
+ return task.content;
19792
+ }
19793
+ async function executeTeamClaim(deps, args, sessionId) {
19794
+ const teamInfo = requireTeamMember(deps, sessionId);
19795
+ const claimerName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
19796
+ const content = claimTask(deps.db, teamInfo.teamId, args.task_id, claimerName);
19797
+ return `Claimed task: ${content}`;
19798
+ }
19799
+
19776
19800
  // src/tools/team-spawn.ts
19777
19801
  init_merge_helper();
19778
19802
  var spawnFailures = new Map;
@@ -19813,6 +19837,7 @@ function withTimeout(promise2, ms, label) {
19813
19837
  ]);
19814
19838
  }
19815
19839
  async function executeTeamSpawn(deps, args, sessionId) {
19840
+ const agent = args.agent ?? "build";
19816
19841
  const nameError = validateMemberName(args.name);
19817
19842
  if (nameError)
19818
19843
  throw new Error(nameError);
@@ -19824,10 +19849,10 @@ async function executeTeamSpawn(deps, args, sessionId) {
19824
19849
  const existing = deps.db.query("SELECT name FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.name);
19825
19850
  if (existing)
19826
19851
  throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`);
19827
- const isReadOnly = args.agent === "plan" || args.agent === "explore";
19852
+ const isReadOnly = agent === "plan" || agent === "explore";
19828
19853
  const useWorktree = args.worktree !== false && !isReadOnly && !isWorktreeDirectory(deps.directory);
19829
19854
  const usePlanApproval = args.plan_approval === true;
19830
- log(`spawn:start name=${args.name} agent=${args.agent} worktree=${useWorktree}`);
19855
+ log(`spawn:start name=${args.name} agent=${agent} worktree=${useWorktree}`);
19831
19856
  let worktreeDir = null;
19832
19857
  let worktreeBranch = null;
19833
19858
  if (useWorktree) {
@@ -19883,7 +19908,7 @@ async function executeTeamSpawn(deps, args, sessionId) {
19883
19908
  log(`spawn:session:start name=${args.name}`);
19884
19909
  const createResult = await withTimeout(deps.client.session.create({
19885
19910
  parentID: sessionId,
19886
- title: `${args.name} (@${args.agent} teammate)`,
19911
+ title: `${args.name} (@${agent} teammate)`,
19887
19912
  permission,
19888
19913
  ...workspaceId ? { workspaceID: workspaceId } : {}
19889
19914
  }), getSpawnTimeout(), `session.create for "${args.name}"`);
@@ -19922,15 +19947,26 @@ async function executeTeamSpawn(deps, args, sessionId) {
19922
19947
  const planApproval = usePlanApproval ? "pending" : "none";
19923
19948
  const now = Date.now();
19924
19949
  const memberCount = deps.db.query("SELECT COUNT(*) as c FROM team_member WHERE team_id = ?").get(teamInfo.teamId).c;
19925
- const resolvedModel = resolveModel(args.model, args.agent, memberCount, deps.config);
19950
+ const resolvedModel = resolveModel(args.model, agent, memberCount, deps.config);
19926
19951
  if (resolvedModel)
19927
19952
  log(`spawn:model name=${args.name} model=${resolvedModel}`);
19928
19953
  deps.db.run(`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)
19929
- VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?, ?)`, [teamInfo.teamId, args.name, childSessionId, args.agent, resolvedModel ?? null, args.prompt, worktreeDir, worktreeBranch, workspaceId, planApproval, now, now]);
19954
+ VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?, ?)`, [teamInfo.teamId, args.name, childSessionId, agent, resolvedModel ?? null, args.prompt, worktreeDir, worktreeBranch, workspaceId, planApproval, now, now]);
19930
19955
  deps.registry.register(teamInfo.teamId, args.name, childSessionId);
19956
+ let claimedTaskContent = null;
19957
+ let claimWarning = null;
19958
+ if (args.claim_task && !isReadOnly) {
19959
+ try {
19960
+ claimedTaskContent = claimTask(deps.db, teamInfo.teamId, args.claim_task, args.name);
19961
+ log(`spawn:claim:ok name=${args.name} task=${args.claim_task}`);
19962
+ } catch (err) {
19963
+ claimWarning = err instanceof Error ? err.message : String(err);
19964
+ log(`spawn:claim:failed name=${args.name} task=${args.claim_task} err=${claimWarning}`);
19965
+ }
19966
+ }
19931
19967
  const context = [
19932
19968
  `You are "${args.name}", a teammate in team "${teamInfo.teamName}".`,
19933
- `Your agent type is "${args.agent}".`
19969
+ `Your agent type is "${agent}".`
19934
19970
  ];
19935
19971
  const otherMembers = deps.db.query("SELECT name FROM team_member WHERE team_id = ? AND name != ? AND status NOT IN ('shutdown', 'error')").all(teamInfo.teamId, args.name);
19936
19972
  if (otherMembers.length > 0) {
@@ -19973,7 +20009,7 @@ async function executeTeamSpawn(deps, args, sessionId) {
19973
20009
  const lastStep = !isReadOnly && worktreeBranch ? "4" : !isReadOnly ? "3" : "2";
19974
20010
  context.push(`${lastStep}. STOP. Do not send follow-up confirmations, status updates, or 'standing by' messages.`, "", "If you are blocked:", "- Send ONE message to the lead via team_message describing the specific blocker.", "- Do NOT attempt workarounds or make assumptions. Wait for the lead's response.", "", "Your plain text output is NOT visible to the team. You MUST use team_message to communicate.");
19975
20011
  context.push("", "Your task:", args.prompt);
19976
- if (args.claim_task) {
20012
+ if (claimedTaskContent) {
19977
20013
  context.push("", `You have been assigned task ${args.claim_task}. Mark it complete when done.`);
19978
20014
  }
19979
20015
  const contextStr = context.join(`
@@ -19986,7 +20022,7 @@ async function executeTeamSpawn(deps, args, sessionId) {
19986
20022
  deps.client.session.promptAsync({
19987
20023
  sessionID: childSessionId,
19988
20024
  parts: [{ type: "text", text: contextStr }],
19989
- agent: args.agent,
20025
+ agent,
19990
20026
  ...modelParam ? { model: modelParam } : {}
19991
20027
  }).catch((err) => {
19992
20028
  const errMsg = err instanceof Error ? err.message : String(err);
@@ -19994,6 +20030,9 @@ async function executeTeamSpawn(deps, args, sessionId) {
19994
20030
  try {
19995
20031
  deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId]);
19996
20032
  deps.registry.unregister(childSessionId);
20033
+ if (claimedTaskContent) {
20034
+ deps.db.run("UPDATE team_task SET status = 'pending', assignee = NULL, time_updated = ? WHERE id = ? AND assignee = ?", [Date.now(), args.claim_task, args.name]);
20035
+ }
19997
20036
  deps.client.session.abort({ sessionID: childSessionId }).catch(() => {});
19998
20037
  if (workspaceId) {
19999
20038
  deps.client.workspace.remove({ id: workspaceId }).catch(() => {});
@@ -20018,9 +20057,10 @@ async function executeTeamSpawn(deps, args, sessionId) {
20018
20057
  });
20019
20058
  const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : "";
20020
20059
  const planInfo = usePlanApproval ? " [plan mode — will send plan for approval]" : "";
20060
+ const claimInfo = claimedTaskContent ? ` (claimed task: ${args.claim_task})` : claimWarning ? ` (could not claim task: ${claimWarning})` : "";
20021
20061
  spawnFailures.delete(teamInfo.teamId);
20022
20062
  log(`spawn:done name=${args.name} sessionId=${childSessionId}`);
20023
- return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}${planInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`;
20063
+ return `Teammate "${args.name}" spawned (agent: ${agent})${branchInfo}${planInfo}${claimInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`;
20024
20064
  }
20025
20065
 
20026
20066
  // src/tools/team-message.ts
@@ -20754,11 +20794,28 @@ async function executeTeamTasksAdd(deps, args, sessionId) {
20754
20794
  // src/tools/team-tasks-complete.ts
20755
20795
  async function executeTeamTasksComplete(deps, args, sessionId) {
20756
20796
  const teamInfo = requireTeamMember(deps, sessionId);
20757
- const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?").get(args.task_id, teamInfo.teamId);
20797
+ const caller = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
20798
+ const task = deps.db.query("SELECT id, content, status, assignee FROM team_task WHERE id = ? AND team_id = ?").get(args.task_id, teamInfo.teamId);
20758
20799
  if (!task)
20759
20800
  throw new Error(`Task "${args.task_id}" not found`);
20801
+ if (task.status === "completed")
20802
+ throw new Error(`Task "${args.task_id}" is already completed`);
20803
+ if (task.status === "cancelled")
20804
+ throw new Error(`Task "${args.task_id}" was cancelled`);
20805
+ if (task.status === "blocked")
20806
+ throw new Error(`Task "${args.task_id}" is blocked by unresolved dependencies`);
20807
+ if (task.status === "in_progress" && task.assignee && teamInfo.role !== "lead" && caller !== task.assignee) {
20808
+ throw new Error(`Task "${args.task_id}" is claimed by ${task.assignee}`);
20809
+ }
20760
20810
  const now = Date.now();
20761
- deps.db.run("UPDATE team_task SET status = 'completed', time_updated = ? WHERE id = ?", [now, args.task_id]);
20811
+ if (task.assignee) {
20812
+ deps.db.run("UPDATE team_task SET status = 'completed', time_updated = ? WHERE id = ?", [now, args.task_id]);
20813
+ } else {
20814
+ const result = deps.db.run("UPDATE team_task SET status = 'completed', assignee = ?, time_updated = ? WHERE id = ? AND assignee IS NULL", [caller, now, args.task_id]);
20815
+ if (result.changes === 0) {
20816
+ throw new Error(`Task "${args.task_id}" was just completed by another teammate`);
20817
+ }
20818
+ }
20762
20819
  const allTasks = deps.db.query("SELECT id, depends_on, status FROM team_task WHERE team_id = ?").all(teamInfo.teamId);
20763
20820
  let unblocked = 0;
20764
20821
  for (const t of allTasks) {
@@ -20796,26 +20853,6 @@ async function executeTeamTasksComplete(deps, args, sessionId) {
20796
20853
  return `Completed task: ${task.content}${unblockedMsg}`;
20797
20854
  }
20798
20855
 
20799
- // src/tools/team-claim.ts
20800
- async function executeTeamClaim(deps, args, sessionId) {
20801
- const teamInfo = requireTeamMember(deps, sessionId);
20802
- const claimerName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
20803
- const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?").get(args.task_id, teamInfo.teamId);
20804
- if (!task)
20805
- throw new Error(`Task "${args.task_id}" not found`);
20806
- if (task.status === "blocked")
20807
- throw new Error(`Task "${args.task_id}" is blocked by unresolved dependencies`);
20808
- if (task.status !== "pending")
20809
- throw new Error(`Task "${args.task_id}" is not pending (status: ${task.status})`);
20810
- if (task.assignee)
20811
- throw new Error(`Task "${args.task_id}" is already claimed by ${task.assignee}`);
20812
- const result = deps.db.run("UPDATE team_task SET status = 'in_progress', assignee = ?, time_updated = ? WHERE id = ? AND status = 'pending' AND assignee IS NULL", [claimerName, Date.now(), args.task_id]);
20813
- if (result.changes === 0) {
20814
- throw new Error(`Task "${args.task_id}" is already claimed (race condition)`);
20815
- }
20816
- return `Claimed task: ${task.content}`;
20817
- }
20818
-
20819
20856
  // src/tools/team-results.ts
20820
20857
  async function executeTeamResults(deps, args, sessionId) {
20821
20858
  const team = requireTeamMember(deps, sessionId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hueyexe/opencode-ensemble",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "description": "Agent teams for OpenCode — parallel agents with peer-to-peer communication, shared tasks, and coordinated execution",
5
5
  "module": "src/index.ts",
6
6
  "main": "dist/index.js",
@@ -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, PermissionRule } from "../types"
2
2
  import { validateMemberName } from "../util"
3
3
  import { requireLead } from "./shared"
4
+ import { claimTask } from "./team-claim"
4
5
  import { sendMessage } from "../messaging"
5
6
  import { log } from "../log"
6
7
  import type { EnsembleConfig } from "../config"
@@ -67,9 +68,14 @@ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise
67
68
  */
68
69
  export async function executeTeamSpawn(
69
70
  deps: ToolDeps,
70
- args: { name: string; agent: string; prompt: string; model?: string; claim_task?: string; worktree?: boolean; plan_approval?: boolean },
71
+ args: { name: string; agent?: string | null; prompt: string; model?: string; claim_task?: string; worktree?: boolean; plan_approval?: boolean },
71
72
  sessionId: string,
72
73
  ): Promise<string> {
74
+ // Normalize agent: the tool schema defaults to "build", but Zod's .default()
75
+ // only fires on undefined — an explicit null slips through and would violate
76
+ // the team_member.agent NOT NULL constraint (issue #28).
77
+ const agent = args.agent ?? "build"
78
+
73
79
  const nameError = validateMemberName(args.name)
74
80
  if (nameError) throw new Error(nameError)
75
81
 
@@ -86,11 +92,11 @@ export async function executeTeamSpawn(
86
92
  .get(teamInfo.teamId, args.name)
87
93
  if (existing) throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`)
88
94
 
89
- const isReadOnly = args.agent === "plan" || args.agent === "explore"
95
+ const isReadOnly = agent === "plan" || agent === "explore"
90
96
  const useWorktree = args.worktree !== false && !isReadOnly && !isWorktreeDirectory(deps.directory)
91
97
  const usePlanApproval = args.plan_approval === true
92
98
 
93
- log(`spawn:start name=${args.name} agent=${args.agent} worktree=${useWorktree}`)
99
+ log(`spawn:start name=${args.name} agent=${agent} worktree=${useWorktree}`)
94
100
 
95
101
  // Create worktree if enabled
96
102
  let worktreeDir: string | null = null
@@ -177,7 +183,7 @@ export async function executeTeamSpawn(
177
183
  const createResult = await withTimeout(
178
184
  deps.client.session.create({
179
185
  parentID: sessionId,
180
- title: `${args.name} (@${args.agent} teammate)`,
186
+ title: `${args.name} (@${agent} teammate)`,
181
187
  permission,
182
188
  ...(workspaceId ? { workspaceID: workspaceId } : {}),
183
189
  }),
@@ -216,22 +222,37 @@ export async function executeTeamSpawn(
216
222
  const now = Date.now()
217
223
  // Resolve model before DB insert so the stored value matches what promptAsync uses
218
224
  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)
225
+ const resolvedModel = resolveModel(args.model, agent, memberCount, deps.config)
220
226
  if (resolvedModel) log(`spawn:model name=${args.name} model=${resolvedModel}`)
221
227
 
222
228
  deps.db.run(
223
229
  `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
230
  VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?, ?)`,
225
- [teamInfo.teamId, args.name, childSessionId, args.agent, resolvedModel ?? null, args.prompt, worktreeDir, worktreeBranch, workspaceId, planApproval, now, now]
231
+ [teamInfo.teamId, args.name, childSessionId, agent, resolvedModel ?? null, args.prompt, worktreeDir, worktreeBranch, workspaceId, planApproval, now, now]
226
232
  )
227
233
 
228
234
  // Register in memory
229
235
  deps.registry.register(teamInfo.teamId, args.name, childSessionId)
230
236
 
237
+ // Auto-claim a task for this teammate when claim_task is provided (issue #27).
238
+ // Claims atomically so a task is never double-assigned when the lead hands out
239
+ // more tasks than there are teammates. Read-only agents cannot claim tasks.
240
+ let claimedTaskContent: string | null = null
241
+ let claimWarning: string | null = null
242
+ if (args.claim_task && !isReadOnly) {
243
+ try {
244
+ claimedTaskContent = claimTask(deps.db, teamInfo.teamId, args.claim_task, args.name)
245
+ log(`spawn:claim:ok name=${args.name} task=${args.claim_task}`)
246
+ } catch (err) {
247
+ claimWarning = err instanceof Error ? err.message : String(err)
248
+ log(`spawn:claim:failed name=${args.name} task=${args.claim_task} err=${claimWarning}`)
249
+ }
250
+ }
251
+
231
252
  // Build teammate context message
232
253
  const context = [
233
254
  `You are "${args.name}", a teammate in team "${teamInfo.teamName}".`,
234
- `Your agent type is "${args.agent}".`,
255
+ `Your agent type is "${agent}".`,
235
256
  ]
236
257
 
237
258
  // Show other teammates so this agent knows who to message
@@ -347,7 +368,7 @@ export async function executeTeamSpawn(
347
368
  args.prompt,
348
369
  )
349
370
 
350
- if (args.claim_task) {
371
+ if (claimedTaskContent) {
351
372
  context.push("", `You have been assigned task ${args.claim_task}. Mark it complete when done.`)
352
373
  }
353
374
 
@@ -364,7 +385,7 @@ export async function executeTeamSpawn(
364
385
  deps.client.session.promptAsync({
365
386
  sessionID: childSessionId,
366
387
  parts: [{ type: "text", text: contextStr }],
367
- agent: args.agent,
388
+ agent,
368
389
  ...(modelParam ? { model: modelParam } : {}),
369
390
  }).catch((err) => {
370
391
  const errMsg = err instanceof Error ? err.message : String(err)
@@ -372,6 +393,13 @@ export async function executeTeamSpawn(
372
393
  try {
373
394
  deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId])
374
395
  deps.registry.unregister(childSessionId)
396
+ // Release any task auto-claimed for this teammate so it returns to the pool.
397
+ if (claimedTaskContent) {
398
+ deps.db.run(
399
+ "UPDATE team_task SET status = 'pending', assignee = NULL, time_updated = ? WHERE id = ? AND assignee = ?",
400
+ [Date.now(), args.claim_task, args.name]
401
+ )
402
+ }
375
403
  deps.client.session.abort({ sessionID: childSessionId }).catch(() => { /* best effort */ })
376
404
  if (workspaceId) {
377
405
  deps.client.workspace.remove({ id: workspaceId }).catch(() => { /* best effort */ })
@@ -397,8 +425,11 @@ export async function executeTeamSpawn(
397
425
 
398
426
  const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : ""
399
427
  const planInfo = usePlanApproval ? " [plan mode — will send plan for approval]" : ""
428
+ const claimInfo = claimedTaskContent
429
+ ? ` (claimed task: ${args.claim_task})`
430
+ : claimWarning ? ` (could not claim task: ${claimWarning})` : ""
400
431
  // Reset circuit breaker on success
401
432
  spawnFailures.delete(teamInfo.teamId)
402
433
  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 ? "..." : ""}`
434
+ return `Teammate "${args.name}" spawned (agent: ${agent})${branchInfo}${planInfo}${claimInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`
404
435
  }
@@ -5,6 +5,12 @@ import { log } from "../log"
5
5
  /**
6
6
  * Execute the team_tasks_complete tool. Marks a task as completed
7
7
  * and unblocks any dependent tasks.
8
+ *
9
+ * Enforces task-board accuracy (issue #27):
10
+ * - Rejects completing an already-completed or cancelled task.
11
+ * - Rejects completing a blocked task (dependencies must resolve first).
12
+ * - Only the assignee (or the lead) may complete a claimed task.
13
+ * - Completing an unclaimed task attributes it to the caller atomically.
8
14
  */
9
15
  export async function executeTeamTasksComplete(
10
16
  deps: ToolDeps,
@@ -12,13 +18,31 @@ export async function executeTeamTasksComplete(
12
18
  sessionId: string,
13
19
  ): Promise<string> {
14
20
  const teamInfo = requireTeamMember(deps, sessionId)
21
+ const caller = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
15
22
 
16
- const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?")
17
- .get(args.task_id, teamInfo.teamId) as Record<string, unknown> | null
23
+ const task = deps.db.query("SELECT id, content, status, assignee FROM team_task WHERE id = ? AND team_id = ?")
24
+ .get(args.task_id, teamInfo.teamId) as { id: string; content: string; status: string; assignee: string | null } | null
18
25
  if (!task) throw new Error(`Task "${args.task_id}" not found`)
26
+ if (task.status === "completed") throw new Error(`Task "${args.task_id}" is already completed`)
27
+ if (task.status === "cancelled") throw new Error(`Task "${args.task_id}" was cancelled`)
28
+ if (task.status === "blocked") throw new Error(`Task "${args.task_id}" is blocked by unresolved dependencies`)
29
+ if (task.status === "in_progress" && task.assignee && teamInfo.role !== "lead" && caller !== task.assignee) {
30
+ throw new Error(`Task "${args.task_id}" is claimed by ${task.assignee}`)
31
+ }
19
32
 
20
33
  const now = Date.now()
21
- deps.db.run("UPDATE team_task SET status = 'completed', time_updated = ? WHERE id = ?", [now, args.task_id])
34
+ if (task.assignee) {
35
+ deps.db.run("UPDATE team_task SET status = 'completed', time_updated = ? WHERE id = ?", [now, args.task_id])
36
+ } else {
37
+ // Attribute an unclaimed task to the caller so the board reflects who did it.
38
+ const result = deps.db.run(
39
+ "UPDATE team_task SET status = 'completed', assignee = ?, time_updated = ? WHERE id = ? AND assignee IS NULL",
40
+ [caller, now, args.task_id]
41
+ )
42
+ if (result.changes === 0) {
43
+ throw new Error(`Task "${args.task_id}" was just completed by another teammate`)
44
+ }
45
+ }
22
46
 
23
47
  // Unblock dependent tasks
24
48
  const allTasks = deps.db.query("SELECT id, depends_on, status FROM team_task WHERE team_id = ?")