mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 08:18:19 +00:00
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>
This commit is contained in:
1 parent
34cc6e011d
commit
60cf79de51
25 files changed
+957
-29
No files matched your search
@@ -21,6 +21,15 @@ All notable changes to GoClaw are documented here. For full documentation, see [
|
||||
the UUID, while the agent-facing `team_tasks` tool and RPCs take the full
|
||||
UUID, so a human had no way to name a task to the lead from the dashboard.
|
||||
|
||||
- **Dashboard can cancel and retry stuck team tasks** — new WS RPCs
|
||||
`teams.tasks.cancel` (any task not yet completed/cancelled; optional reason is
|
||||
posted as a comment) and `teams.tasks.retry` (stale / failed / cancelled /
|
||||
in_review, and blocked tasks that nothing blocks any more; a human comment is
|
||||
required and is both posted on the task and appended to the assignment prompt).
|
||||
Task detail dialog gets Retry and Cancel buttons. Previously these transitions
|
||||
were reachable only through the lead agent's `team_tasks` tool, so a task that
|
||||
ended up blocked or stale could not be recovered from the UI (#506).
|
||||
|
||||
- **Behavior UX sidecar delivery overrides** — Adds sidecar-generated Quick
|
||||
Acknowledgement and Intermediate Replies with provider/model, timeout, token,
|
||||
and char caps. Effective config resolves Channel > Agent > Workspace, with
|
||||
|
||||
@@ -288,6 +288,8 @@ flowchart TD
|
||||
| `teams.tasks.delete` | Delete a task |
|
||||
| `teams.tasks.claim` | Claim a task (mark as in-progress) |
|
||||
| `teams.tasks.assign` | Assign task to member |
|
||||
| `teams.tasks.cancel` | Cancel an unfinished task from the dashboard |
|
||||
| `teams.tasks.retry` | Re-dispatch a stuck task to its assignee with a required human comment |
|
||||
| `teams.tasks.approve` | Approve completed task |
|
||||
| `teams.tasks.reject` | Reject task submission |
|
||||
| `teams.tasks.comment` | Add comment to task |
|
||||
|
||||
@@ -431,6 +431,8 @@ sequenceDiagram
|
||||
| `teams.tasks.comments` | List comments |
|
||||
| `teams.tasks.events` | List task events |
|
||||
| `teams.tasks.assign` | Assign to member |
|
||||
| `teams.tasks.cancel` | Cancel an unfinished task (human); optional `reason` is posted as a comment |
|
||||
| `teams.tasks.retry` | Send a stale/failed/cancelled/blocked task back to its assignee; `comment` required, optional `agentId` reassigns |
|
||||
| `teams.tasks.delete` | Delete task |
|
||||
| `teams.tasks.delete-bulk` | Bulk delete tasks |
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ func (m *TeamsMethods) RegisterTasks(router *gateway.MethodRouter) {
|
||||
router.Register(protocol.MethodTeamsTaskDelete, m.handleTaskDelete)
|
||||
router.Register(protocol.MethodTeamsTaskDeleteBulk, m.handleTaskDeleteBulk)
|
||||
router.Register(protocol.MethodTeamsTaskAssign, m.handleTaskAssign)
|
||||
router.Register(protocol.MethodTeamsTaskCancel, m.handleTaskCancel)
|
||||
router.Register(protocol.MethodTeamsTaskRetry, m.handleTaskRetry)
|
||||
}
|
||||
|
||||
// --- Task Get (with comments + events + attachments) ---
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
package methods
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
// Human-in-the-loop task actions from the dashboard.
|
||||
//
|
||||
// Until now a human could only delete a task once it was terminal and
|
||||
// approve/reject one sitting in in_review. A task that ended up blocked,
|
||||
// stale, failed or cancelled could be neither cancelled nor restarted from the
|
||||
// UI: CancelTask/ResetTaskStatus existed in the store but were reachable only
|
||||
// through the lead agent's team_tasks tool, which requires the full task UUID
|
||||
// the dashboard never showed. These two handlers expose the same transitions
|
||||
// to the human directly.
|
||||
|
||||
// --- Task Cancel ---
|
||||
|
||||
type taskCancelParams struct {
|
||||
TeamID string `json:"teamId"`
|
||||
TaskID string `json:"taskId"`
|
||||
Reason string `json:"reason"` // optional; stored as result and posted as a comment
|
||||
}
|
||||
|
||||
func (m *TeamsMethods) handleTaskCancel(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
var params taskCancelParams
|
||||
locale, ok := m.parseTaskParams(ctx, client, req, ¶ms)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
teamID, err := uuid.Parse(params.TeamID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
|
||||
return
|
||||
}
|
||||
taskID, err := uuid.Parse(params.TaskID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "taskId")))
|
||||
return
|
||||
}
|
||||
|
||||
reason := strings.TrimSpace(params.Reason)
|
||||
if len(reason) > maxCommentLength {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "reason too long"))
|
||||
return
|
||||
}
|
||||
|
||||
task, ok := m.loadTeamTask(ctx, client, req, locale, "teams.tasks.cancel", taskID, teamID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch task.Status {
|
||||
case store.TeamTaskStatusCompleted, store.TeamTaskStatusCancelled:
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "task is already "+task.Status))
|
||||
return
|
||||
}
|
||||
|
||||
storedReason := reason
|
||||
if storedReason == "" {
|
||||
storedReason = "Cancelled by human"
|
||||
}
|
||||
// CancelTask also unblocks dependents (blocked → pending). They are not
|
||||
// dispatched here: the dashboard has no agent turn to hang a post-turn
|
||||
// dispatch on, so the lead's next turn (or teams.tasks.retry) picks them up.
|
||||
if err := m.teamStore.CancelTask(ctx, taskID, teamID, storedReason); err != nil {
|
||||
slog.Warn("teams.tasks.cancel failed", "task_id", taskID, "error", err)
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "")))
|
||||
return
|
||||
}
|
||||
|
||||
// A human-supplied reason is worth keeping in the thread, not only in the
|
||||
// result column: the lead reads comments when it reviews the board.
|
||||
if reason != "" {
|
||||
m.addHumanComment(ctx, client, taskID, reason)
|
||||
}
|
||||
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"ok": true, "status": store.TeamTaskStatusCancelled}))
|
||||
|
||||
if m.msgBus != nil {
|
||||
m.msgBus.Broadcast(taskBusEvent(protocol.EventTeamTaskCancelled, protocol.TeamTaskEventPayload{
|
||||
TeamID: teamID.String(),
|
||||
TaskID: taskID.String(),
|
||||
TaskNumber: task.TaskNumber,
|
||||
Subject: task.Subject,
|
||||
Status: store.TeamTaskStatusCancelled,
|
||||
Reason: storedReason,
|
||||
UserID: client.UserID(),
|
||||
Channel: taskOriginChannel(task),
|
||||
ChatID: task.ChatID,
|
||||
Timestamp: taskNowUTC(),
|
||||
ActorType: "human",
|
||||
ActorID: client.UserID(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Task Retry ---
|
||||
|
||||
type taskRetryParams struct {
|
||||
TeamID string `json:"teamId"`
|
||||
TaskID string `json:"taskId"`
|
||||
Comment string `json:"comment"` // required: the human's answer or instructions for the assignee
|
||||
AgentID string `json:"agentId"` // optional: reassign to another member (agent key or UUID)
|
||||
}
|
||||
|
||||
// retryableTaskStatuses mirrors the ResetTaskStatus predicate in the store.
|
||||
// blocked is allowed only when nothing blocks the task any more — a task that
|
||||
// still waits on other tasks must be unblocked by finishing or cancelling
|
||||
// those, not by restarting it.
|
||||
var retryableTaskStatuses = map[string]bool{
|
||||
store.TeamTaskStatusStale: true,
|
||||
store.TeamTaskStatusFailed: true,
|
||||
store.TeamTaskStatusCancelled: true,
|
||||
store.TeamTaskStatusInReview: true,
|
||||
store.TeamTaskStatusBlocked: true,
|
||||
}
|
||||
|
||||
func (m *TeamsMethods) handleTaskRetry(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
var params taskRetryParams
|
||||
locale, ok := m.parseTaskParams(ctx, client, req, ¶ms)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
teamID, err := uuid.Parse(params.TeamID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
|
||||
return
|
||||
}
|
||||
taskID, err := uuid.Parse(params.TaskID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "taskId")))
|
||||
return
|
||||
}
|
||||
|
||||
// The comment is the whole point of a human retry: it is the answer the
|
||||
// assignee was missing. Without it the agent would just hit the same wall.
|
||||
comment := strings.TrimSpace(params.Comment)
|
||||
if comment == "" {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "comment")))
|
||||
return
|
||||
}
|
||||
if len(comment) > maxCommentLength {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "comment too long"))
|
||||
return
|
||||
}
|
||||
|
||||
task, ok := m.loadTeamTask(ctx, client, req, locale, "teams.tasks.retry", taskID, teamID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !retryableTaskStatuses[task.Status] {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest,
|
||||
"task cannot be retried from status "+task.Status+" (retry works on stale, failed, cancelled, in_review and unblocked blocked tasks)"))
|
||||
return
|
||||
}
|
||||
if task.Status == store.TeamTaskStatusBlocked && len(task.BlockedBy) > 0 {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest,
|
||||
"task is still blocked by other tasks — complete or cancel them first"))
|
||||
return
|
||||
}
|
||||
|
||||
// Assignee: explicit reassignment wins, otherwise keep the current owner.
|
||||
var agentID uuid.UUID
|
||||
if params.AgentID != "" {
|
||||
agentID, err = resolveAgentUUIDCached(ctx, m.agentRouter, m.agentStore, params.AgentID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "agentId")))
|
||||
return
|
||||
}
|
||||
} else if task.OwnerAgentID != nil {
|
||||
agentID = *task.OwnerAgentID
|
||||
} else {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "task has no assignee — pass agentId"))
|
||||
return
|
||||
}
|
||||
|
||||
// Same guard as teams.tasks.assign / dispatchTaskToAgent: a task owned by
|
||||
// the lead would be dispatched into the lead's own session and loop.
|
||||
team, err := m.teamStore.GetTeam(ctx, teamID)
|
||||
if err != nil || team == nil {
|
||||
slog.Warn("teams.tasks.retry get team failed", "team_id", teamID, "error", err)
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "")))
|
||||
return
|
||||
}
|
||||
if agentID == team.LeadAgentID {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest,
|
||||
"cannot retry a task assigned to the team lead — pass a team member as agentId"))
|
||||
return
|
||||
}
|
||||
|
||||
// Comment first, so it is already on the task when the assignee reads it.
|
||||
if err := m.teamStore.AddTaskComment(ctx, &store.TeamTaskCommentData{
|
||||
TaskID: taskID,
|
||||
UserID: client.UserID(),
|
||||
Content: comment,
|
||||
}); err != nil {
|
||||
slog.Warn("teams.tasks.retry comment failed", "task_id", taskID, "error", err)
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "")))
|
||||
return
|
||||
}
|
||||
|
||||
// pending first (AssignTask only transitions from pending), then assign.
|
||||
if err := m.teamStore.ResetTaskStatus(ctx, taskID, teamID); err != nil {
|
||||
slog.Warn("teams.tasks.retry reset failed", "task_id", taskID, "error", err)
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "")))
|
||||
return
|
||||
}
|
||||
if err := m.teamStore.AssignTask(ctx, taskID, agentID, teamID); err != nil {
|
||||
slog.Warn("teams.tasks.retry assign failed", "task_id", taskID, "agent_id", agentID, "error", err)
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "")))
|
||||
return
|
||||
}
|
||||
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"ok": true, "status": store.TeamTaskStatusInProgress}))
|
||||
|
||||
if m.msgBus == nil {
|
||||
return
|
||||
}
|
||||
m.msgBus.Broadcast(taskBusEvent(protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{
|
||||
TeamID: teamID.String(),
|
||||
TaskID: taskID.String(),
|
||||
TaskNumber: task.TaskNumber,
|
||||
Subject: task.Subject,
|
||||
Status: store.TeamTaskStatusInProgress,
|
||||
Reason: comment,
|
||||
UserID: client.UserID(),
|
||||
Channel: taskOriginChannel(task),
|
||||
ChatID: task.ChatID,
|
||||
Timestamp: taskNowUTC(),
|
||||
ActorType: "human",
|
||||
ActorID: client.UserID(),
|
||||
}))
|
||||
|
||||
// The assignee gets the task prompt with the human's comment appended, so
|
||||
// the answer arrives in the same message as the assignment instead of
|
||||
// relying on the agent to go and read the comment thread.
|
||||
dispatched := *task
|
||||
dispatched.Description = strings.TrimSpace(task.Description + "\n\n[Retry requested by human]\n" + comment)
|
||||
m.dispatchTaskToAgent(ctx, &dispatched, taskID, teamID, agentID, client.UserID())
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
// loadTeamTask fetches a task and checks it belongs to the team (IDOR guard).
|
||||
// Sends the error response itself and returns ok=false when the caller must stop.
|
||||
func (m *TeamsMethods) loadTeamTask(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame, locale, op string, taskID, teamID uuid.UUID) (*store.TeamTaskData, bool) {
|
||||
task, err := m.teamStore.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrTaskNotFound) {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "task", "")))
|
||||
} else {
|
||||
slog.Warn(op+" get failed", "task_id", taskID, "error", err)
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "")))
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
if task.TeamID != teamID {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "task", "")))
|
||||
return nil, false
|
||||
}
|
||||
return task, true
|
||||
}
|
||||
|
||||
func (m *TeamsMethods) addHumanComment(ctx context.Context, client *gateway.Client, taskID uuid.UUID, content string) {
|
||||
if err := m.teamStore.AddTaskComment(ctx, &store.TeamTaskCommentData{
|
||||
TaskID: taskID,
|
||||
UserID: client.UserID(),
|
||||
Content: content,
|
||||
}); err != nil {
|
||||
slog.Warn("audit.comment_failed", "task_id", taskID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// taskOriginChannel is the channel the task was created from; "dashboard" for
|
||||
// tasks that never had one, matching dispatchTaskToAgent.
|
||||
func taskOriginChannel(task *store.TeamTaskData) string {
|
||||
if task.Channel == "" {
|
||||
return "dashboard"
|
||||
}
|
||||
return task.Channel
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -351,6 +351,8 @@ func isWriteMethod(method string) bool {
|
||||
protocol.MethodTeamsTaskComment,
|
||||
protocol.MethodTeamsTaskCreate,
|
||||
protocol.MethodTeamsTaskAssign,
|
||||
protocol.MethodTeamsTaskCancel,
|
||||
protocol.MethodTeamsTaskRetry,
|
||||
protocol.MethodTeamsWorkspaceDelete,
|
||||
protocol.MethodHooksTest,
|
||||
protocol.MethodPairingRequest,
|
||||
|
||||
@@ -128,6 +128,8 @@ func TestCanAccess_WriteMethods(t *testing.T) {
|
||||
protocol.MethodSessionsDelete,
|
||||
protocol.MethodSessionsCompact,
|
||||
protocol.MethodCronCreate,
|
||||
protocol.MethodTeamsTaskCancel,
|
||||
protocol.MethodTeamsTaskRetry,
|
||||
}
|
||||
for _, method := range writeMethods {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
|
||||
@@ -235,10 +235,10 @@ func (s *PGTeamStore) ResetTaskStatus(ctx context.Context, taskID, teamID uuid.U
|
||||
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) AND tenant_id = $9`,
|
||||
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, tid,
|
||||
store.TeamTaskStatusCancelled, store.TeamTaskStatusInReview, store.TeamTaskStatusBlocked, tid,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -532,11 +532,11 @@ func (s *SQLiteTeamStore) ResetTaskStatus(ctx context.Context, taskID, teamID uu
|
||||
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 = ?`,
|
||||
WHERE id = ? AND team_id = ? AND status IN (?, ?, ?, ?, ?) AND tenant_id = ?`,
|
||||
store.TeamTaskStatusPending, now,
|
||||
taskID, teamID,
|
||||
store.TeamTaskStatusStale, store.TeamTaskStatusFailed,
|
||||
store.TeamTaskStatusCancelled, store.TeamTaskStatusInReview, tid,
|
||||
store.TeamTaskStatusCancelled, store.TeamTaskStatusInReview, store.TeamTaskStatusBlocked, tid,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -145,6 +145,8 @@ const (
|
||||
MethodTeamsTaskDelete = "teams.tasks.delete"
|
||||
MethodTeamsTaskDeleteBulk = "teams.tasks.delete-bulk"
|
||||
MethodTeamsTaskAssign = "teams.tasks.assign"
|
||||
MethodTeamsTaskCancel = "teams.tasks.cancel"
|
||||
MethodTeamsTaskRetry = "teams.tasks.retry"
|
||||
MethodTeamsTaskActiveBySession = "teams.tasks.active-by-session"
|
||||
MethodTeamsMembersAdd = "teams.members.add"
|
||||
MethodTeamsMembersRemove = "teams.members.remove"
|
||||
|
||||
@@ -149,6 +149,8 @@ export const Methods = {
|
||||
TEAMS_TASK_DELETE: "teams.tasks.delete",
|
||||
TEAMS_TASK_DELETE_BULK: "teams.tasks.delete-bulk",
|
||||
TEAMS_TASK_ASSIGN: "teams.tasks.assign",
|
||||
TEAMS_TASK_CANCEL: "teams.tasks.cancel",
|
||||
TEAMS_TASK_RETRY: "teams.tasks.retry",
|
||||
TEAMS_TASK_ACTIVE_BY_SESSION: "teams.tasks.active-by-session",
|
||||
TEAMS_MEMBERS_ADD: "teams.members.add",
|
||||
TEAMS_MEMBERS_REMOVE: "teams.members.remove",
|
||||
|
||||
@@ -129,7 +129,17 @@
|
||||
"deleteBulkConfirm": "This will permanently delete {{count}} task(s). Type \"delete\" to confirm.",
|
||||
"badges": {
|
||||
"awaitingReply": "awaiting reply"
|
||||
}
|
||||
},
|
||||
"cancel": "Cancel task",
|
||||
"cancelTitle": "Cancel this task?",
|
||||
"cancelDescription": "The task is marked cancelled and tasks it was blocking become pending. The reason, if given, is posted as a comment.",
|
||||
"cancelReasonPlaceholder": "Reason (optional)",
|
||||
"retry": "Retry",
|
||||
"retryTitle": "Retry this task",
|
||||
"retryDescription": "The task goes back to its assignee as a new run. Your comment is posted on the task and included in the assignment — this is where the answer or missing instruction goes.",
|
||||
"retryCommentPlaceholder": "What should the assignee do differently, or what was the answer they were waiting for?",
|
||||
"retryCommentRequired": "A comment is required to retry.",
|
||||
"retryAssignee": "Assignee"
|
||||
},
|
||||
"delegations": {
|
||||
"noTitle": "No delegations",
|
||||
@@ -352,7 +362,11 @@
|
||||
"failedRejectTask": "Failed to reject task",
|
||||
"failedBulkDeleteTasks": "Failed to delete tasks",
|
||||
"workspaceFileDeleted": "File deleted",
|
||||
"failedDeleteWorkspaceFile": "Failed to delete file"
|
||||
"failedDeleteWorkspaceFile": "Failed to delete file",
|
||||
"taskCancelled": "Task cancelled",
|
||||
"failedCancelTask": "Failed to cancel task",
|
||||
"taskRetried": "Task sent back to its assignee",
|
||||
"failedRetryTask": "Failed to retry task"
|
||||
},
|
||||
"board": {
|
||||
"kanban": "Board",
|
||||
|
||||
@@ -129,7 +129,17 @@
|
||||
"deleteBulkConfirm": "{{count}}개 태스크가 영구적으로 삭제됩니다. 확인하려면 \"delete\"를 입력하세요.",
|
||||
"badges": {
|
||||
"awaitingReply": "응답 대기 중"
|
||||
}
|
||||
},
|
||||
"cancel": "작업 취소",
|
||||
"cancelTitle": "이 작업을 취소할까요?",
|
||||
"cancelDescription": "작업은 취소됨으로 표시되고, 이 작업이 차단하던 작업은 대기 상태가 됩니다. 사유를 입력하면 댓글로 기록됩니다.",
|
||||
"cancelReasonPlaceholder": "사유(선택)",
|
||||
"retry": "재시도",
|
||||
"retryTitle": "작업 재시도",
|
||||
"retryDescription": "작업이 담당자에게 새 실행으로 다시 전달됩니다. 댓글은 작업에 기록되고 배정 내용에 포함됩니다. 답변이나 누락된 지시를 여기에 적으세요.",
|
||||
"retryCommentPlaceholder": "담당자가 무엇을 다르게 해야 하나요, 또는 어떤 답변을 기다리고 있었나요?",
|
||||
"retryCommentRequired": "재시도하려면 댓글이 필요합니다.",
|
||||
"retryAssignee": "담당자"
|
||||
},
|
||||
"delegations": {
|
||||
"noTitle": "위임 없음",
|
||||
@@ -344,7 +354,11 @@
|
||||
"failedRejectTask": "태스크 거부 실패",
|
||||
"failedBulkDeleteTasks": "태스크 삭제 실패",
|
||||
"workspaceFileDeleted": "파일 삭제됨",
|
||||
"failedDeleteWorkspaceFile": "파일 삭제 실패"
|
||||
"failedDeleteWorkspaceFile": "파일 삭제 실패",
|
||||
"taskCancelled": "작업이 취소되었습니다",
|
||||
"failedCancelTask": "작업 취소 실패",
|
||||
"taskRetried": "작업이 담당자에게 다시 전달되었습니다",
|
||||
"failedRetryTask": "작업 재시도 실패"
|
||||
},
|
||||
"board": {
|
||||
"kanban": "보드",
|
||||
|
||||
@@ -129,7 +129,17 @@
|
||||
"deleteBulkConfirm": "Это безвозвратно удалит {{count}} задач(и). Введите «delete» для подтверждения.",
|
||||
"badges": {
|
||||
"awaitingReply": "ожидает ответа"
|
||||
}
|
||||
},
|
||||
"cancel": "Отменить задачу",
|
||||
"cancelTitle": "Отменить эту задачу?",
|
||||
"cancelDescription": "Задача получит статус «отменена», а задачи, которые она блокировала, станут ожидающими. Причина, если указана, будет добавлена комментарием.",
|
||||
"cancelReasonPlaceholder": "Причина (необязательно)",
|
||||
"retry": "Перезапустить",
|
||||
"retryTitle": "Перезапустить задачу",
|
||||
"retryDescription": "Задача вернётся исполнителю новым прогоном. Ваш комментарий будет добавлен к задаче и включён в постановку — сюда пишется ответ или недостающая инструкция.",
|
||||
"retryCommentPlaceholder": "Что исполнителю сделать иначе, или какой ответ он ждал?",
|
||||
"retryCommentRequired": "Для перезапуска нужен комментарий.",
|
||||
"retryAssignee": "Исполнитель"
|
||||
},
|
||||
"delegations": {
|
||||
"noTitle": "Нет делегирований",
|
||||
@@ -352,7 +362,11 @@
|
||||
"failedRejectTask": "Не удалось отклонить задачу",
|
||||
"failedBulkDeleteTasks": "Не удалось удалить задачи",
|
||||
"workspaceFileDeleted": "Файл удалён",
|
||||
"failedDeleteWorkspaceFile": "Не удалось удалить файл"
|
||||
"failedDeleteWorkspaceFile": "Не удалось удалить файл",
|
||||
"taskCancelled": "Задача отменена",
|
||||
"failedCancelTask": "Не удалось отменить задачу",
|
||||
"taskRetried": "Задача возвращена исполнителю",
|
||||
"failedRetryTask": "Не удалось перезапустить задачу"
|
||||
},
|
||||
"board": {
|
||||
"kanban": "Доска",
|
||||
|
||||
@@ -129,7 +129,17 @@
|
||||
"deleteBulkConfirm": "Thao tác này sẽ xoá vĩnh viễn {{count}} task. Nhập \"delete\" để xác nhận.",
|
||||
"badges": {
|
||||
"awaitingReply": "chờ trả lời"
|
||||
}
|
||||
},
|
||||
"cancel": "Hủy tác vụ",
|
||||
"cancelTitle": "Hủy tác vụ này?",
|
||||
"cancelDescription": "Tác vụ sẽ được đánh dấu đã hủy, các tác vụ bị nó chặn sẽ chuyển sang chờ xử lý. Lý do (nếu có) sẽ được đăng dưới dạng bình luận.",
|
||||
"cancelReasonPlaceholder": "Lý do (không bắt buộc)",
|
||||
"retry": "Thử lại",
|
||||
"retryTitle": "Thử lại tác vụ này",
|
||||
"retryDescription": "Tác vụ sẽ được giao lại cho người thực hiện trong một lượt chạy mới. Bình luận của bạn được đăng lên tác vụ và đưa vào nội dung giao việc — hãy ghi câu trả lời hoặc hướng dẫn còn thiếu tại đây.",
|
||||
"retryCommentPlaceholder": "Người thực hiện cần làm gì khác đi, hoặc họ đang chờ câu trả lời nào?",
|
||||
"retryCommentRequired": "Cần có bình luận để thử lại.",
|
||||
"retryAssignee": "Người thực hiện"
|
||||
},
|
||||
"delegations": {
|
||||
"noTitle": "Không có ủy quyền",
|
||||
@@ -352,7 +362,11 @@
|
||||
"failedRejectTask": "Không thể từ chối task",
|
||||
"failedBulkDeleteTasks": "Không thể xóa các task",
|
||||
"workspaceFileDeleted": "Đã xóa file",
|
||||
"failedDeleteWorkspaceFile": "Không thể xóa file"
|
||||
"failedDeleteWorkspaceFile": "Không thể xóa file",
|
||||
"taskCancelled": "Đã hủy tác vụ",
|
||||
"failedCancelTask": "Không thể hủy tác vụ",
|
||||
"taskRetried": "Đã giao lại tác vụ cho người thực hiện",
|
||||
"failedRetryTask": "Không thể thử lại tác vụ"
|
||||
},
|
||||
"board": {
|
||||
"kanban": "Bảng",
|
||||
|
||||
@@ -129,7 +129,17 @@
|
||||
"deleteBulkConfirm": "此操作将永久删除 {{count}} 个任务。输入「delete」确认。",
|
||||
"badges": {
|
||||
"awaitingReply": "等待回复"
|
||||
}
|
||||
},
|
||||
"cancel": "取消任务",
|
||||
"cancelTitle": "取消此任务?",
|
||||
"cancelDescription": "任务将标记为已取消,被它阻塞的任务将变为待处理。如填写原因,将作为评论发布。",
|
||||
"cancelReasonPlaceholder": "原因(可选)",
|
||||
"retry": "重试",
|
||||
"retryTitle": "重试此任务",
|
||||
"retryDescription": "任务将作为新一轮运行返回给执行者。您的评论会发布到任务并包含在派发内容中——请在此写下答复或缺失的说明。",
|
||||
"retryCommentPlaceholder": "执行者应当如何调整,或者他们在等待什么答复?",
|
||||
"retryCommentRequired": "重试需要填写评论。",
|
||||
"retryAssignee": "执行者"
|
||||
},
|
||||
"delegations": {
|
||||
"noTitle": "暂无委托",
|
||||
@@ -352,7 +362,11 @@
|
||||
"failedRejectTask": "拒绝任务失败",
|
||||
"failedBulkDeleteTasks": "批量删除任务失败",
|
||||
"workspaceFileDeleted": "文件已删除",
|
||||
"failedDeleteWorkspaceFile": "删除文件失败"
|
||||
"failedDeleteWorkspaceFile": "删除文件失败",
|
||||
"taskCancelled": "任务已取消",
|
||||
"failedCancelTask": "取消任务失败",
|
||||
"taskRetried": "任务已返回给执行者",
|
||||
"failedRetryTask": "重试任务失败"
|
||||
},
|
||||
"board": {
|
||||
"kanban": "看板",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useMemo, useRef, memo, lazy, Suspense
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useBoardStore } from "../stores/use-board-store";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
import { buildTaskLookup, buildMemberLookup, buildEmojiLookup } from "./board-utils";
|
||||
import { buildTaskLookup, buildMemberLookup, buildEmojiLookup, buildAssigneeOptions } from "./board-utils";
|
||||
import { BoardToolbar } from "./board-toolbar";
|
||||
import { KanbanBoard } from "./kanban-board";
|
||||
import { TaskList } from "../task-sections";
|
||||
@@ -30,12 +30,14 @@ interface BoardContainerProps {
|
||||
deleteTask?: (teamId: string, taskId: string) => Promise<void>;
|
||||
deleteTasksBulk?: (teamId: string, taskIds: string[]) => Promise<number>;
|
||||
addTaskComment?: (teamId: string, taskId: string, content: string) => Promise<void>;
|
||||
cancelTask?: (teamId: string, taskId: string, reason?: string) => Promise<void>;
|
||||
retryTask?: (teamId: string, taskId: string, comment: string, agentId?: string) => Promise<void>;
|
||||
onWorkspace?: () => void;
|
||||
}
|
||||
|
||||
export const BoardContainer = memo(function BoardContainer({
|
||||
teamId, members, scopes, isTeamV2,
|
||||
getTeamTasks, getTaskDetail, getTaskLight, deleteTask, deleteTasksBulk, addTaskComment, onWorkspace,
|
||||
getTeamTasks, getTaskDetail, getTaskLight, deleteTask, deleteTasksBulk, addTaskComment, cancelTask, retryTask, onWorkspace,
|
||||
}: BoardContainerProps) {
|
||||
const { t } = useTranslation("teams");
|
||||
const viewMode = useBoardStore((s) => s.viewMode);
|
||||
@@ -58,6 +60,7 @@ export const BoardContainer = memo(function BoardContainer({
|
||||
// Lookups for name resolution
|
||||
const taskLookup = useMemo(() => buildTaskLookup(tasks), [tasks]);
|
||||
const memberLookup = useMemo(() => buildMemberLookup(members), [members]);
|
||||
const assigneeOptions = useMemo(() => buildAssigneeOptions(members), [members]);
|
||||
const emojiLookup = useMemo(() => buildEmojiLookup(members), [members]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
@@ -138,6 +141,8 @@ export const BoardContainer = memo(function BoardContainer({
|
||||
deleteTask={deleteTask}
|
||||
deleteTasksBulk={deleteTasksBulk}
|
||||
addTaskComment={addTaskComment}
|
||||
cancelTask={cancelTask}
|
||||
retryTask={retryTask}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -162,6 +167,9 @@ export const BoardContainer = memo(function BoardContainer({
|
||||
onClose={handleCloseDetail}
|
||||
getTaskDetail={getTaskDetail}
|
||||
deleteTask={deleteTask}
|
||||
cancelTask={cancelTask}
|
||||
retryTask={retryTask}
|
||||
assignees={assigneeOptions}
|
||||
taskLookup={taskLookup}
|
||||
memberLookup={memberLookup}
|
||||
emojiLookup={emojiLookup}
|
||||
|
||||
@@ -65,6 +65,15 @@ export function buildMemberLookup(
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Members a task can be handed to on retry: everyone but the lead */
|
||||
export function buildAssigneeOptions(
|
||||
members: TeamMemberData[],
|
||||
): { id: string; name: string }[] {
|
||||
return members
|
||||
.filter((m) => m.role !== "lead")
|
||||
.map((m) => ({ id: m.agent_id, name: m.display_name || m.agent_key || m.agent_id.slice(0, 8) }));
|
||||
}
|
||||
|
||||
/** Build agent_id -> emoji lookup from members */
|
||||
export function buildEmojiLookup(
|
||||
members: TeamMemberData[],
|
||||
|
||||
@@ -230,6 +230,34 @@ export function useTeams() {
|
||||
[ws],
|
||||
);
|
||||
|
||||
const cancelTask = useCallback(
|
||||
async (teamId: string, taskId: string, reason?: string) => {
|
||||
try {
|
||||
await ws.call(Methods.TEAMS_TASK_CANCEL, { teamId, taskId, reason });
|
||||
toast.success(i18next.t("teams:toast.taskCancelled"));
|
||||
} catch (err) {
|
||||
toast.error(i18next.t("teams:toast.failedCancelTask"), userFriendlyError(err));
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[ws],
|
||||
);
|
||||
|
||||
// Retry always carries a human comment: it is the answer the assignee was
|
||||
// missing, and the backend rejects an empty one.
|
||||
const retryTask = useCallback(
|
||||
async (teamId: string, taskId: string, comment: string, agentId?: string) => {
|
||||
try {
|
||||
await ws.call(Methods.TEAMS_TASK_RETRY, { teamId, taskId, comment, agentId });
|
||||
toast.success(i18next.t("teams:toast.taskRetried"));
|
||||
} catch (err) {
|
||||
toast.error(i18next.t("teams:toast.failedRetryTask"), userFriendlyError(err));
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[ws],
|
||||
);
|
||||
|
||||
const addMember = useCallback(
|
||||
async (teamId: string, agent: string, role?: string) => {
|
||||
try {
|
||||
@@ -272,7 +300,7 @@ export function useTeams() {
|
||||
return {
|
||||
teams, loading, load, createTeam, deleteTeam, getTeam, getTeamTasks, getTeamScopes,
|
||||
getTaskDetail, getTaskLight, approveTask, rejectTask, addTaskComment, getTaskComments, getTaskEvents,
|
||||
createTask, deleteTask, deleteTasksBulk, assignTask,
|
||||
createTask, deleteTask, deleteTasksBulk, assignTask, cancelTask, retryTask,
|
||||
addMember, removeMember, updateTeam,
|
||||
};
|
||||
}
|
||||
@@ -5,9 +5,11 @@ import {
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
|
||||
import {
|
||||
Trash2, ArrowUp, ArrowDown, ArrowRight, AlertTriangle, Copy, Check,
|
||||
Trash2, ArrowUp, ArrowDown, ArrowRight, AlertTriangle, Copy, Check, Ban, RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatDate } from "@/lib/format";
|
||||
@@ -15,7 +17,7 @@ import { useWsEvent } from "@/hooks/use-ws-event";
|
||||
import { Events } from "@/api/protocol";
|
||||
import type { TeamTaskData, TeamTaskComment, TeamTaskEvent, TeamTaskAttachment } from "@/types/team";
|
||||
import type { TeamTaskEventPayload } from "@/types/team-events";
|
||||
import { taskStatusBadgeVariant, isTerminalStatus } from "./task-utils";
|
||||
import { taskStatusBadgeVariant, isTerminalStatus, canCancelTask, canRetryTask } from "./task-utils";
|
||||
import { TaskDetailContent } from "./task-detail-content";
|
||||
import { TaskDetailAttachments } from "./task-detail-attachments";
|
||||
import { TaskDetailComments } from "./task-detail-comments";
|
||||
@@ -88,6 +90,88 @@ function MetaItem({ label, children }: { label: string; children: React.ReactNod
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Action dialog (cancel with optional reason / retry with required comment) ── */
|
||||
|
||||
export interface TaskAssigneeOption { id: string; name: string }
|
||||
|
||||
interface TaskActionDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
placeholder: string;
|
||||
confirmLabel: string;
|
||||
required?: boolean;
|
||||
requiredHint?: string;
|
||||
variant?: "default" | "destructive";
|
||||
loading?: boolean;
|
||||
/** When set, an assignee select is shown above the textarea (retry). */
|
||||
assignees?: TaskAssigneeOption[];
|
||||
assigneeLabel?: string;
|
||||
defaultAssigneeId?: string;
|
||||
onConfirm: (text: string, assigneeId?: string) => void;
|
||||
}
|
||||
|
||||
function TaskActionDialog({
|
||||
open, onOpenChange, title, description, placeholder, confirmLabel,
|
||||
required, requiredHint, variant = "default", loading,
|
||||
assignees, assigneeLabel, defaultAssigneeId, onConfirm,
|
||||
}: TaskActionDialogProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const [text, setText] = useState("");
|
||||
const [assigneeId, setAssigneeId] = useState<string>("");
|
||||
useEffect(() => {
|
||||
if (open) { setText(""); setAssigneeId(defaultAssigneeId ?? ""); }
|
||||
}, [open, defaultAssigneeId]);
|
||||
const withAssignee = !!assignees && assignees.length > 0;
|
||||
const missing = (!!required && text.trim() === "") || (withAssignee && assigneeId === "");
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!loading) onOpenChange(v); }}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
{withAssignee && (
|
||||
<div className="space-y-1">
|
||||
{assigneeLabel && <p className="text-xs text-muted-foreground">{assigneeLabel}</p>}
|
||||
<Select value={assigneeId} onValueChange={setAssigneeId} disabled={loading}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{assignees!.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<Textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
rows={4}
|
||||
autoFocus
|
||||
disabled={loading}
|
||||
/>
|
||||
{missing && requiredHint && (
|
||||
<p className="text-xs text-muted-foreground">{requiredHint}</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)} disabled={loading}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button variant={variant} size="sm" onClick={() => onConfirm(text.trim(), assigneeId || undefined)} disabled={loading || missing}>
|
||||
{loading ? "..." : confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Props ────────────────────────────────────────────────────── */
|
||||
|
||||
interface TaskDetailDialogProps {
|
||||
@@ -100,6 +184,10 @@ interface TaskDetailDialogProps {
|
||||
events: TeamTaskEvent[]; attachments: TeamTaskAttachment[];
|
||||
}>;
|
||||
deleteTask?: (teamId: string, taskId: string) => Promise<void>;
|
||||
cancelTask?: (teamId: string, taskId: string, reason?: string) => Promise<void>;
|
||||
retryTask?: (teamId: string, taskId: string, comment: string, agentId?: string) => Promise<void>;
|
||||
/** Team members a retried task may be (re)assigned to; the lead is excluded. */
|
||||
assignees?: TaskAssigneeOption[];
|
||||
onAddComment?: (teamId: string, taskId: string, content: string) => Promise<void>;
|
||||
taskLookup?: Map<string, string>;
|
||||
memberLookup?: Map<string, string>;
|
||||
@@ -109,7 +197,7 @@ interface TaskDetailDialogProps {
|
||||
|
||||
export function TaskDetailDialog({
|
||||
task, teamId, isTeamV2, onClose,
|
||||
getTaskDetail, deleteTask, onAddComment, taskLookup, memberLookup, emojiLookup, onNavigateTask,
|
||||
getTaskDetail, deleteTask, cancelTask, retryTask, assignees, onAddComment, taskLookup, memberLookup, emojiLookup, onNavigateTask,
|
||||
}: TaskDetailDialogProps) {
|
||||
const { t } = useTranslation("teams");
|
||||
const [events, setEvents] = useState<TeamTaskEvent[]>([]);
|
||||
@@ -117,6 +205,10 @@ export function TaskDetailDialog({
|
||||
const [comments, setComments] = useState<TeamTaskComment[]>([]);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [retryOpen, setRetryOpen] = useState(false);
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
|
||||
const loadDetail = useCallback(async () => {
|
||||
try {
|
||||
@@ -146,8 +238,32 @@ export function TaskDetailDialog({
|
||||
finally { setDeleting(false); setConfirmDelete(false); }
|
||||
};
|
||||
|
||||
const handleCancel = async (reason: string) => {
|
||||
if (!cancelTask) return;
|
||||
setCancelling(true);
|
||||
try { await cancelTask(teamId, task.id, reason || undefined); onClose(); }
|
||||
catch { /* toast handled by hook */ }
|
||||
finally { setCancelling(false); setCancelOpen(false); }
|
||||
};
|
||||
|
||||
const handleRetry = async (comment: string, assigneeId?: string) => {
|
||||
if (!retryTask || !comment) return;
|
||||
// Only send agentId when it actually changes the assignee; the backend
|
||||
// keeps the current owner otherwise.
|
||||
const agentId = assigneeId && assigneeId !== task.owner_agent_id ? assigneeId : undefined;
|
||||
if (!task.owner_agent_id && !agentId) return;
|
||||
setRetrying(true);
|
||||
try { await retryTask(teamId, task.id, comment, agentId); onClose(); }
|
||||
catch { /* toast handled by hook */ }
|
||||
finally { setRetrying(false); setRetryOpen(false); }
|
||||
};
|
||||
|
||||
const ownerEmoji = resolveEmoji(task.owner_agent_id);
|
||||
const canDelete = deleteTask && isTerminalStatus(task.status);
|
||||
const canCancel = !!cancelTask && canCancelTask(task.status);
|
||||
// Without an owner the backend needs agentId, so retry is offered only when
|
||||
// there is someone to pick.
|
||||
const canRetry = !!retryTask && canRetryTask(task) && (!!task.owner_agent_id || (assignees?.length ?? 0) > 0);
|
||||
|
||||
const handleAddComment = onAddComment
|
||||
? async (content: string) => { await onAddComment(teamId, task.id, content); await loadDetail(); }
|
||||
@@ -280,15 +396,57 @@ export function TaskDetailDialog({
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{canDelete && (
|
||||
<div className="flex justify-end border-t pt-3">
|
||||
<Button variant="destructive" size="sm" onClick={() => setConfirmDelete(true)}>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("tasks.delete")}
|
||||
</Button>
|
||||
{(canRetry || canCancel || canDelete) && (
|
||||
<div className="flex flex-wrap justify-end gap-2 border-t pt-3">
|
||||
{canRetry && (
|
||||
<Button variant="default" size="sm" onClick={() => setRetryOpen(true)}>
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("tasks.retry")}
|
||||
</Button>
|
||||
)}
|
||||
{canCancel && (
|
||||
<Button variant="outline" size="sm" onClick={() => setCancelOpen(true)}>
|
||||
<Ban className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("tasks.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button variant="destructive" size="sm" onClick={() => setConfirmDelete(true)}>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("tasks.delete")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TaskActionDialog
|
||||
open={retryOpen}
|
||||
onOpenChange={setRetryOpen}
|
||||
title={t("tasks.retryTitle")}
|
||||
description={t("tasks.retryDescription")}
|
||||
placeholder={t("tasks.retryCommentPlaceholder")}
|
||||
confirmLabel={t("tasks.retry")}
|
||||
required
|
||||
requiredHint={t("tasks.retryCommentRequired")}
|
||||
assignees={assignees}
|
||||
assigneeLabel={t("tasks.retryAssignee")}
|
||||
defaultAssigneeId={task.owner_agent_id}
|
||||
loading={retrying}
|
||||
onConfirm={handleRetry}
|
||||
/>
|
||||
|
||||
<TaskActionDialog
|
||||
open={cancelOpen}
|
||||
onOpenChange={setCancelOpen}
|
||||
title={t("tasks.cancelTitle")}
|
||||
description={t("tasks.cancelDescription")}
|
||||
placeholder={t("tasks.cancelReasonPlaceholder")}
|
||||
confirmLabel={t("tasks.cancel")}
|
||||
variant="destructive"
|
||||
loading={cancelling}
|
||||
onConfirm={handleCancel}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
onOpenChange={setConfirmDelete}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { usePagination } from "@/hooks/use-pagination";
|
||||
import type { TeamTaskData, TeamTaskComment, TeamTaskEvent, TeamTaskAttachment } from "@/types/team";
|
||||
import type { TeamMemberData } from "@/types/team";
|
||||
import { taskStatusBadgeVariant, isTerminalStatus } from "./task-utils";
|
||||
import { buildTaskLookup, buildMemberLookup } from "../board/board-utils";
|
||||
import { buildTaskLookup, buildMemberLookup, buildAssigneeOptions } from "../board/board-utils";
|
||||
|
||||
const TaskDetailDialog = lazy(() =>
|
||||
import("./task-detail-dialog").then((m) => ({ default: m.TaskDetailDialog }))
|
||||
@@ -30,11 +30,13 @@ interface TaskListProps {
|
||||
deleteTask?: (teamId: string, taskId: string) => Promise<void>;
|
||||
deleteTasksBulk?: (teamId: string, taskIds: string[]) => Promise<number>;
|
||||
addTaskComment?: (teamId: string, taskId: string, content: string) => Promise<void>;
|
||||
cancelTask?: (teamId: string, taskId: string, reason?: string) => Promise<void>;
|
||||
retryTask?: (teamId: string, taskId: string, comment: string, agentId?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function TaskList({
|
||||
tasks, loading, teamId, members, isTeamV2, emojiLookup,
|
||||
getTaskDetail, deleteTask, deleteTasksBulk, addTaskComment,
|
||||
getTaskDetail, deleteTask, deleteTasksBulk, addTaskComment, cancelTask, retryTask,
|
||||
}: TaskListProps) {
|
||||
const { t } = useTranslation("teams");
|
||||
const [selectedTask, setSelectedTask] = useState<TeamTaskData | null>(null);
|
||||
@@ -45,6 +47,7 @@ export function TaskList({
|
||||
const [singleDeleting, setSingleDeleting] = useState(false);
|
||||
const taskLookup = useMemo(() => buildTaskLookup(tasks), [tasks]);
|
||||
const memberLookup = useMemo(() => buildMemberLookup(members), [members]);
|
||||
const assigneeOptions = useMemo(() => buildAssigneeOptions(members), [members]);
|
||||
const { pageItems, pagination, setPage, setPageSize } = usePagination(tasks, { defaultPageSize: 20 });
|
||||
|
||||
// "Select all" applies to terminal tasks on the current page only.
|
||||
@@ -277,6 +280,9 @@ export function TaskList({
|
||||
onClose={() => setSelectedTask(null)}
|
||||
getTaskDetail={getTaskDetail}
|
||||
deleteTask={deleteTask}
|
||||
cancelTask={cancelTask}
|
||||
retryTask={retryTask}
|
||||
assignees={assigneeOptions}
|
||||
onAddComment={addTaskComment}
|
||||
taskLookup={taskLookup}
|
||||
memberLookup={memberLookup}
|
||||
|
||||
@@ -23,3 +23,20 @@ const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled"]);
|
||||
export function isTerminalStatus(status: string) {
|
||||
return TERMINAL_STATUSES.has(status);
|
||||
}
|
||||
|
||||
/** Whether a human can cancel the task (teams.tasks.cancel): anything not completed/cancelled */
|
||||
export function canCancelTask(status: string) {
|
||||
return status !== "completed" && status !== "cancelled";
|
||||
}
|
||||
|
||||
const RETRYABLE_STATUSES = new Set(["stale", "failed", "cancelled", "in_review", "blocked"]);
|
||||
|
||||
/**
|
||||
* Whether a human can restart the task (teams.tasks.retry). Mirrors the
|
||||
* backend: blocked tasks qualify only once nothing blocks them any more.
|
||||
*/
|
||||
export function canRetryTask(task: { status: string; blocked_by?: string[] | null }) {
|
||||
if (!RETRYABLE_STATUSES.has(task.status)) return false;
|
||||
if (task.status === "blocked" && task.blocked_by && task.blocked_by.length > 0) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
|
||||
const {
|
||||
getTeam, getTeamTasks, getTeamScopes, addMember, removeMember, deleteTeam,
|
||||
getTaskDetail, getTaskLight, deleteTask, deleteTasksBulk, addTaskComment, updateTeam,
|
||||
cancelTask, retryTask,
|
||||
} = useTeams();
|
||||
|
||||
// Wrap addTaskComment to match (teamId, taskId, content) signature expected by UI components.
|
||||
@@ -122,6 +123,8 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
|
||||
deleteTask={deleteTask}
|
||||
deleteTasksBulk={deleteTasksBulk}
|
||||
addTaskComment={handleAddComment}
|
||||
cancelTask={cancelTask}
|
||||
retryTask={retryTask}
|
||||
onWorkspace={() => setWorkspaceOpen(true)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ export interface TeamTaskData {
|
||||
team_id: string;
|
||||
subject: string;
|
||||
description?: string;
|
||||
status: "pending" | "in_progress" | "completed" | "blocked" | "failed" | "in_review" | "cancelled";
|
||||
status: "pending" | "in_progress" | "completed" | "blocked" | "failed" | "in_review" | "cancelled" | "stale";
|
||||
owner_agent_id?: string;
|
||||
owner_agent_key?: string;
|
||||
blocked_by?: string[];
|
||||
|
||||
Reference in new issue
Block a user