@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 +68 -31
- package/package.json +1 -1
- package/src/tools/team-claim.ts +33 -18
- package/src/tools/team-spawn.ts +41 -10
- package/src/tools/team-tasks-complete.ts +27 -3
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 =
|
|
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=${
|
|
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} (@${
|
|
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,
|
|
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,
|
|
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 "${
|
|
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 (
|
|
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
|
|
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: ${
|
|
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
|
|
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
|
-
|
|
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.
|
|
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",
|
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-spawn.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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=${
|
|
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} (@${
|
|
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,
|
|
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,
|
|
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 "${
|
|
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 (
|
|
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
|
|
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: ${
|
|
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
|
|
17
|
-
.get(args.task_id, teamInfo.teamId) as
|
|
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
|
-
|
|
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 = ?")
|