Files
goclaw/internal/store/pg/teams_tasks_progress.go
yatulandClaude Fable 5.1 60cf79de51 feat(teams): let a human cancel and retry stuck team tasks from the dashboard (#1559)
* 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>
2026-09-09 13:30:48 +07:00

255 lines
8.9 KiB
Go

package pg
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// ============================================================
// Progress
// ============================================================
func (s *PGTeamStore) 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)
}
// Also renews lock_expires_at as a heartbeat.
now := time.Now()
lockExpires := now.Add(taskLockDuration)
tid := tenantIDForInsert(ctx)
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET progress_percent = $1, progress_step = $2, lock_expires_at = $3, updated_at = $4
WHERE id = $5 AND status = $6 AND team_id = $7 AND tenant_id = $8`,
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 {
// Diagnostic: log actual status to help trace why the task is not in_progress.
if diag, _ := s.GetTask(ctx, taskID); diag != nil {
slog.Warn("UpdateTaskProgress: status mismatch", "task_id", taskID,
"expected", "in_progress", "actual", diag.Status, "lock_expires", diag.LockExpiresAt)
}
return fmt.Errorf("task not in progress or not found")
}
return nil
}
// RenewTaskLock extends the lock expiration for an in-progress task.
// Called periodically by the consumer as a heartbeat to prevent
// the ticker from recovering a long-running task.
func (s *PGTeamStore) 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 = $1, updated_at = $2
WHERE id = $3 AND team_id = $4 AND status = $5 AND tenant_id = $6`,
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 (batch — all active teams in one query)
// ============================================================
// activeTeamJoin is the JOIN clause that filters to active teams.
const activeTeamJoin = `JOIN agent_teams tm ON tm.id = t.team_id
AND tm.status = 'active'`
// RecoverAllStaleTasks resets in_progress tasks with expired locks across all active teams.
func (s *PGTeamStore) RecoverAllStaleTasks(ctx context.Context) ([]store.RecoveredTaskInfo, error) {
now := time.Now()
rows, err := s.db.QueryContext(ctx,
`UPDATE team_tasks t
SET status = $1, 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 = $2
FROM agent_teams tm
WHERE t.team_id = tm.id AND tm.status = 'active'
AND t.status = $3
AND t.lock_expires_at IS NOT NULL AND t.lock_expires_at < $2
RETURNING t.id, t.team_id, t.tenant_id, t.task_number, t.subject, COALESCE(t.channel, ''), COALESCE(t.chat_id, '')`,
store.TeamTaskStatusPending, now, store.TeamTaskStatusInProgress,
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanRecoveredTaskInfoRows(rows)
}
// ForceRecoverAllTasks resets ALL in_progress tasks across active teams (startup).
func (s *PGTeamStore) ForceRecoverAllTasks(ctx context.Context) ([]store.RecoveredTaskInfo, error) {
now := time.Now()
rows, err := s.db.QueryContext(ctx,
`UPDATE team_tasks t
SET status = $1, 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 = $2
FROM agent_teams tm
WHERE t.team_id = tm.id AND tm.status = 'active'
AND t.status = $3
RETURNING t.id, t.team_id, t.tenant_id, t.task_number, t.subject, COALESCE(t.channel, ''), COALESCE(t.chat_id, '')`,
store.TeamTaskStatusPending, now, store.TeamTaskStatusInProgress,
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanRecoveredTaskInfoRows(rows)
}
// ListRecoverableTasks returns pending + stale-locked tasks for a single team.
// Used by DispatchUnblockedTasks after task completion.
func (s *PGTeamStore) 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 = $1
AND t.tenant_id = $6
AND (
t.status = $2
OR (t.status = $3 AND t.lock_expires_at IS NOT NULL AND t.lock_expires_at < $4)
)
ORDER BY t.priority DESC, t.created_at
LIMIT $5`,
teamID, store.TeamTaskStatusPending, store.TeamTaskStatusInProgress, now, maxListTasksRows, tid)
if err != nil {
return nil, err
}
defer rows.Close()
return scanTaskRowsJoined(rows)
}
// MarkAllStaleTasks marks pending tasks older than olderThan as stale across all active teams.
func (s *PGTeamStore) MarkAllStaleTasks(ctx context.Context, olderThan time.Time) ([]store.RecoveredTaskInfo, error) {
now := time.Now()
rows, err := s.db.QueryContext(ctx,
`UPDATE team_tasks t
SET status = $1, updated_at = $2
FROM agent_teams tm
WHERE t.team_id = tm.id AND tm.status = 'active'
AND t.status = $3 AND t.updated_at < $4
RETURNING t.id, t.team_id, t.tenant_id, t.task_number, t.subject, COALESCE(t.channel, ''), COALESCE(t.chat_id, '')`,
store.TeamTaskStatusStale, now, store.TeamTaskStatusPending, olderThan,
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanRecoveredTaskInfoRows(rows)
}
// MarkInReviewStaleTasks marks in_review tasks older than olderThan as stale across all active teams.
func (s *PGTeamStore) MarkInReviewStaleTasks(ctx context.Context, olderThan time.Time) ([]store.RecoveredTaskInfo, error) {
now := time.Now()
rows, err := s.db.QueryContext(ctx,
`UPDATE team_tasks t
SET status = $1, updated_at = $2
FROM agent_teams tm
WHERE t.team_id = tm.id AND tm.status = 'active'
AND t.status = $3 AND t.updated_at < $4
RETURNING t.id, t.team_id, t.tenant_id, t.task_number, t.subject, COALESCE(t.channel, ''), COALESCE(t.chat_id, '')`,
store.TeamTaskStatusStale, now, store.TeamTaskStatusInReview, olderThan,
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanRecoveredTaskInfoRows(rows)
}
// FixOrphanedBlockedTasks unblocks blocked tasks where all blockers have reached terminal status.
// Safety net for cases where unblockDependentTasks() transaction rolled back.
func (s *PGTeamStore) FixOrphanedBlockedTasks(ctx context.Context) ([]store.RecoveredTaskInfo, error) {
now := time.Now()
rows, err := s.db.QueryContext(ctx,
`UPDATE team_tasks t
SET blocked_by = '{}', status = $1, updated_at = $2
FROM agent_teams tm
WHERE t.team_id = tm.id AND tm.status = 'active'
AND t.status = 'blocked'
AND array_length(t.blocked_by, 1) > 0
AND NOT EXISTS (
SELECT 1 FROM unnest(t.blocked_by) AS bid(id)
JOIN team_tasks bt ON bt.id = bid.id AND bt.tenant_id = t.tenant_id
WHERE bt.status NOT IN ($3, $4, $5)
)
RETURNING t.id, t.team_id, t.tenant_id, t.task_number, t.subject, COALESCE(t.channel, ''), COALESCE(t.chat_id, '')`,
store.TeamTaskStatusPending, now,
store.TeamTaskStatusCompleted, store.TeamTaskStatusFailed, store.TeamTaskStatusCancelled,
)
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 *PGTeamStore) 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 = $1, locked_at = NULL, lock_expires_at = NULL, result = NULL,
progress_percent = NULL, progress_step = NULL, updated_at = $2
WHERE id = $3 AND team_id = $4 AND status IN ($5, $6, $7, $8, $9) AND tenant_id = $10`,
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
}