mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 22:14:37 +00:00
* feat(teams): let a human cancel and retry stuck team tasks from the dashboard A task that ended up blocked, stale, failed or cancelled could not be recovered from the UI: the dashboard only deletes terminal tasks and approves/rejects in_review ones. CancelTask and ResetTaskStatus existed in the store but were reachable only through the lead agent's team_tasks tool, which needs the full task UUID the dashboard never shows (#506). Two WS RPCs, wired to Retry / Cancel buttons in the task detail dialog: - teams.tasks.cancel — any task not yet completed/cancelled. Optional reason is stored as the result and posted as a comment so the lead sees it on the board. Dependents are unblocked by the store; they are not dispatched here because the dashboard has no agent turn. - teams.tasks.retry — stale / failed / cancelled / in_review, plus blocked tasks that nothing blocks any more. A human comment is required: it is posted on the task and appended to the assignment prompt, so the assignee gets the missing answer in the same message. Optional agentId reassigns; the lead is refused as assignee (same guard as teams.tasks.assign). ResetTaskStatus (pg + sqlite) now also accepts blocked, guarded on the handler side by an empty blocked_by. Handler tests cover both actions with a stub store: reason/comment persistence, required comment, status gating, blocked_by guard, lead guard, cross-team IDOR. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(permissions): classify teams.tasks.cancel and teams.tasks.retry as write methods Every WS method must be classified in internal/permissions/policy.go, otherwise the router answers UNAUTHORIZED for every role. Caught on a live gateway (the drift test TestMethodRole_DriftCoverage… flags it, I had not run that package). Both sit next to approve/reject/assign as operator-level write methods; the write-methods test now pins them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(teams): let the retry dialog pick the assignee A cancelled or stale task may have no owner (created unassigned, or the member was removed). teams.tasks.retry then needs agentId, which the dialog did not send, so Retry would fail with "task has no assignee". The retry dialog now shows an assignee select (team members minus the lead, defaulting to the current owner) and passes agentId only when it differs from the owner. Retry is offered only when there is someone to pick. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
691 lines
23 KiB
Go
691 lines
23 KiB
Go
//go:build sqlite || sqliteonly
|
|
|
|
package sqlitestore
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// ============================================================
|
|
// Task comments
|
|
// ============================================================
|
|
|
|
func (s *SQLiteTeamStore) AddTaskComment(ctx context.Context, comment *store.TeamTaskCommentData) error {
|
|
if comment.ID == uuid.Nil {
|
|
comment.ID = store.GenNewID()
|
|
}
|
|
comment.CreatedAt = time.Now()
|
|
commentType := comment.CommentType
|
|
if commentType == "" {
|
|
commentType = "note"
|
|
}
|
|
tid := tenantIDForInsert(ctx)
|
|
_, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO team_task_comments (id, task_id, agent_id, user_id, content, comment_type, created_at, tenant_id)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
comment.ID, comment.TaskID, comment.AgentID,
|
|
nilStr(comment.UserID),
|
|
comment.Content, commentType, comment.CreatedAt, tid,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, _ = s.db.ExecContext(ctx,
|
|
`UPDATE team_tasks SET comment_count = comment_count + 1 WHERE id = ? AND tenant_id = ?`, comment.TaskID, tid)
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) ListTaskComments(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskCommentData, error) {
|
|
tid := tenantIDForInsert(ctx)
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT c.id, c.task_id, c.agent_id, c.user_id, c.content, c.comment_type, c.created_at,
|
|
COALESCE(a.agent_key, '') AS agent_key
|
|
FROM team_task_comments c
|
|
LEFT JOIN agents a ON a.id = c.agent_id
|
|
WHERE c.task_id = ? AND c.tenant_id = ?
|
|
ORDER BY c.created_at ASC`, taskID, tid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanTaskCommentRows(rows)
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) ListRecentTaskComments(ctx context.Context, taskID uuid.UUID, limit int) ([]store.TeamTaskCommentData, error) {
|
|
tid := tenantIDForInsert(ctx)
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT c.id, c.task_id, c.agent_id, c.user_id, c.content, c.comment_type, c.created_at,
|
|
COALESCE(a.agent_key, '') AS agent_key
|
|
FROM team_task_comments c
|
|
LEFT JOIN agents a ON a.id = c.agent_id
|
|
WHERE c.task_id = ? AND c.tenant_id = ?
|
|
ORDER BY c.created_at DESC
|
|
LIMIT ?`, taskID, tid, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
comments, err := scanTaskCommentRows(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Reverse to chronological order (ASC) for display.
|
|
for i, j := 0, len(comments)-1; i < j; i, j = i+1, j-1 {
|
|
comments[i], comments[j] = comments[j], comments[i]
|
|
}
|
|
return comments, nil
|
|
}
|
|
|
|
func scanTaskCommentRows(rows *sql.Rows) ([]store.TeamTaskCommentData, error) {
|
|
var comments []store.TeamTaskCommentData
|
|
for rows.Next() {
|
|
var c store.TeamTaskCommentData
|
|
var agentID *uuid.UUID
|
|
var userID sql.NullString
|
|
var createdAt sqliteTime
|
|
if err := rows.Scan(&c.ID, &c.TaskID, &agentID, &userID, &c.Content, &c.CommentType, &createdAt, &c.AgentKey); err != nil {
|
|
return nil, err
|
|
}
|
|
c.CreatedAt = createdAt.Time
|
|
c.AgentID = agentID
|
|
if userID.Valid {
|
|
c.UserID = userID.String
|
|
}
|
|
comments = append(comments, c)
|
|
}
|
|
return comments, rows.Err()
|
|
}
|
|
|
|
// ============================================================
|
|
// Audit events
|
|
// ============================================================
|
|
|
|
func (s *SQLiteTeamStore) RecordTaskEvent(ctx context.Context, event *store.TeamTaskEventData) error {
|
|
if event.ID == uuid.Nil {
|
|
event.ID = store.GenNewID()
|
|
}
|
|
event.CreatedAt = time.Now()
|
|
_, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO team_task_events (id, task_id, event_type, actor_type, actor_id, data, created_at, tenant_id)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
event.ID, event.TaskID, event.EventType, event.ActorType, event.ActorID, event.Data, event.CreatedAt, tenantIDForInsert(ctx),
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) ListTaskEvents(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskEventData, error) {
|
|
tid := tenantIDForInsert(ctx)
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT id, task_id, event_type, actor_type, actor_id, data, created_at
|
|
FROM team_task_events
|
|
WHERE task_id = ? AND tenant_id = ?
|
|
ORDER BY created_at ASC`, taskID, tid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanTaskEventRows(rows)
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) ListTeamEvents(ctx context.Context, teamID uuid.UUID, limit, offset int) ([]store.TeamTaskEventData, error) {
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 50
|
|
}
|
|
tid := tenantIDForInsert(ctx)
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT e.id, e.task_id, e.event_type, e.actor_type, e.actor_id, e.data, e.created_at
|
|
FROM team_task_events e
|
|
JOIN team_tasks t ON t.id = e.task_id
|
|
WHERE t.team_id = ? AND t.tenant_id = ?
|
|
ORDER BY e.created_at DESC
|
|
LIMIT ? OFFSET ?`,
|
|
teamID, tid, limit, offset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanTaskEventRows(rows)
|
|
}
|
|
|
|
func scanTaskEventRows(rows *sql.Rows) ([]store.TeamTaskEventData, error) {
|
|
var events []store.TeamTaskEventData
|
|
for rows.Next() {
|
|
var e store.TeamTaskEventData
|
|
var data json.RawMessage
|
|
var createdAt sqliteTime
|
|
if err := rows.Scan(&e.ID, &e.TaskID, &e.EventType, &e.ActorType, &e.ActorID, &data, &createdAt); err != nil {
|
|
return nil, err
|
|
}
|
|
e.CreatedAt = createdAt.Time
|
|
e.Data = data
|
|
events = append(events, e)
|
|
}
|
|
return events, rows.Err()
|
|
}
|
|
|
|
// ============================================================
|
|
// Attachments
|
|
// ============================================================
|
|
|
|
func (s *SQLiteTeamStore) AttachFileToTask(ctx context.Context, att *store.TeamTaskAttachmentData) error {
|
|
if att.ID == uuid.Nil {
|
|
att.ID = store.GenNewID()
|
|
}
|
|
att.CreatedAt = time.Now()
|
|
if len(att.Metadata) == 0 {
|
|
att.Metadata = json.RawMessage(`{}`)
|
|
}
|
|
// SQLite has no GENERATED columns via modernc driver — compute app-side.
|
|
if att.BaseName == "" && att.Path != "" {
|
|
att.BaseName = ComputeAttachmentBaseName(att.Path)
|
|
}
|
|
tid := tenantIDForInsert(ctx)
|
|
res, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO team_task_attachments (id, task_id, team_id, chat_id, path, base_name, file_size, mime_type, created_by_agent_id, created_by_sender_id, metadata, created_at, tenant_id)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT (task_id, path) DO NOTHING`,
|
|
att.ID, att.TaskID, att.TeamID, att.ChatID, att.Path, att.BaseName,
|
|
att.FileSize, att.MimeType, att.CreatedByAgentID,
|
|
nilStr(att.CreatedBySenderID),
|
|
att.Metadata, att.CreatedAt, tid,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n > 0 {
|
|
_, _ = s.db.ExecContext(ctx,
|
|
`UPDATE team_tasks SET attachment_count = attachment_count + 1 WHERE id = ? AND tenant_id = ?`, att.TaskID, tid)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) GetAttachment(ctx context.Context, attachmentID uuid.UUID) (*store.TeamTaskAttachmentData, error) {
|
|
var a store.TeamTaskAttachmentData
|
|
var agentID *uuid.UUID
|
|
var senderID sql.NullString
|
|
var metadata json.RawMessage
|
|
var createdAt sqliteTime
|
|
tid := tenantIDForInsert(ctx)
|
|
err := s.db.QueryRowContext(ctx,
|
|
`SELECT id, task_id, team_id, chat_id, path, file_size, mime_type,
|
|
created_by_agent_id, created_by_sender_id, metadata, created_at
|
|
FROM team_task_attachments WHERE id = ? AND tenant_id = ?`, attachmentID, tid,
|
|
).Scan(&a.ID, &a.TaskID, &a.TeamID, &a.ChatID, &a.Path, &a.FileSize, &a.MimeType,
|
|
&agentID, &senderID, &metadata, &createdAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
a.CreatedAt = createdAt.Time
|
|
a.CreatedByAgentID = agentID
|
|
if senderID.Valid {
|
|
a.CreatedBySenderID = senderID.String
|
|
}
|
|
a.Metadata = metadata
|
|
return &a, nil
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) ListTaskAttachments(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskAttachmentData, error) {
|
|
tid := tenantIDForInsert(ctx)
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT id, task_id, team_id, chat_id, path, file_size, mime_type,
|
|
created_by_agent_id, created_by_sender_id, metadata, created_at
|
|
FROM team_task_attachments
|
|
WHERE task_id = ? AND tenant_id = ?
|
|
ORDER BY created_at ASC`, taskID, tid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var atts []store.TeamTaskAttachmentData
|
|
for rows.Next() {
|
|
var a store.TeamTaskAttachmentData
|
|
var agentID *uuid.UUID
|
|
var senderID sql.NullString
|
|
var metadata json.RawMessage
|
|
var createdAt sqliteTime
|
|
if err := rows.Scan(&a.ID, &a.TaskID, &a.TeamID, &a.ChatID, &a.Path, &a.FileSize, &a.MimeType,
|
|
&agentID, &senderID, &metadata, &createdAt); err != nil {
|
|
return nil, err
|
|
}
|
|
a.CreatedAt = createdAt.Time
|
|
a.CreatedByAgentID = agentID
|
|
if senderID.Valid {
|
|
a.CreatedBySenderID = senderID.String
|
|
}
|
|
a.Metadata = metadata
|
|
atts = append(atts, a)
|
|
}
|
|
return atts, rows.Err()
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) DetachFileFromTask(ctx context.Context, taskID uuid.UUID, path string) error {
|
|
tid := tenantIDForInsert(ctx)
|
|
res, err := s.db.ExecContext(ctx,
|
|
`DELETE FROM team_task_attachments WHERE task_id = ? AND path = ? AND tenant_id = ?`,
|
|
taskID, path, tid,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n > 0 {
|
|
_, _ = s.db.ExecContext(ctx,
|
|
`UPDATE team_tasks SET attachment_count = MAX(attachment_count - 1, 0) WHERE id = ? AND tenant_id = ?`, taskID, tid)
|
|
|
|
// Phase 04: clean up Phase 2.5 auto-links sourced from this task.
|
|
source := "task:" + taskID.String()
|
|
if _, derr := s.db.ExecContext(ctx, `
|
|
DELETE FROM vault_links
|
|
WHERE json_extract(metadata, '$.source') = ?
|
|
AND (
|
|
from_doc_id IN (SELECT id FROM vault_documents WHERE tenant_id = ?)
|
|
OR to_doc_id IN (SELECT id FROM vault_documents WHERE tenant_id = ?)
|
|
)
|
|
`, source, tid, tid); derr != nil {
|
|
slog.Warn("vault.link.cleanup_on_detach", "task_id", taskID, "err", derr)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ============================================================
|
|
// Progress
|
|
// ============================================================
|
|
|
|
func (s *SQLiteTeamStore) UpdateTaskProgress(ctx context.Context, taskID, teamID uuid.UUID, percent int, step string) error {
|
|
if percent < 0 || percent > 100 {
|
|
return fmt.Errorf("progress percent must be 0-100, got %d", percent)
|
|
}
|
|
now := time.Now()
|
|
lockExpires := now.Add(taskLockDuration)
|
|
tid := tenantIDForInsert(ctx)
|
|
res, err := s.db.ExecContext(ctx,
|
|
`UPDATE team_tasks SET progress_percent = ?, progress_step = ?, lock_expires_at = ?, updated_at = ?
|
|
WHERE id = ? AND status = ? AND team_id = ? AND tenant_id = ?`,
|
|
percent, step, lockExpires, now,
|
|
taskID, store.TeamTaskStatusInProgress, teamID, tid,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n == 0 {
|
|
return fmt.Errorf("task not in progress or not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) RenewTaskLock(ctx context.Context, taskID, teamID uuid.UUID) error {
|
|
now := time.Now()
|
|
lockExpires := now.Add(taskLockDuration)
|
|
tid := tenantIDForInsert(ctx)
|
|
res, err := s.db.ExecContext(ctx,
|
|
`UPDATE team_tasks SET lock_expires_at = ?, updated_at = ?
|
|
WHERE id = ? AND team_id = ? AND status = ? AND tenant_id = ?`,
|
|
lockExpires, now,
|
|
taskID, teamID, store.TeamTaskStatusInProgress, tid,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("task not in progress or not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ============================================================
|
|
// Stale recovery
|
|
// ============================================================
|
|
|
|
// activeTeamJoin is the JOIN that filters to active teams.
|
|
// SQLite uses json_extract instead of PG's ->> operator.
|
|
const activeTeamJoin = `JOIN agent_teams tm ON tm.id = t.team_id
|
|
AND tm.status = 'active'`
|
|
|
|
func (s *SQLiteTeamStore) RecoverAllStaleTasks(ctx context.Context) ([]store.RecoveredTaskInfo, error) {
|
|
now := time.Now()
|
|
// SQLite: UPDATE ... FROM not supported in older versions; use subquery in WHERE.
|
|
_, err := s.db.ExecContext(ctx,
|
|
`UPDATE team_tasks SET status = ?, locked_at = NULL, lock_expires_at = NULL,
|
|
followup_at = NULL, followup_count = 0, followup_message = NULL,
|
|
followup_channel = NULL, followup_chat_id = NULL, updated_at = ?
|
|
WHERE status = ? AND lock_expires_at IS NOT NULL AND lock_expires_at < ?
|
|
AND team_id IN (
|
|
SELECT id FROM agent_teams WHERE status = 'active'
|
|
)`,
|
|
store.TeamTaskStatusPending, now, store.TeamTaskStatusInProgress, now,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.queryRecoveredTasks(ctx, store.TeamTaskStatusPending)
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) ForceRecoverAllTasks(ctx context.Context) ([]store.RecoveredTaskInfo, error) {
|
|
now := time.Now()
|
|
_, err := s.db.ExecContext(ctx,
|
|
`UPDATE team_tasks SET status = ?, locked_at = NULL, lock_expires_at = NULL,
|
|
followup_at = NULL, followup_count = 0, followup_message = NULL,
|
|
followup_channel = NULL, followup_chat_id = NULL, updated_at = ?
|
|
WHERE status = ?
|
|
AND team_id IN (
|
|
SELECT id FROM agent_teams WHERE status = 'active'
|
|
)`,
|
|
store.TeamTaskStatusPending, now, store.TeamTaskStatusInProgress,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.queryRecoveredTasks(ctx, store.TeamTaskStatusPending)
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) ListRecoverableTasks(ctx context.Context, teamID uuid.UUID) ([]store.TeamTaskData, error) {
|
|
now := time.Now()
|
|
tid := tenantIDForInsert(ctx)
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT `+taskSelectCols+`
|
|
`+taskJoinClause+`
|
|
WHERE t.team_id = ? AND t.tenant_id = ?
|
|
AND (
|
|
t.status = ?
|
|
OR (t.status = ? AND t.lock_expires_at IS NOT NULL AND t.lock_expires_at < ?)
|
|
)
|
|
ORDER BY t.priority DESC, t.created_at
|
|
LIMIT ?`,
|
|
teamID, tid,
|
|
store.TeamTaskStatusPending, store.TeamTaskStatusInProgress, now, maxListTasksRows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanTaskRowsJoined(rows)
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) MarkAllStaleTasks(ctx context.Context, olderThan time.Time) ([]store.RecoveredTaskInfo, error) {
|
|
now := time.Now()
|
|
_, err := s.db.ExecContext(ctx,
|
|
`UPDATE team_tasks SET status = ?, updated_at = ?
|
|
WHERE status = ? AND updated_at < ?
|
|
AND team_id IN (
|
|
SELECT id FROM agent_teams WHERE status = 'active'
|
|
)`,
|
|
store.TeamTaskStatusStale, now, store.TeamTaskStatusPending, olderThan,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.queryRecoveredTasks(ctx, store.TeamTaskStatusStale)
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) MarkInReviewStaleTasks(ctx context.Context, olderThan time.Time) ([]store.RecoveredTaskInfo, error) {
|
|
now := time.Now()
|
|
_, err := s.db.ExecContext(ctx,
|
|
`UPDATE team_tasks SET status = ?, updated_at = ?
|
|
WHERE status = ? AND updated_at < ?
|
|
AND team_id IN (
|
|
SELECT id FROM agent_teams WHERE status = 'active'
|
|
)`,
|
|
store.TeamTaskStatusStale, now, store.TeamTaskStatusInReview, olderThan,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.queryRecoveredTasks(ctx, store.TeamTaskStatusStale)
|
|
}
|
|
|
|
// FixOrphanedBlockedTasks unblocks blocked tasks where all blockers have reached terminal status.
|
|
func (s *SQLiteTeamStore) FixOrphanedBlockedTasks(ctx context.Context) ([]store.RecoveredTaskInfo, error) {
|
|
now := time.Now()
|
|
// Find blocked tasks where every entry in blocked_by is in terminal status.
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT t.id, t.team_id, t.tenant_id, t.task_number, t.subject,
|
|
COALESCE(t.channel, ''), COALESCE(t.chat_id, '')
|
|
FROM team_tasks t
|
|
JOIN agent_teams tm ON tm.id = t.team_id AND tm.status = 'active'
|
|
WHERE t.status = 'blocked'
|
|
AND t.blocked_by IS NOT NULL AND t.blocked_by != '[]'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM json_each(t.blocked_by) AS je
|
|
JOIN team_tasks bt ON bt.id = je.value AND bt.tenant_id = t.tenant_id
|
|
WHERE bt.status NOT IN (?, ?, ?)
|
|
)`,
|
|
store.TeamTaskStatusCompleted, store.TeamTaskStatusFailed, store.TeamTaskStatusCancelled,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
infos, err := scanRecoveredTaskInfoRows(rows)
|
|
rows.Close()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Unblock each — clear blocked_by and set pending.
|
|
for _, info := range infos {
|
|
_, _ = s.db.ExecContext(ctx,
|
|
`UPDATE team_tasks SET blocked_by = '[]', status = ?, updated_at = ? WHERE id = ? AND tenant_id = ?`,
|
|
store.TeamTaskStatusPending, now, info.ID, info.TenantID,
|
|
)
|
|
}
|
|
return infos, nil
|
|
}
|
|
|
|
// queryRecoveredTasks fetches recently-updated tasks matching the given status for recovery reporting.
|
|
func (s *SQLiteTeamStore) queryRecoveredTasks(ctx context.Context, status string) ([]store.RecoveredTaskInfo, error) {
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT t.id, t.team_id, t.tenant_id, t.task_number, t.subject,
|
|
COALESCE(t.channel, ''), COALESCE(t.chat_id, '')
|
|
FROM team_tasks t
|
|
WHERE t.status = ?
|
|
AND t.updated_at >= ?
|
|
AND t.team_id IN (
|
|
SELECT id FROM agent_teams WHERE status = 'active'
|
|
)
|
|
ORDER BY t.updated_at DESC
|
|
LIMIT 200`,
|
|
status, time.Now().Add(-5*time.Second),
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanRecoveredTaskInfoRows(rows)
|
|
}
|
|
|
|
func scanRecoveredTaskInfoRows(rows interface {
|
|
Next() bool
|
|
Scan(...any) error
|
|
Err() error
|
|
}) ([]store.RecoveredTaskInfo, error) {
|
|
var out []store.RecoveredTaskInfo
|
|
for rows.Next() {
|
|
var info store.RecoveredTaskInfo
|
|
if err := rows.Scan(&info.ID, &info.TeamID, &info.TenantID, &info.TaskNumber, &info.Subject, &info.Channel, &info.ChatID); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, info)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) ResetTaskStatus(ctx context.Context, taskID, teamID uuid.UUID) error {
|
|
now := time.Now()
|
|
tid := tenantIDForInsert(ctx)
|
|
res, err := s.db.ExecContext(ctx,
|
|
`UPDATE team_tasks SET status = ?, locked_at = NULL, lock_expires_at = NULL, result = NULL,
|
|
progress_percent = NULL, progress_step = NULL, updated_at = ?
|
|
WHERE id = ? AND team_id = ? AND status IN (?, ?, ?, ?, ?) AND tenant_id = ?`,
|
|
store.TeamTaskStatusPending, now,
|
|
taskID, teamID,
|
|
store.TeamTaskStatusStale, store.TeamTaskStatusFailed,
|
|
store.TeamTaskStatusCancelled, store.TeamTaskStatusInReview, store.TeamTaskStatusBlocked, tid,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n == 0 {
|
|
return fmt.Errorf("task not available for reset (not stale/failed/cancelled/in_review or wrong team)")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ============================================================
|
|
// Follow-up reminders
|
|
// ============================================================
|
|
|
|
func (s *SQLiteTeamStore) SetTaskFollowup(ctx context.Context, taskID, teamID uuid.UUID, followupAt time.Time, max int, message, channel, chatID string) error {
|
|
now := time.Now()
|
|
tid := tenantIDForInsert(ctx)
|
|
res, err := s.db.ExecContext(ctx,
|
|
`UPDATE team_tasks SET followup_at = ?, followup_max = ?, followup_message = ?, followup_channel = ?, followup_chat_id = ?, updated_at = ?
|
|
WHERE id = ? AND team_id = ? AND status = ? AND tenant_id = ?`,
|
|
followupAt, max, message, channel, chatID, now,
|
|
taskID, teamID, store.TeamTaskStatusInProgress, tid,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n == 0 {
|
|
return fmt.Errorf("task not in progress or not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) ClearTaskFollowup(ctx context.Context, taskID uuid.UUID) error {
|
|
tid := tenantIDForInsert(ctx)
|
|
_, err := s.db.ExecContext(ctx,
|
|
`UPDATE team_tasks SET followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL, updated_at = ?
|
|
WHERE id = ? AND tenant_id = ?`,
|
|
time.Now(), taskID, tid,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) ListAllFollowupDueTasks(ctx context.Context) ([]store.TeamTaskData, error) {
|
|
now := time.Now()
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT `+taskSelectCols+`
|
|
`+taskJoinClause+`
|
|
`+activeTeamJoin+`
|
|
WHERE t.followup_at IS NOT NULL
|
|
AND t.followup_at <= ?
|
|
AND t.status = ?
|
|
AND (t.followup_max = 0 OR t.followup_count < t.followup_max)
|
|
ORDER BY t.followup_at
|
|
LIMIT 100`,
|
|
now, store.TeamTaskStatusInProgress,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanTaskRowsJoined(rows)
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) IncrementFollowupCount(ctx context.Context, taskID uuid.UUID, nextAt *time.Time) error {
|
|
tid := tenantIDForInsert(ctx)
|
|
_, err := s.db.ExecContext(ctx,
|
|
`UPDATE team_tasks SET followup_count = followup_count + 1, followup_at = ?, updated_at = ?
|
|
WHERE id = ? AND tenant_id = ?`,
|
|
nextAt, time.Now(), taskID, tid,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) ClearFollowupByScope(ctx context.Context, channel, chatID string) (int, error) {
|
|
tid := tenantIDForInsert(ctx)
|
|
res, err := s.db.ExecContext(ctx,
|
|
`UPDATE team_tasks
|
|
SET followup_at = NULL, followup_count = 0, followup_message = NULL,
|
|
followup_channel = NULL, followup_chat_id = NULL, updated_at = ?
|
|
WHERE followup_channel = ? AND followup_chat_id = ?
|
|
AND followup_at IS NOT NULL AND status = ? AND tenant_id = ?`,
|
|
time.Now(), channel, chatID, store.TeamTaskStatusInProgress, tid,
|
|
)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
n, err := res.RowsAffected()
|
|
return int(n), err
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) SetFollowupForActiveTasks(ctx context.Context, teamID uuid.UUID, channel, chatID string, followupAt time.Time, max int, message string) (int, error) {
|
|
tid := tenantIDForInsert(ctx)
|
|
res, err := s.db.ExecContext(ctx,
|
|
`UPDATE team_tasks
|
|
SET followup_at = ?, followup_max = ?, followup_message = ?,
|
|
followup_channel = ?, followup_chat_id = ?, updated_at = ?
|
|
WHERE team_id = ?
|
|
AND status = ?
|
|
AND followup_at IS NULL
|
|
AND tenant_id = ?
|
|
AND (
|
|
(COALESCE(channel,'') = ? AND COALESCE(chat_id,'') = ?)
|
|
OR followup_channel = ?
|
|
OR (COALESCE(channel,'') IN ('', 'system', 'delegate') AND COALESCE(chat_id,'') = '')
|
|
)`,
|
|
followupAt, max, message, channel, chatID, time.Now(),
|
|
teamID, store.TeamTaskStatusInProgress, tid,
|
|
channel, chatID, channel,
|
|
)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
n, err := res.RowsAffected()
|
|
return int(n), err
|
|
}
|
|
|
|
func (s *SQLiteTeamStore) HasActiveMemberTasks(ctx context.Context, teamID uuid.UUID, excludeAgentID uuid.UUID) (bool, error) {
|
|
tid := tenantIDForInsert(ctx)
|
|
var exists bool
|
|
err := s.db.QueryRowContext(ctx,
|
|
`SELECT EXISTS(
|
|
SELECT 1 FROM team_tasks
|
|
WHERE team_id = ?
|
|
AND owner_agent_id IS NOT NULL
|
|
AND owner_agent_id != ?
|
|
AND status IN (?, ?, ?)
|
|
AND tenant_id = ?
|
|
)`,
|
|
teamID, excludeAgentID,
|
|
store.TeamTaskStatusPending, store.TeamTaskStatusInProgress, store.TeamTaskStatusBlocked, tid,
|
|
).Scan(&exists)
|
|
return exists, err
|
|
}
|
|
|
|
// BackfillTaskEmbeddings is a no-op for SQLite — vector search not supported.
|
|
func (s *SQLiteTeamStore) BackfillTaskEmbeddings(_ context.Context) (int, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
// SearchTasksByEmbedding is a no-op for SQLite — falls back to LIKE search.
|
|
func (s *SQLiteTeamStore) SearchTasksByEmbedding(_ context.Context, _ uuid.UUID, _ []float32, _ int, _ string) ([]store.TeamTaskData, error) {
|
|
return nil, nil
|
|
}
|