Files
synapbus/internal/channels/task_store.go
T
Algis DumbrisandClaude Opus 4.6 7909450473 feat: implement swarm patterns (task auction, stigmergy, discovery)
Add task auction lifecycle with full permission enforcement:
- TaskStore (SQLite) for tasks and bids CRUD, expiry, channel cancellation
- SwarmService with PostTask, BidOnTask, AcceptBid, CompleteTask
- MCP tools: post_task, bid_task, accept_bid, complete_task, list_tasks
- ExpiryWorker background goroutine for deadline-based task cancellation
- Channel type enforcement (auction ops only on auction channels)
- Agent cannot bid on own task, only poster accepts bids, only assignee completes
- Wired into main.go with graceful shutdown

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:12:33 +02:00

334 lines
10 KiB
Go

package channels
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"time"
)
// TaskStore defines the storage interface for task auction operations.
type TaskStore interface {
CreateTask(ctx context.Context, task *Task) error
GetTask(ctx context.Context, id int64) (*Task, error)
ListTasks(ctx context.Context, channelID int64, status string) ([]*Task, error)
UpdateTaskStatus(ctx context.Context, id int64, status, assignedTo string) error
CreateBid(ctx context.Context, bid *Bid) error
GetBids(ctx context.Context, taskID int64) ([]*Bid, error)
GetBid(ctx context.Context, bidID int64) (*Bid, error)
UpdateBidStatus(ctx context.Context, bidID int64, status string) error
ExpireTasks(ctx context.Context) (int, error)
CancelTasksByChannel(ctx context.Context, channelID int64) (int, error)
}
// SQLiteTaskStore implements TaskStore using SQLite.
type SQLiteTaskStore struct {
db *sql.DB
logger *slog.Logger
}
// NewSQLiteTaskStore creates a new SQLite-backed task store.
func NewSQLiteTaskStore(db *sql.DB) *SQLiteTaskStore {
return &SQLiteTaskStore{
db: db,
logger: slog.Default().With("component", "task-store"),
}
}
// sqliteTimeFormat is the format used for storing timestamps consistently in SQLite.
const sqliteTimeFormat = "2006-01-02 15:04:05"
// CreateTask inserts a new task.
func (s *SQLiteTaskStore) CreateTask(ctx context.Context, task *Task) error {
requirements := string(task.Requirements)
if requirements == "" {
requirements = "{}"
}
// Format deadline as SQLite-compatible timestamp string
var deadlineStr *string
if task.Deadline != nil {
s := task.Deadline.UTC().Format(sqliteTimeFormat)
deadlineStr = &s
}
result, err := s.db.ExecContext(ctx,
`INSERT INTO tasks (channel_id, posted_by, title, description, requirements, deadline, status, assigned_to, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
task.ChannelID, task.PostedBy, task.Title, task.Description, requirements,
deadlineStr, task.Status, task.AssignedTo,
)
if err != nil {
return fmt.Errorf("insert task: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("get task id: %w", err)
}
task.ID = id
s.logger.Info("task created", "id", id, "title", task.Title, "channel_id", task.ChannelID)
return nil
}
// GetTask returns a task by ID.
func (s *SQLiteTaskStore) GetTask(ctx context.Context, id int64) (*Task, error) {
var task Task
var requirements string
var deadlineStr sql.NullString
var assignedTo sql.NullString
err := s.db.QueryRowContext(ctx,
`SELECT id, channel_id, posted_by, title, description, requirements, deadline, status, assigned_to, created_at, updated_at
FROM tasks WHERE id = ?`, id,
).Scan(&task.ID, &task.ChannelID, &task.PostedBy, &task.Title, &task.Description,
&requirements, &deadlineStr, &task.Status, &assignedTo, &task.CreatedAt, &task.UpdatedAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("task not found: %d", id)
}
return nil, fmt.Errorf("get task: %w", err)
}
task.Requirements = json.RawMessage(requirements)
if deadlineStr.Valid {
t, err := time.Parse(sqliteTimeFormat, deadlineStr.String)
if err == nil {
task.Deadline = &t
}
}
if assignedTo.Valid {
task.AssignedTo = assignedTo.String
}
return &task, nil
}
// ListTasks returns tasks for a channel, optionally filtered by status.
func (s *SQLiteTaskStore) ListTasks(ctx context.Context, channelID int64, status string) ([]*Task, error) {
var query string
var args []any
if status != "" {
query = `SELECT id, channel_id, posted_by, title, description, requirements, deadline, status, assigned_to, created_at, updated_at
FROM tasks WHERE channel_id = ? AND status = ? ORDER BY created_at DESC`
args = []any{channelID, status}
} else {
query = `SELECT id, channel_id, posted_by, title, description, requirements, deadline, status, assigned_to, created_at, updated_at
FROM tasks WHERE channel_id = ? ORDER BY created_at DESC`
args = []any{channelID}
}
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list tasks: %w", err)
}
defer rows.Close()
return scanTasks(rows)
}
// UpdateTaskStatus updates a task's status and optionally assigned_to.
func (s *SQLiteTaskStore) UpdateTaskStatus(ctx context.Context, id int64, status, assignedTo string) error {
var result sql.Result
var err error
if assignedTo != "" {
result, err = s.db.ExecContext(ctx,
`UPDATE tasks SET status = ?, assigned_to = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
status, assignedTo, id)
} else {
result, err = s.db.ExecContext(ctx,
`UPDATE tasks SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
status, id)
}
if err != nil {
return fmt.Errorf("update task status: %w", err)
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
return fmt.Errorf("task not found: %d", id)
}
return nil
}
// CreateBid inserts a new bid on a task.
func (s *SQLiteTaskStore) CreateBid(ctx context.Context, bid *Bid) error {
capabilities := string(bid.Capabilities)
if capabilities == "" {
capabilities = "{}"
}
result, err := s.db.ExecContext(ctx,
`INSERT INTO task_bids (task_id, agent_name, capabilities, time_estimate, message, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
bid.TaskID, bid.AgentName, capabilities, bid.TimeEstimate, bid.Message, BidStatusPending,
)
if err != nil {
if isUniqueConstraintError(err) {
return fmt.Errorf("agent %s has already bid on task %d", bid.AgentName, bid.TaskID)
}
return fmt.Errorf("insert bid: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("get bid id: %w", err)
}
bid.ID = id
bid.Status = BidStatusPending
s.logger.Info("bid created", "id", id, "task_id", bid.TaskID, "agent", bid.AgentName)
return nil
}
// GetBids returns all bids for a task.
func (s *SQLiteTaskStore) GetBids(ctx context.Context, taskID int64) ([]*Bid, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, task_id, agent_name, capabilities, time_estimate, message, status, created_at
FROM task_bids WHERE task_id = ? ORDER BY created_at ASC`,
taskID,
)
if err != nil {
return nil, fmt.Errorf("get bids: %w", err)
}
defer rows.Close()
return scanBids(rows)
}
// GetBid returns a bid by ID.
func (s *SQLiteTaskStore) GetBid(ctx context.Context, bidID int64) (*Bid, error) {
var bid Bid
var capabilities string
var timeEstimate sql.NullString
err := s.db.QueryRowContext(ctx,
`SELECT id, task_id, agent_name, capabilities, time_estimate, message, status, created_at
FROM task_bids WHERE id = ?`, bidID,
).Scan(&bid.ID, &bid.TaskID, &bid.AgentName, &capabilities, &timeEstimate, &bid.Message, &bid.Status, &bid.CreatedAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("bid not found: %d", bidID)
}
return nil, fmt.Errorf("get bid: %w", err)
}
bid.Capabilities = json.RawMessage(capabilities)
if timeEstimate.Valid {
bid.TimeEstimate = timeEstimate.String
}
return &bid, nil
}
// UpdateBidStatus updates a bid's status.
func (s *SQLiteTaskStore) UpdateBidStatus(ctx context.Context, bidID int64, status string) error {
result, err := s.db.ExecContext(ctx,
`UPDATE task_bids SET status = ? WHERE id = ?`,
status, bidID,
)
if err != nil {
return fmt.Errorf("update bid status: %w", err)
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
return fmt.Errorf("bid not found: %d", bidID)
}
return nil
}
// ExpireTasks marks all open tasks past their deadline as cancelled.
func (s *SQLiteTaskStore) ExpireTasks(ctx context.Context) (int, error) {
// Use a string-formatted timestamp for consistent SQLite comparison
now := time.Now().UTC().Format("2006-01-02 15:04:05")
result, err := s.db.ExecContext(ctx,
`UPDATE tasks SET status = ?, updated_at = CURRENT_TIMESTAMP
WHERE status = ? AND deadline IS NOT NULL AND deadline < ?`,
TaskStatusCancelled, TaskStatusOpen, now,
)
if err != nil {
return 0, fmt.Errorf("expire tasks: %w", err)
}
rowsAffected, _ := result.RowsAffected()
return int(rowsAffected), nil
}
// CancelTasksByChannel cancels all open tasks for a channel (used before channel deletion).
func (s *SQLiteTaskStore) CancelTasksByChannel(ctx context.Context, channelID int64) (int, error) {
result, err := s.db.ExecContext(ctx,
`UPDATE tasks SET status = ?, updated_at = CURRENT_TIMESTAMP
WHERE channel_id = ? AND status = ?`,
TaskStatusCancelled, channelID, TaskStatusOpen,
)
if err != nil {
return 0, fmt.Errorf("cancel tasks by channel: %w", err)
}
rowsAffected, _ := result.RowsAffected()
return int(rowsAffected), nil
}
// scanTasks scans multiple task rows.
func scanTasks(rows *sql.Rows) ([]*Task, error) {
var tasks []*Task
for rows.Next() {
var task Task
var requirements string
var deadlineStr sql.NullString
var assignedTo sql.NullString
err := rows.Scan(&task.ID, &task.ChannelID, &task.PostedBy, &task.Title, &task.Description,
&requirements, &deadlineStr, &task.Status, &assignedTo, &task.CreatedAt, &task.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("scan task: %w", err)
}
task.Requirements = json.RawMessage(requirements)
if deadlineStr.Valid {
t, err := time.Parse(sqliteTimeFormat, deadlineStr.String)
if err == nil {
task.Deadline = &t
}
}
if assignedTo.Valid {
task.AssignedTo = assignedTo.String
}
tasks = append(tasks, &task)
}
if tasks == nil {
tasks = []*Task{}
}
return tasks, rows.Err()
}
// scanBids scans multiple bid rows.
func scanBids(rows *sql.Rows) ([]*Bid, error) {
var bids []*Bid
for rows.Next() {
var bid Bid
var capabilities string
var timeEstimate sql.NullString
err := rows.Scan(&bid.ID, &bid.TaskID, &bid.AgentName, &capabilities, &timeEstimate,
&bid.Message, &bid.Status, &bid.CreatedAt)
if err != nil {
return nil, fmt.Errorf("scan bid: %w", err)
}
bid.Capabilities = json.RawMessage(capabilities)
if timeEstimate.Valid {
bid.TimeEstimate = timeEstimate.String
}
bids = append(bids, &bid)
}
if bids == nil {
bids = []*Bid{}
}
return bids, rows.Err()
}