Add team tasks: several agents in one worktree sharing a board

This commit is contained in:
Ian Gulliver
2026-10-18 08:48:57 -07:00
parent 7f62df19d2
commit 6bcde13b2f
31 changed files with 1392 additions and 439 deletions
+5 -3
View File
@@ -125,9 +125,11 @@ func (s *Store) EndTurn(ctx context.Context, sessionID, actor string, payload ma
role := ""
state := DiscardNone
discardSession := ""
lead := false
err := tx.QueryRowContext(ctx, `
SELECT coalesce(t.id, ''), coalesce(t.project_id, ''), s.owner, s.role, coalesce(t.discard_state, ''), coalesce(t.discard_session, '')
FROM agent_sessions s LEFT JOIN tasks t ON t.id = s.task_id WHERE s.id = ?`, sessionID).Scan(&taskID, &projectID, &owner, &role, &state, &discardSession)
SELECT coalesce(t.id, ''), coalesce(t.project_id, ''), s.owner, s.role, coalesce(t.discard_state, ''), coalesce(t.discard_session, ''),
s.member = coalesce((SELECT f.member FROM agent_sessions f WHERE f.task_id = s.task_id AND f.role = s.role ORDER BY f.created_at LIMIT 1), '')
FROM agent_sessions s LEFT JOIN tasks t ON t.id = s.task_id WHERE s.id = ?`, sessionID).Scan(&taskID, &projectID, &owner, &role, &state, &discardSession, &lead)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
@@ -169,7 +171,7 @@ func (s *Store) EndTurn(ctx context.Context, sessionID, actor string, payload ma
case state == DiscardRunning && discardSession == sessionID:
next = DiscardClosing
_, err = tx.ExecContext(ctx, `UPDATE tasks SET discard_state = ? WHERE id = ?`, string(DiscardClosing), taskID)
case state == DiscardNone && finalizeMerge:
case state == DiscardNone && finalizeMerge && lead:
err = tx.QueryRowContext(ctx, `UPDATE tasks SET finalizing = merging_by, merging_by = '' WHERE id = ? AND merging_by != '' RETURNING finalizing`, taskID).Scan(&finalizing)
if errors.Is(err, sql.ErrNoRows) {
err = nil
+21 -7
View File
@@ -476,6 +476,9 @@ ALTER TABLE inbox_responses DROP COLUMN client;
ALTER TABLE inbox_responses DROP COLUMN exit_code;
`}, {"add-user-default-model", `
ALTER TABLE users ADD COLUMN default_model TEXT NOT NULL DEFAULT 'claude-opus-5-5';
`}, {"add-team-members", `
ALTER TABLE agent_sessions ADD COLUMN member TEXT NOT NULL DEFAULT '';
ALTER TABLE task_inputs ADD COLUMN member TEXT NOT NULL DEFAULT '';
`}}
const (
@@ -1305,7 +1308,7 @@ func (s *Store) Participants(ctx context.Context, taskID string) ([]api.Particip
rows, err := s.db.QueryContext(ctx, `
SELECT u.login, u.display_name, u.git_name, u.git_email
FROM events e JOIN users u ON u.login = e.actor
WHERE e.task_id = ? AND (e.kind = 'user.message' OR (e.kind = 'task.created' AND json_extract(e.payload, '$.forked_from') IS NOT NULL))
WHERE e.task_id = ? AND (e.kind IN ('user.message', 'board.posted') OR (e.kind = 'task.created' AND json_extract(e.payload, '$.forked_from') IS NOT NULL))
GROUP BY u.login ORDER BY min(e.seq)
`, taskID)
if err != nil {
@@ -1349,6 +1352,17 @@ func (s *Store) TaskEvents(ctx context.Context, taskID string, after int64, limi
return scanEvents(rows)
}
func (s *Store) BoardPosts(ctx context.Context, taskID string, after int64) ([]api.Event, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT `+eventColumns+`
FROM events WHERE task_id = ? AND kind = 'board.posted' AND seq > ? ORDER BY seq
`, taskID, after)
if err != nil {
return nil, err
}
return scanEvents(rows)
}
func (s *Store) TaskEventsTail(ctx context.Context, taskID string, limit int) ([]api.Event, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT * FROM (SELECT `+eventColumns+` FROM events WHERE task_id = ? ORDER BY seq DESC LIMIT ?) ORDER BY seq
@@ -1478,9 +1492,9 @@ func (s *Store) CreateSession(ctx context.Context, sess api.AgentSession, projec
}
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO agent_sessions (id, task_id, owner, vendor, model, vendor_session_id, state, role, created_by, created_at, forked_from, forked_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, sess.Id, nullable(sess.TaskId), sess.Owner, string(sess.Vendor), sess.Model, sess.VendorSessionId, string(sess.State), string(sess.Role), sess.CreatedBy, formatTime(sess.CreatedAt), forkedFrom, forkedAt)
INSERT INTO agent_sessions (id, task_id, owner, vendor, model, vendor_session_id, state, role, member, created_by, created_at, forked_from, forked_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, sess.Id, nullable(sess.TaskId), sess.Owner, string(sess.Vendor), sess.Model, sess.VendorSessionId, string(sess.State), string(sess.Role), sess.Member, sess.CreatedBy, formatTime(sess.CreatedAt), forkedFrom, forkedAt)
if err != nil {
return err
}
@@ -1836,7 +1850,7 @@ const taskStateExpr = `CASE
END
FROM agent_sessions s
WHERE s.task_id = tasks.id AND s.role != 'reviewer' AND s.state != 'completed'
ORDER BY s.created_at DESC LIMIT 1
ORDER BY s.state = 'running' DESC, s.subagents > 0 DESC, s.created_at DESC LIMIT 1
), 'no_agent')
END`
@@ -1895,7 +1909,7 @@ const projectColumns = `id, name, repo_path, default_branch, remote, created_by,
const taskColumns = `id, project_id, title, merge_title, branch, worktree_path, base_ref, port_base, color, status, created_by, created_at, closed_at, merge_commit, todo_item, forked_from, forked_at, merge_queued_at, workflow_name, workflow_run, workflow_stage, parent_task_id, parent_merge_by, merge_after_review_by != '', state, merging_by != '' OR finalizing != '', discard_state != '' AND status = 'active', (SELECT coalesce(max(seq), 0) FROM events WHERE events.task_id = tasks.id AND kind = 'turn.ended' AND json_extract(payload, '$.summary') = 1)`
const sessionColumns = `id, coalesce(task_id, ''), owner, vendor, model, vendor_session_id, state, role, created_by, created_at, forked_from, forked_at, subagents`
const sessionColumns = `id, coalesce(task_id, ''), owner, vendor, model, vendor_session_id, state, role, member, created_by, created_at, forked_from, forked_at, subagents`
type scanner interface {
Scan(dest ...any) error
@@ -1985,7 +1999,7 @@ func scanSession(row scanner) (api.AgentSession, error) {
createdAt := ""
forkedFrom := ""
forkedAt := ""
err := row.Scan(&sess.Id, &sess.TaskId, &sess.Owner, &vendor, &sess.Model, &sess.VendorSessionId, &state, &role, &sess.CreatedBy, &createdAt, &forkedFrom, &forkedAt, &sess.Subagents)
err := row.Scan(&sess.Id, &sess.TaskId, &sess.Owner, &vendor, &sess.Model, &sess.VendorSessionId, &state, &role, &sess.Member, &sess.CreatedBy, &createdAt, &forkedFrom, &forkedAt, &sess.Subagents)
if err != nil {
return sess, err
}
+23 -18
View File
@@ -4,12 +4,14 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
)
type TaskInput struct {
ID int64
TaskID string
Member string
Actor string
Text string
Images []string
@@ -26,8 +28,8 @@ func (s *Store) AddTaskInput(ctx context.Context, in TaskInput, projectID, sessi
payload["images"] = in.Images
}
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
res, err := tx.ExecContext(ctx, `INSERT INTO task_inputs (task_id, actor, text, images, created_at) VALUES (?, ?, ?, ?, ?)`,
in.TaskID, in.Actor, in.Text, string(images), now())
res, err := tx.ExecContext(ctx, `INSERT INTO task_inputs (task_id, member, actor, text, images, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
in.TaskID, in.Member, in.Actor, in.Text, string(images), now())
if err != nil {
return err
}
@@ -39,25 +41,28 @@ func (s *Store) AddTaskInput(ctx context.Context, in TaskInput, projectID, sessi
})
}
func (s *Store) WithdrawTaskInput(ctx context.Context, taskID string, id int64, projectID, sessionID, actor string) error {
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
res, err := tx.ExecContext(ctx, `DELETE FROM task_inputs WHERE id = ? AND task_id = ?`, id, taskID)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
func (s *Store) WithdrawTaskInput(ctx context.Context, taskID string, id int64, projectID, actor string) (string, error) {
sessionID := ""
err := s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
member := ""
err := tx.QueryRowContext(ctx, `DELETE FROM task_inputs WHERE id = ? AND task_id = ? RETURNING member`, id, taskID).Scan(&member)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
err = tx.QueryRowContext(ctx, `SELECT id FROM agent_sessions WHERE task_id = ? AND member = ? AND role = 'implementer' ORDER BY created_at DESC LIMIT 1`, taskID, member).Scan(&sessionID)
if err != nil {
return err
}
return emit(sessionEvent("user.unqueued", projectID, taskID, "", sessionID, actor, map[string]any{"id": id}))
})
return sessionID, err
}
func (s *Store) TaskInputs(ctx context.Context, taskID string) ([]TaskInput, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, task_id, actor, text, images, created_at FROM task_inputs WHERE task_id = ? ORDER BY id`, taskID)
func (s *Store) TaskInputs(ctx context.Context, taskID, member string) ([]TaskInput, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, task_id, member, actor, text, images, created_at FROM task_inputs WHERE task_id = ? AND member = ? ORDER BY id`, taskID, member)
if err != nil {
return nil, err
}
@@ -68,7 +73,7 @@ func (s *Store) TaskInputs(ctx context.Context, taskID string) ([]TaskInput, err
in := TaskInput{}
images := ""
createdAt := ""
err = rows.Scan(&in.ID, &in.TaskID, &in.Actor, &in.Text, &images, &createdAt)
err = rows.Scan(&in.ID, &in.TaskID, &in.Member, &in.Actor, &in.Text, &images, &createdAt)
if err != nil {
return nil, err
}
@@ -92,8 +97,8 @@ func (s *Store) RestoreTaskInputs(ctx context.Context, inputs []TaskInput) error
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `INSERT INTO task_inputs (id, task_id, actor, text, images, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
in.ID, in.TaskID, in.Actor, in.Text, string(images), formatTime(in.CreatedAt))
_, err = tx.ExecContext(ctx, `INSERT INTO task_inputs (id, task_id, member, actor, text, images, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
in.ID, in.TaskID, in.Member, in.Actor, in.Text, string(images), formatTime(in.CreatedAt))
if err != nil {
return err
}