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>
255 lines
8.9 KiB
Go
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
|
|
}
|