Files
goclaw/internal/gateway/methods/teams_tasks_human_test.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

314 lines
10 KiB
Go

package methods
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// Coverage for teams.tasks.cancel / teams.tasks.retry — the human-in-the-loop
// transitions the dashboard could not trigger before. The store is a stub that
// records calls; msgBus stays nil so no dispatch is attempted (the handlers
// guard on it, same as teams.tasks.assign).
// ---- stub team store ----
type humanTaskStore struct {
store.TeamStore // nil: any method not overridden below panics loudly
mu sync.Mutex
task *store.TeamTaskData
team *store.TeamData
getErr error
cancelled []string // reasons
comments []string
resets int
assigned []uuid.UUID
}
func (s *humanTaskStore) GetTask(_ context.Context, id uuid.UUID) (*store.TeamTaskData, error) {
if s.getErr != nil {
return nil, s.getErr
}
if s.task == nil || s.task.ID != id {
return nil, store.ErrTaskNotFound
}
cp := *s.task
return &cp, nil
}
func (s *humanTaskStore) GetTeam(_ context.Context, id uuid.UUID) (*store.TeamData, error) {
if s.team == nil || s.team.ID != id {
return nil, errors.New("team not found")
}
return s.team, nil
}
func (s *humanTaskStore) CancelTask(_ context.Context, _, _ uuid.UUID, reason string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.cancelled = append(s.cancelled, reason)
s.task.Status = store.TeamTaskStatusCancelled
return nil
}
func (s *humanTaskStore) AddTaskComment(_ context.Context, c *store.TeamTaskCommentData) error {
s.mu.Lock()
defer s.mu.Unlock()
s.comments = append(s.comments, c.Content)
return nil
}
func (s *humanTaskStore) ResetTaskStatus(_ context.Context, _, _ uuid.UUID) error {
s.mu.Lock()
defer s.mu.Unlock()
s.resets++
s.task.Status = store.TeamTaskStatusPending
return nil
}
func (s *humanTaskStore) AssignTask(_ context.Context, _, agentID, _ uuid.UUID) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.task.Status != store.TeamTaskStatusPending {
return errors.New("assign requires pending")
}
s.assigned = append(s.assigned, agentID)
s.task.Status = store.TeamTaskStatusInProgress
s.task.OwnerAgentID = &agentID
return nil
}
// ---- harness ----
type humanTaskFixture struct {
m *TeamsMethods
st *humanTaskStore
teamID uuid.UUID
taskID uuid.UUID
leadID uuid.UUID
member uuid.UUID
}
func newHumanTaskFixture(t *testing.T, status string) *humanTaskFixture {
t.Helper()
teamID, taskID := uuid.New(), uuid.New()
leadID, memberID := uuid.New(), uuid.New()
owner := memberID
st := &humanTaskStore{
task: &store.TeamTaskData{
BaseModel: store.BaseModel{ID: taskID},
TeamID: teamID, TaskNumber: 7, Subject: "Ship it",
Status: status, OwnerAgentID: &owner, Channel: "telegram", ChatID: "42",
},
team: &store.TeamData{BaseModel: store.BaseModel{ID: teamID}, LeadAgentID: leadID},
}
return &humanTaskFixture{
m: &TeamsMethods{teamStore: st},
st: st,
teamID: teamID, taskID: taskID, leadID: leadID, member: memberID,
}
}
// call runs a handler and decodes the single response frame it sends.
func (f *humanTaskFixture) call(t *testing.T, handler func(context.Context, *gateway.Client, *protocol.RequestFrame), params map[string]any) (ok bool, errCode string, errMsg string) {
t.Helper()
client, frames := gateway.NewCapturingTestClient(permissions.RoleAdmin, uuid.Nil, "human-1", 4)
raw, _ := json.Marshal(params)
handler(context.Background(), client, &protocol.RequestFrame{ID: "1", Params: raw})
select {
case b := <-frames:
var resp struct {
OK bool `json:"ok"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(b, &resp); err != nil {
t.Fatalf("bad response frame: %v: %s", err, b)
}
if resp.Error != nil {
return resp.OK, resp.Error.Code, resp.Error.Message
}
return resp.OK, "", ""
default:
t.Fatal("handler sent no response")
return false, "", ""
}
}
// ---- cancel ----
func TestTaskCancel_BlockedTaskCancelledWithReasonComment(t *testing.T) {
f := newHumanTaskFixture(t, store.TeamTaskStatusBlocked)
ok, code, msg := f.call(t, f.m.handleTaskCancel, map[string]any{
"teamId": f.teamID.String(), "taskId": f.taskID.String(), "reason": "wrong approach, dropping",
})
if !ok {
t.Fatalf("expected ok, got %s: %s", code, msg)
}
if len(f.st.cancelled) != 1 || f.st.cancelled[0] != "wrong approach, dropping" {
t.Fatalf("CancelTask calls = %v", f.st.cancelled)
}
if len(f.st.comments) != 1 || f.st.comments[0] != "wrong approach, dropping" {
t.Fatalf("reason must be posted as a comment, got %v", f.st.comments)
}
}
func TestTaskCancel_NoReasonUsesDefaultAndSkipsComment(t *testing.T) {
f := newHumanTaskFixture(t, store.TeamTaskStatusStale)
if ok, code, msg := f.call(t, f.m.handleTaskCancel, map[string]any{
"teamId": f.teamID.String(), "taskId": f.taskID.String(),
}); !ok {
t.Fatalf("expected ok, got %s: %s", code, msg)
}
if got := f.st.cancelled; len(got) != 1 || got[0] != "Cancelled by human" {
t.Fatalf("default reason expected, got %v", got)
}
if len(f.st.comments) != 0 {
t.Fatalf("no comment expected without a reason, got %v", f.st.comments)
}
}
func TestTaskCancel_TerminalStatusRejected(t *testing.T) {
for _, status := range []string{store.TeamTaskStatusCompleted, store.TeamTaskStatusCancelled} {
f := newHumanTaskFixture(t, status)
ok, code, _ := f.call(t, f.m.handleTaskCancel, map[string]any{
"teamId": f.teamID.String(), "taskId": f.taskID.String(),
})
if ok || code != protocol.ErrInvalidRequest {
t.Fatalf("%s: expected INVALID_REQUEST, got ok=%v code=%s", status, ok, code)
}
if len(f.st.cancelled) != 0 {
t.Fatalf("%s: store must not be touched", status)
}
}
}
func TestTaskCancel_WrongTeamIsNotFound(t *testing.T) {
f := newHumanTaskFixture(t, store.TeamTaskStatusInProgress)
ok, code, _ := f.call(t, f.m.handleTaskCancel, map[string]any{
"teamId": uuid.NewString(), "taskId": f.taskID.String(),
})
if ok || code != protocol.ErrNotFound {
t.Fatalf("expected NOT_FOUND for foreign team, got ok=%v code=%s", ok, code)
}
if len(f.st.cancelled) != 0 {
t.Fatal("IDOR: task of another team was cancelled")
}
}
// ---- retry ----
func TestTaskRetry_RequiresComment(t *testing.T) {
f := newHumanTaskFixture(t, store.TeamTaskStatusFailed)
for _, comment := range []string{"", " \n"} {
ok, code, _ := f.call(t, f.m.handleTaskRetry, map[string]any{
"teamId": f.teamID.String(), "taskId": f.taskID.String(), "comment": comment,
})
if ok || code != protocol.ErrInvalidRequest {
t.Fatalf("comment %q: expected INVALID_REQUEST, got ok=%v code=%s", comment, ok, code)
}
}
if f.st.resets != 0 || len(f.st.assigned) != 0 || len(f.st.comments) != 0 {
t.Fatal("store must not be touched when the comment is missing")
}
}
func TestTaskRetry_FailedTaskGoesBackToOwnerWithComment(t *testing.T) {
f := newHumanTaskFixture(t, store.TeamTaskStatusFailed)
ok, code, msg := f.call(t, f.m.handleTaskRetry, map[string]any{
"teamId": f.teamID.String(), "taskId": f.taskID.String(), "comment": "use the mirrored image, not docker hub",
})
if !ok {
t.Fatalf("expected ok, got %s: %s", code, msg)
}
if len(f.st.comments) != 1 || !strings.Contains(f.st.comments[0], "mirrored image") {
t.Fatalf("comment not stored: %v", f.st.comments)
}
if f.st.resets != 1 {
t.Fatalf("ResetTaskStatus calls = %d", f.st.resets)
}
if len(f.st.assigned) != 1 || f.st.assigned[0] != f.member {
t.Fatalf("task must go back to its owner, assigned = %v", f.st.assigned)
}
if f.st.task.Status != store.TeamTaskStatusInProgress {
t.Fatalf("status = %s", f.st.task.Status)
}
}
func TestTaskRetry_BlockedWithoutBlockersIsAllowed(t *testing.T) {
f := newHumanTaskFixture(t, store.TeamTaskStatusBlocked)
if ok, code, msg := f.call(t, f.m.handleTaskRetry, map[string]any{
"teamId": f.teamID.String(), "taskId": f.taskID.String(), "comment": "answer: option B",
}); !ok {
t.Fatalf("expected ok, got %s: %s", code, msg)
}
if f.st.resets != 1 || len(f.st.assigned) != 1 {
t.Fatalf("resets=%d assigned=%v", f.st.resets, f.st.assigned)
}
}
func TestTaskRetry_BlockedByOtherTasksIsRejected(t *testing.T) {
f := newHumanTaskFixture(t, store.TeamTaskStatusBlocked)
f.st.task.BlockedBy = []uuid.UUID{uuid.New()}
ok, code, msg := f.call(t, f.m.handleTaskRetry, map[string]any{
"teamId": f.teamID.String(), "taskId": f.taskID.String(), "comment": "go",
})
if ok || code != protocol.ErrInvalidRequest || !strings.Contains(msg, "blocked by other tasks") {
t.Fatalf("expected blocked-by rejection, got ok=%v code=%s msg=%s", ok, code, msg)
}
if f.st.resets != 0 || len(f.st.comments) != 0 {
t.Fatal("store must not be touched")
}
}
func TestTaskRetry_NonRetryableStatusRejected(t *testing.T) {
for _, status := range []string{store.TeamTaskStatusInProgress, store.TeamTaskStatusCompleted, store.TeamTaskStatusPending} {
f := newHumanTaskFixture(t, status)
ok, code, _ := f.call(t, f.m.handleTaskRetry, map[string]any{
"teamId": f.teamID.String(), "taskId": f.taskID.String(), "comment": "go",
})
if ok || code != protocol.ErrInvalidRequest {
t.Fatalf("%s: expected INVALID_REQUEST, got ok=%v code=%s", status, ok, code)
}
}
}
func TestTaskRetry_LeadOwnedTaskRejected(t *testing.T) {
f := newHumanTaskFixture(t, store.TeamTaskStatusFailed)
lead := f.leadID
f.st.task.OwnerAgentID = &lead
ok, code, msg := f.call(t, f.m.handleTaskRetry, map[string]any{
"teamId": f.teamID.String(), "taskId": f.taskID.String(), "comment": "go",
})
if ok || code != protocol.ErrInvalidRequest || !strings.Contains(msg, "team lead") {
t.Fatalf("expected lead guard, got ok=%v code=%s msg=%s", ok, code, msg)
}
if f.st.resets != 0 || len(f.st.comments) != 0 {
t.Fatal("store must not be touched")
}
}
func TestTaskRetry_NoOwnerNeedsAgentID(t *testing.T) {
f := newHumanTaskFixture(t, store.TeamTaskStatusCancelled)
f.st.task.OwnerAgentID = nil
ok, code, msg := f.call(t, f.m.handleTaskRetry, map[string]any{
"teamId": f.teamID.String(), "taskId": f.taskID.String(), "comment": "go",
})
if ok || code != protocol.ErrInvalidRequest || !strings.Contains(msg, "agentId") {
t.Fatalf("expected agentId requirement, got ok=%v code=%s msg=%s", ok, code, msg)
}
}