diff --git a/cmd/gateway.go b/cmd/gateway.go index 754d9592..68f7a909 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "fmt" "log/slog" "os" @@ -546,11 +547,31 @@ func runGateway() { if err != nil { return } - if err := teamEventStore.RecordTaskEvent(context.Background(), &store.TeamTaskEventData{ + + // Propagate tenant from bus event to ensure correct tenant isolation. + auditCtx := store.WithTenantID(context.Background(), evt.TenantID) + + // Populate data field with event-specific context for audit trail. + var data json.RawMessage + switch evt.Name { + case protocol.EventTeamTaskFailed, protocol.EventTeamTaskRejected, protocol.EventTeamTaskCancelled: + if payload.Reason != "" { + data, _ = json.Marshal(map[string]string{"reason": payload.Reason}) + } + case protocol.EventTeamTaskCommented: + if payload.CommentText != "" { + data, _ = json.Marshal(map[string]string{"comment_text": payload.CommentText}) + } + case protocol.EventTeamTaskProgress: + data, _ = json.Marshal(map[string]any{"progress_percent": payload.ProgressPercent, "progress_step": payload.ProgressStep}) + } + + if err := teamEventStore.RecordTaskEvent(auditCtx, &store.TeamTaskEventData{ TaskID: taskID, EventType: eventType, ActorType: payload.ActorType, ActorID: payload.ActorID, + Data: data, }); err != nil { slog.Warn("team_task_audit.record_failed", "task_id", payload.TaskID, "event", eventType, "error", err) } @@ -1060,6 +1081,14 @@ func teamTaskEventType(eventName string) string { return "approved" case protocol.EventTeamTaskRejected: return "rejected" + case protocol.EventTeamTaskCommented: + return "commented" + case protocol.EventTeamTaskProgress: + return "progress" + case protocol.EventTeamTaskUpdated: + return "updated" + case protocol.EventTeamTaskStale: + return "stale" default: return "" } diff --git a/cmd/gateway_consumer.go b/cmd/gateway_consumer.go index 9576a7dd..d501903e 100644 --- a/cmd/gateway_consumer.go +++ b/cmd/gateway_consumer.go @@ -108,6 +108,12 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents continue } + // Blocker escalation messages bypass debounce — deliver immediately to leader. + if msg.SenderID == "system:escalation" { + go processNormalMessage(ctx, msg, agents, cfg, sched, channelMgr, teamStore, quotaChecker, sessStore, agentStore, contactCollector, postTurn, msgBus) + continue + } + // --- Normal messages: route through debouncer --- debouncer.Push(msg) } diff --git a/docs/03-tools-system.md b/docs/03-tools-system.md index 712c9798..2e3c7f62 100644 --- a/docs/03-tools-system.md +++ b/docs/03-tools-system.md @@ -118,8 +118,8 @@ Context keys ensure each tool call receives the correct per-call values without | Tool | Description | |------|-------------| -| `team_tasks` | Task board: list, create, claim, complete, search | -| `team_message` | Mailbox: send, broadcast, read unread messages | +| `team_tasks` | Task board: create, list, get, claim, complete, cancel, assign, review, approve, reject, comment (with type: note/blocker), progress, attach, ask_user, search, update | +| `team_message` | Mailbox: send direct message to teammate, broadcast to all, read unread messages | ### Media Generation diff --git a/docs/11-agent-teams.md b/docs/11-agent-teams.md index a87a05f6..1dedd59b 100644 --- a/docs/11-agent-teams.md +++ b/docs/11-agent-teams.md @@ -161,30 +161,42 @@ flowchart TD | `retry` | Re-dispatch stale or failed tasks back to pending | Admin | | `update` | Update task metadata (priority, description, etc.) | Lead | -### Team Versioning - -Many task actions require **team version >= 2**. Teams created with v1 only support basic actions. - -**V2-Required Actions:** -- `approve` — Approve completed task -- `reject` — Reject and cancel task -- `review` — Submit for review -- `comment` — Add comments -- `progress` — Update progress -- `attach` — Attach files -- `update` — Update metadata -- `ask_user` — Set reminders -- `clear_ask_user` — Cancel reminders -- `retry` — Retry failed tasks - -**V1 Teams** support only: `create`, `claim`, `complete`, `cancel`, `assign`, `list`, `get`, `search` - -If a v1 team tries a v2 action, error: `"action 'X' requires team version 2 — upgrade in team settings"` - ### Atomic Claiming Two agents grabbing the same task is prevented at the database level. The claim operation uses a conditional update: `SET status = 'in_progress', owner = agent WHERE status = 'pending' AND owner IS NULL`. One row updated means claimed; zero rows means someone else got it first. No distributed mutex needed. +### Blocker Escalation + +Members can flag themselves as blocked on a task by adding a blocker comment: + +``` +team_tasks(action="comment", task_id="...", text="Cannot find API documentation", type="blocker") +``` + +When a blocker comment is posted: +1. The comment is saved with `comment_type='blocker'` +2. The task is **auto-failed** (status: in_progress → failed) +3. An `EventTeamTaskFailed` is broadcast: + - Member's session is cancelled via scheduler + - Chat channel receives "❌ Task failed" notification + - Web UI dashboard updates in real-time +4. The **lead agent receives an escalation message** from `system:escalation` with: + - Blocked member name + - Task number and subject + - Blocker reason + - Instructions to retry with `team_tasks(action="retry", task_id="...")` + +Blocker escalation is **enabled by default** but can be disabled per-team via settings: +```json +{ + "blocker_escalation": { + "enabled": false + } +} +``` + +When disabled, blocker comments are saved but do not trigger auto-fail or escalation. + ### Task Dependencies & Blocking Tasks can declare `blocked_by` — a list of prerequisite task IDs. When a task has blocking dependencies: @@ -289,6 +301,30 @@ Workspace files can be attached to tasks: - Auto-links files created during task execution - Metadata captures which agent/user attached the file +### Review Workflow + +Tasks can require human approval before final completion. When creating a task, pass `require_approval: true`: + +``` +team_tasks(action="create", ..., require_approval=true) +``` + +**Flow:** + +1. **Create with approval flag**: Task created with status `pending`, `require_approval` set +2. **Member submits for review**: When done, member calls `team_tasks(action="review", task_id="...")` + - Task transitions to `in_review` status + - Emits `EventTeamTaskReviewed` event +3. **Human approves**: Via dashboard, human clicks "Approve" → `teams.tasks.approve` RPC + - Task transitions to `completed` + - Emits `EventTeamTaskApproved` event +4. **Human rejects**: Via dashboard, human clicks "Reject" with reason → `teams.tasks.reject` RPC + - Task transitions to `cancelled` with reason + - Emits `EventTeamTaskRejected` event + - Lead receives notification to retry or investigate + +Without `require_approval`, tasks move directly to `completed` after member calls `complete` (no in-review stage). + --- ## 5. Team Mailbox @@ -546,6 +582,7 @@ Teams support fine-grained access control through team settings. | `followup_max_reminders` | Integer | Max ask_user reminders before escalation | | `escalation_mode` | String | How to escalate stale tasks: "notify_lead", "fail_task" | | `escalation_actions` | String list | Actions to take on escalation | +| `blocker_escalation` | Object | Blocker comment escalation settings: `{enabled: true}` (default enabled) | System channels (`teammate`, `system`) always pass access checks. Empty settings mean open access. diff --git a/docs/13-ws-team-events.md b/docs/13-ws-team-events.md index def0daff..4e0d0bf5 100644 --- a/docs/13-ws-team-events.md +++ b/docs/13-ws-team-events.md @@ -270,7 +270,7 @@ Emitted when a human rejects a task via the dashboard (status becomes cancelled ``` #### `team.task.commented` -Emitted when a human adds a comment to a task (no status change). +Emitted when a human or agent adds a comment to a task. For agent blocker comments, also triggers task failure and leader escalation (see below). ```json { @@ -279,10 +279,19 @@ Emitted when a human adds a comment to a task (no status change). "user_id": "user123", "channel": "dashboard", "chat_id": "-100123456", - "timestamp": "2026-03-05T10:02:45Z" + "timestamp": "2026-03-05T10:02:45Z", + "actor_type": "human", + "actor_id": "user123" } ``` +**Agent blocker comments** (type='blocker') trigger special handling: +- Comment is saved with `comment_type='blocker'` +- Task is auto-failed (transitions to failed status) +- `EventTeamTaskFailed` is emitted (see below) +- Lead agent receives escalation message via `system:escalation` +- Member's session is cancelled + #### `team.task.deleted` Emitted when a terminal-status task is hard-deleted via the dashboard. @@ -301,19 +310,98 @@ Emitted when a terminal-status task is hard-deleted via the dashboard. ``` #### `team.task.failed` -Reserved for future use. Emitted when a task auto-execution fails (not currently triggered). +Emitted when a task is auto-failed due to blocker escalation or system errors. + +```json +{ + "team_id": "019c9503-...", + "task_id": "019ca84f-...", + "task_number": 5, + "subject": "Create Instagram image", + "status": "failed", + "reason": "Blocked: Cannot find API documentation", + "owner_agent_key": "tieu-la", + "owner_display_name": "Tieu La", + "user_id": "user123", + "channel": "telegram", + "chat_id": "-100123456", + "timestamp": "2026-03-05T10:02:15Z", + "actor_type": "system", + "actor_id": "blocker-escalation" +} +``` #### `team.task.reviewed` -Reserved for future use. Emitted when a task enters review stage (not currently triggered). +Emitted when a member submits a task for review via `team_tasks(action="review", task_id="...")`. + +```json +{ + "team_id": "019c9503-...", + "task_id": "019ca84f-...", + "status": "in_review", + "owner_agent_key": "tieu-la", + "owner_display_name": "Tieu La", + "user_id": "user123", + "channel": "dashboard", + "chat_id": "-100123456", + "timestamp": "2026-03-05T10:02:30Z", + "actor_type": "agent", + "actor_id": "tieu-la" +} +``` #### `team.task.progress` -Reserved for future use. Emitted periodically for long-running tasks (not currently triggered). +Emitted when a member updates task progress via `team_tasks(action="progress", task_id="...", progress_percent=..., progress_step="...")`. + +```json +{ + "team_id": "019c9503-...", + "task_id": "019ca84f-...", + "progress_percent": 50, + "progress_step": "Waiting for API response", + "user_id": "user123", + "channel": "system", + "chat_id": "-100123456", + "timestamp": "2026-03-05T10:02:00Z", + "actor_type": "agent", + "actor_id": "tieu-la" +} +``` #### `team.task.updated` -Reserved for future use. Emitted when task metadata is updated (not currently triggered). +Emitted when task metadata (priority, description, etc.) is updated via `team_tasks(action="update", task_id="...", ...)`. + +```json +{ + "team_id": "019c9503-...", + "task_id": "019ca84f-...", + "changes": ["priority", "description"], + "user_id": "user123", + "channel": "dashboard", + "chat_id": "-100123456", + "timestamp": "2026-03-05T10:01:30Z", + "actor_type": "human", + "actor_id": "user123" +} +``` #### `team.task.stale` -Reserved for future use. Emitted when a task hasn't been updated within a timeout threshold (not currently triggered). +Emitted when a task hasn't been updated within a timeout threshold and is marked as stale by the system. + +```json +{ + "team_id": "019c9503-...", + "task_id": "019ca84f-...", + "status": "stale", + "reason": "No activity for 7 days", + "user_id": "user123", + "channel": "system", + "chat_id": "-100123456", + "timestamp": "2026-03-05T10:00:00Z", + "actor_type": "system", + "actor_id": "stale-detector" +} +``` --- @@ -558,11 +646,11 @@ All event name constants are defined in `pkg/protocol/events.go`: | `EventTeamTaskRejected` | `team.task.rejected` | Active | | `EventTeamTaskCommented` | `team.task.commented` | Active | | `EventTeamTaskDeleted` | `team.task.deleted` | Active | -| `EventTeamTaskFailed` | `team.task.failed` | Reserved (future) | -| `EventTeamTaskReviewed` | `team.task.reviewed` | Reserved (future) | -| `EventTeamTaskProgress` | `team.task.progress` | Reserved (future) | -| `EventTeamTaskUpdated` | `team.task.updated` | Reserved (future) | -| `EventTeamTaskStale` | `team.task.stale` | Reserved (future) | +| `EventTeamTaskFailed` | `team.task.failed` | Active | +| `EventTeamTaskReviewed` | `team.task.reviewed` | Active | +| `EventTeamTaskProgress` | `team.task.progress` | Active | +| `EventTeamTaskUpdated` | `team.task.updated` | Active | +| `EventTeamTaskStale` | `team.task.stale` | Active | ### Team CRUD Events | Constant | Event Name | diff --git a/internal/agent/resolver_helpers.go b/internal/agent/resolver_helpers.go index 5c78868b..747eeef2 100644 --- a/internal/agent/resolver_helpers.go +++ b/internal/agent/resolver_helpers.go @@ -130,6 +130,10 @@ func buildTeamMD(team *store.TeamData, members []store.TeamMemberData, selfID uu sb.WriteString("\n## Communication\n\n") sb.WriteString("Use `team_tasks(action=\"comment\")` to report issues or ask questions on your current task.\n") } + sb.WriteString("\n## Blocker Escalation\n\n") + sb.WriteString("If blocked (missing info, unclear requirements, need credentials):\n") + sb.WriteString("```\nteam_tasks(action=\"comment\", type=\"blocker\", text=\"what you need\")\n```\n") + sb.WriteString("This auto-fails the task and notifies the leader, who can retry with updated instructions.\n") } return sb.String() diff --git a/internal/store/pg/teams_tasks_activity.go b/internal/store/pg/teams_tasks_activity.go index 775e81ac..d8d8de6b 100644 --- a/internal/store/pg/teams_tasks_activity.go +++ b/internal/store/pg/teams_tasks_activity.go @@ -20,12 +20,16 @@ func (s *PGTeamStore) AddTaskComment(ctx context.Context, comment *store.TeamTas comment.ID = store.GenNewID() } comment.CreatedAt = time.Now() + commentType := comment.CommentType + if commentType == "" { + commentType = "note" + } _, err := s.db.ExecContext(ctx, - `INSERT INTO team_task_comments (id, task_id, agent_id, user_id, content, created_at, tenant_id) - VALUES ($1, $2, $3, $4, $5, $6, $7)`, + `INSERT INTO team_task_comments (id, task_id, agent_id, user_id, content, comment_type, created_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, comment.ID, comment.TaskID, comment.AgentID, sql.NullString{String: comment.UserID, Valid: comment.UserID != ""}, - comment.Content, comment.CreatedAt, tenantIDForInsert(ctx), + comment.Content, commentType, comment.CreatedAt, tenantIDForInsert(ctx), ) if err != nil { return err @@ -39,7 +43,7 @@ func (s *PGTeamStore) AddTaskComment(ctx context.Context, comment *store.TeamTas func (s *PGTeamStore) ListTaskComments(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskCommentData, error) { tid := tenantIDForInsert(ctx) rows, err := s.db.QueryContext(ctx, - `SELECT c.id, c.task_id, c.agent_id, c.user_id, c.content, c.created_at, + `SELECT c.id, c.task_id, c.agent_id, c.user_id, c.content, c.comment_type, c.created_at, COALESCE(a.agent_key, '') AS agent_key FROM team_task_comments c LEFT JOIN agents a ON a.id = c.agent_id @@ -55,7 +59,7 @@ func (s *PGTeamStore) ListTaskComments(ctx context.Context, taskID uuid.UUID) ([ var c store.TeamTaskCommentData var agentID *uuid.UUID var userID sql.NullString - if err := rows.Scan(&c.ID, &c.TaskID, &agentID, &userID, &c.Content, &c.CreatedAt, &c.AgentKey); err != nil { + if err := rows.Scan(&c.ID, &c.TaskID, &agentID, &userID, &c.Content, &c.CommentType, &c.CreatedAt, &c.AgentKey); err != nil { return nil, err } c.AgentID = agentID @@ -72,7 +76,7 @@ func (s *PGTeamStore) ListTaskComments(ctx context.Context, taskID uuid.UUID) ([ func (s *PGTeamStore) ListRecentTaskComments(ctx context.Context, taskID uuid.UUID, limit int) ([]store.TeamTaskCommentData, error) { tid := tenantIDForInsert(ctx) rows, err := s.db.QueryContext(ctx, - `SELECT c.id, c.task_id, c.agent_id, c.user_id, c.content, c.created_at, + `SELECT c.id, c.task_id, c.agent_id, c.user_id, c.content, c.comment_type, c.created_at, COALESCE(a.agent_key, '') AS agent_key FROM team_task_comments c LEFT JOIN agents a ON a.id = c.agent_id @@ -89,7 +93,7 @@ func (s *PGTeamStore) ListRecentTaskComments(ctx context.Context, taskID uuid.UU var c store.TeamTaskCommentData var agentID *uuid.UUID var userID sql.NullString - if err := rows.Scan(&c.ID, &c.TaskID, &agentID, &userID, &c.Content, &c.CreatedAt, &c.AgentKey); err != nil { + if err := rows.Scan(&c.ID, &c.TaskID, &agentID, &userID, &c.Content, &c.CommentType, &c.CreatedAt, &c.AgentKey); err != nil { return nil, err } c.AgentID = agentID diff --git a/internal/store/team_store.go b/internal/store/team_store.go index 60195ce0..c2976b3d 100644 --- a/internal/store/team_store.go +++ b/internal/store/team_store.go @@ -137,12 +137,13 @@ type TeamTaskData struct { // TeamTaskCommentData represents a comment on a team task. type TeamTaskCommentData struct { - ID uuid.UUID `json:"id"` - TaskID uuid.UUID `json:"task_id"` - AgentID *uuid.UUID `json:"agent_id,omitempty"` - UserID string `json:"user_id,omitempty"` - Content string `json:"content"` - CreatedAt time.Time `json:"created_at"` + ID uuid.UUID `json:"id"` + TaskID uuid.UUID `json:"task_id"` + AgentID *uuid.UUID `json:"agent_id,omitempty"` + UserID string `json:"user_id,omitempty"` + Content string `json:"content"` + CommentType string `json:"comment_type,omitempty"` // "note" (default) or "blocker" + CreatedAt time.Time `json:"created_at"` // Joined AgentKey string `json:"agent_key,omitempty"` diff --git a/internal/tools/team_blocker_escalation_config.go b/internal/tools/team_blocker_escalation_config.go new file mode 100644 index 00000000..3ab1c79a --- /dev/null +++ b/internal/tools/team_blocker_escalation_config.go @@ -0,0 +1,34 @@ +package tools + +import "encoding/json" + +// BlockerEscalationConfig controls whether blocker comments trigger +// auto-fail + leader escalation. +type BlockerEscalationConfig struct { + Enabled bool `json:"enabled"` // default: true +} + +// DefaultBlockerEscalationConfig returns the default (enabled) config. +func DefaultBlockerEscalationConfig() BlockerEscalationConfig { + return BlockerEscalationConfig{Enabled: true} +} + +// ParseBlockerEscalationConfig extracts blocker escalation config from team settings. +func ParseBlockerEscalationConfig(settings json.RawMessage) BlockerEscalationConfig { + cfg := DefaultBlockerEscalationConfig() + if len(settings) == 0 { + return cfg + } + var s struct { + BlockerEscalation *struct { + Enabled *bool `json:"enabled"` + } `json:"blocker_escalation"` + } + if json.Unmarshal(settings, &s) != nil || s.BlockerEscalation == nil { + return cfg + } + if s.BlockerEscalation.Enabled != nil { + cfg.Enabled = *s.BlockerEscalation.Enabled + } + return cfg +} diff --git a/internal/tools/team_tasks_blocker.go b/internal/tools/team_tasks_blocker.go new file mode 100644 index 00000000..857e4c73 --- /dev/null +++ b/internal/tools/team_tasks_blocker.go @@ -0,0 +1,94 @@ +package tools + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// handleBlockerComment auto-fails the task, cancels the member session via +// EventTeamTaskFailed broadcast, and escalates to the leader agent. +func (t *TeamTasksTool) handleBlockerComment( + ctx context.Context, + team *store.TeamData, + task *store.TeamTaskData, + taskID, agentID uuid.UUID, + text string, +) *Result { + // Only escalate if task is still in progress. + if task.Status != store.TeamTaskStatusInProgress { + return NewResult(fmt.Sprintf( + "Blocker comment saved on task #%d \"%s\", but task is already %s — no escalation needed.", + task.TaskNumber, task.Subject, task.Status)) + } + + // Auto-fail the task. FailTask guards with AND status='in_progress' so + // concurrent calls are safe — only one succeeds. + reason := "Blocked: " + text + if len([]rune(reason)) > 500 { + reason = string([]rune(reason)[:500]) + } + if err := t.manager.teamStore.FailTask(ctx, taskID, team.ID, reason); err != nil { + slog.Warn("blocker: FailTask error", "task_id", taskID, "error", err) + // Task may have completed concurrently — not a hard error. + return NewResult("Blocker comment saved. Task may have already completed — check task status.") + } + + // Broadcast EventTeamTaskFailed — triggers: + // 1. Cancel subscriber → sched.CancelSession() → member stops + // 2. Notify subscriber → "❌ Task failed" → chat channel (direct outbound) + // 3. WS broadcast → web UI dashboard real-time update + memberKey := t.manager.agentKeyFromID(ctx, agentID) + t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskFailed, protocol.TeamTaskEventPayload{ + TeamID: team.ID.String(), + TaskID: taskID.String(), + TaskNumber: task.TaskNumber, + Subject: task.Subject, + Status: store.TeamTaskStatusFailed, + Reason: reason, + OwnerAgentKey: memberKey, + UserID: store.UserIDFromContext(ctx), + Channel: task.Channel, + ChatID: task.ChatID, + Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"), + ActorType: "agent", + ActorID: memberKey, + }) + + // Escalate to leader if enabled in team settings. + escalationCfg := ParseBlockerEscalationConfig(team.Settings) + if escalationCfg.Enabled && t.manager.msgBus != nil { + leadAg, err := t.manager.cachedGetAgentByID(ctx, team.LeadAgentID) + if err == nil { + escalationMsg := fmt.Sprintf( + "[Escalation] Member %q is blocked on task #%d \"%s\"\n\n"+ + "Blocker: %s\n\n"+ + "Use team_tasks(action=\"retry\", task_id=\"%s\") to reopen with updated instructions.", + memberKey, task.TaskNumber, task.Subject, text, taskID) + + if !t.manager.msgBus.TryPublishInbound(bus.InboundMessage{ + Channel: task.Channel, + SenderID: "system:escalation", + ChatID: task.ChatID, + Content: escalationMsg, + UserID: store.UserIDFromContext(ctx), + TenantID: store.TenantIDFromContext(ctx), + AgentID: leadAg.AgentKey, + }) { + slog.Warn("blocker: inbound buffer full, escalation dropped", + "task_id", taskID, "leader", leadAg.AgentKey) + } + } + } + + return NewResult(fmt.Sprintf( + "Task #%d \"%s\" auto-failed due to blocker. Leader has been notified and can retry with updated instructions.", + task.TaskNumber, task.Subject)) +} diff --git a/internal/tools/team_tasks_lifecycle.go b/internal/tools/team_tasks_lifecycle.go index 2d38a2eb..f5b400d6 100644 --- a/internal/tools/team_tasks_lifecycle.go +++ b/internal/tools/team_tasks_lifecycle.go @@ -43,11 +43,9 @@ func (t *TeamTasksTool) executeClaim(ctx context.Context, args map[string]any) * } func (t *TeamTasksTool) executeComplete(ctx context.Context, args map[string]any) *Result { - // TODO: Enable when reviewer workflow is implemented — teammate agents should - // not complete tasks directly when a reviewer is required. - // if ToolChannelFromCtx(ctx) == ChannelTeammate { - // return ErrorResult("teammate agents cannot complete team tasks directly — results are auto-completed when delegation finishes") - // } + // Note: reviewer role exists but is not yet active in UI. + // All approval flows through leader. When reviewer role is enabled, + // restrict teammate agents from completing tasks directly. team, agentID, err := t.manager.resolveTeam(ctx) if err != nil { @@ -106,11 +104,7 @@ func (t *TeamTasksTool) executeComplete(ctx context.Context, args map[string]any } func (t *TeamTasksTool) executeCancel(ctx context.Context, args map[string]any) *Result { - // TODO: Enable when reviewer workflow is implemented — teammate agents should - // not cancel tasks directly when a reviewer is required. - // if ToolChannelFromCtx(ctx) == ChannelTeammate { - // return ErrorResult("teammate agents cannot cancel team tasks directly") - // } + // Note: reviewer role not yet active. Cancellation goes through leader only. team, agentID, err := t.manager.resolveTeam(ctx) if err != nil { @@ -199,11 +193,7 @@ func (t *TeamTasksTool) executeReview(ctx context.Context, args map[string]any) } func (t *TeamTasksTool) executeApprove(ctx context.Context, args map[string]any) *Result { - // TODO: Enable when reviewer workflow is implemented — teammate agents should - // not approve tasks directly when a reviewer is required. - // if ToolChannelFromCtx(ctx) == ChannelTeammate { - // return ErrorResult("teammate agents cannot approve team tasks") - // } + // Note: reviewer role not yet active. All approvals flow through leader or dashboard. team, agentID, err := t.manager.resolveTeam(ctx) if err != nil { @@ -270,11 +260,7 @@ func (t *TeamTasksTool) executeApprove(ctx context.Context, args map[string]any) } func (t *TeamTasksTool) executeReject(ctx context.Context, args map[string]any) *Result { - // TODO: Enable when reviewer workflow is implemented — teammate agents should - // not reject tasks directly when a reviewer is required. - // if ToolChannelFromCtx(ctx) == ChannelTeammate { - // return ErrorResult("teammate agents cannot reject team tasks") - // } + // Note: reviewer role not yet active. Rejections flow through leader or dashboard. team, agentID, err := t.manager.resolveTeam(ctx) if err != nil { diff --git a/internal/tools/team_tasks_mutations.go b/internal/tools/team_tasks_mutations.go index 513e0e3a..0955751a 100644 --- a/internal/tools/team_tasks_mutations.go +++ b/internal/tools/team_tasks_mutations.go @@ -309,10 +309,16 @@ func (t *TeamTasksTool) executeComment(ctx context.Context, args map[string]any) return ErrorResult("task does not belong to your team") } + commentType, _ := args["type"].(string) + if commentType != "blocker" { + commentType = "note" + } + if err := t.manager.teamStore.AddTaskComment(ctx, &store.TeamTaskCommentData{ - TaskID: taskID, - AgentID: &agentID, - Content: text, + TaskID: taskID, + AgentID: &agentID, + Content: text, + CommentType: commentType, }); err != nil { return ErrorResult("failed to add comment: " + err.Error()) } @@ -331,6 +337,11 @@ func (t *TeamTasksTool) executeComment(ctx context.Context, args map[string]any) ActorID: t.manager.agentKeyFromID(ctx, agentID), }) + // Blocker escalation: auto-fail task + notify leader. + if commentType == "blocker" { + return t.handleBlockerComment(ctx, team, task, taskID, agentID, text) + } + isLead := agentID == team.LeadAgentID msg := fmt.Sprintf("Comment added to task #%d \"%s\" (id: %s).", task.TaskNumber, task.Subject, taskID) switch { @@ -346,6 +357,7 @@ func (t *TeamTasksTool) executeComment(ctx context.Context, args map[string]any) return NewResult(msg) } + func (t *TeamTasksTool) executeProgress(ctx context.Context, args map[string]any) *Result { team, agentID, err := t.manager.resolveTeam(ctx) if err != nil { diff --git a/internal/tools/team_tasks_tool.go b/internal/tools/team_tasks_tool.go index 6b13786e..67ec5d47 100644 --- a/internal/tools/team_tasks_tool.go +++ b/internal/tools/team_tasks_tool.go @@ -52,6 +52,10 @@ func (t *TeamTasksTool) Parameters() map[string]any { "type": "string", "description": "Text content: comment text, cancel/reject reason, progress update, or ask_user reminder question (must be a question asking the user for input/decision)", }, + "type": map[string]any{ + "type": "string", + "description": "Comment type for action=comment: 'note' (default, share findings) or 'blocker' (you are BLOCKED and need leader input — auto-fails task and notifies leader)", + }, "status": map[string]any{ "type": "string", "description": "Filter for list: '' (all, default), 'active', 'completed', 'in_review'", diff --git a/internal/tools/team_tool_dispatch.go b/internal/tools/team_tool_dispatch.go index b8980984..8195634d 100644 --- a/internal/tools/team_tool_dispatch.go +++ b/internal/tools/team_tool_dispatch.go @@ -83,7 +83,8 @@ func (m *TeamToolManager) dispatchTaskToAgent(ctx context.Context, task *store.T // Hint: guide member on available team_tasks actions. content += "\n\n[Instructions]\n" + "- Use team_tasks(action=\"progress\", percent=N, text=\"...\") to report progress\n" + - "- Use team_tasks(action=\"comment\", text=\"...\") to share findings or ask questions — comments are included in the task report sent to the leader\n" + + "- Use team_tasks(action=\"comment\", text=\"...\") to share findings\n" + + "- Use team_tasks(action=\"comment\", type=\"blocker\", text=\"...\") when BLOCKED and need leader input — auto-fails task and notifies leader\n" + "- When done: team_tasks(action=\"complete\", result=\"summary of your work\")\n" + "- Write output files to team workspace so lead can review" diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go index 80e0c495..32a73865 100644 --- a/internal/upgrade/version.go +++ b/internal/upgrade/version.go @@ -2,4 +2,4 @@ package upgrade // RequiredSchemaVersion is the schema migration version this binary requires. // Bump this whenever adding a new SQL migration file. -const RequiredSchemaVersion uint = 27 +const RequiredSchemaVersion uint = 28 diff --git a/migrations/000028_comment_type.down.sql b/migrations/000028_comment_type.down.sql new file mode 100644 index 00000000..99d0bf64 --- /dev/null +++ b/migrations/000028_comment_type.down.sql @@ -0,0 +1 @@ +ALTER TABLE team_task_comments DROP COLUMN IF EXISTS comment_type; diff --git a/migrations/000028_comment_type.up.sql b/migrations/000028_comment_type.up.sql new file mode 100644 index 00000000..49941aeb --- /dev/null +++ b/migrations/000028_comment_type.up.sql @@ -0,0 +1,3 @@ +-- Add comment_type to distinguish note vs blocker comments. +-- Blocker comments trigger task auto-fail + leader escalation. +ALTER TABLE team_task_comments ADD COLUMN IF NOT EXISTS comment_type VARCHAR(20) NOT NULL DEFAULT 'note'; diff --git a/ui/web/src/api/protocol.ts b/ui/web/src/api/protocol.ts index 6f0bb046..f4f78e3c 100644 --- a/ui/web/src/api/protocol.ts +++ b/ui/web/src/api/protocol.ts @@ -151,6 +151,7 @@ export const Methods = { TEAMS_WORKSPACE_LIST: "teams.workspace.list", TEAMS_WORKSPACE_READ: "teams.workspace.read", TEAMS_WORKSPACE_DELETE: "teams.workspace.delete", + TEAMS_EVENTS_LIST: "teams.events.list", // Heartbeat HEARTBEAT_GET: "heartbeat.get", diff --git a/ui/web/src/i18n/locales/en/teams.json b/ui/web/src/i18n/locales/en/teams.json index 8f12d22c..3ea5e02f 100644 --- a/ui/web/src/i18n/locales/en/teams.json +++ b/ui/web/src/i18n/locales/en/teams.json @@ -160,6 +160,27 @@ "empty": "File is empty or binary" } }, + "auditLogs": { + "title": "Audit Logs", + "empty": "No events yet", + "loadMore": "Load more", + "event": { + "created": "Created", + "claimed": "Claimed", + "assigned": "Assigned", + "dispatched": "Dispatched", + "completed": "Completed", + "failed": "Failed", + "cancelled": "Cancelled", + "reviewed": "Reviewed", + "approved": "Approved", + "rejected": "Rejected", + "commented": "Commented", + "progress": "Progress", + "updated": "Updated", + "stale": "Stale" + } + }, "settings": { "title": "Team Settings", "name": "Name", @@ -212,6 +233,9 @@ "escalationPolicy": "Escalation Policy", "escalationMode": "Escalation Mode", "escalationModeHint": "Controls how privileged workspace actions (pin, tag, delete others' files, set_template) are handled when requested by agents.", + "blockerEscalation": "Blocker Escalation", + "blockerEscalationEnabled": "Enable blocker escalation", + "blockerEscalationHint": "When members flag a blocker, the task auto-fails and the leader is notified to retry with updated instructions.", "escalationModeAuto": "Auto (default)", "escalationModeAutoDesc": "LLM lead evaluates the request — either rejects or creates a review task. Never executes directly.", "escalationModeReview": "Review", @@ -259,8 +283,8 @@ "commentsAuditDesc": "Task comments and full event history", "autoRecovery": "Auto-Recovery", "autoRecoveryDesc": "Stuck tasks auto-reset after timeout", - "escalationPolicy": "Escalation Policy", - "escalationPolicyDesc": "Control what agents can do autonomously", + "escalationPolicy": "Blocker Escalation", + "escalationPolicyDesc": "Members can flag blockers — auto-fails task and notifies leader to retry", "comingSoon": "Coming soon", "downgradeNote": "Downgrade is safe — V2 data is preserved but hidden.", "betaNote": "Some features are currently in Beta.", diff --git a/ui/web/src/i18n/locales/vi/teams.json b/ui/web/src/i18n/locales/vi/teams.json index 90e85eba..c97231eb 100644 --- a/ui/web/src/i18n/locales/vi/teams.json +++ b/ui/web/src/i18n/locales/vi/teams.json @@ -160,6 +160,27 @@ "empty": "File trống hoặc là file nhị phân" } }, + "auditLogs": { + "title": "Nhật ký hoạt động", + "empty": "Chưa có sự kiện", + "loadMore": "Tải thêm", + "event": { + "created": "Tạo mới", + "claimed": "Nhận", + "assigned": "Phân công", + "dispatched": "Gửi đi", + "completed": "Hoàn thành", + "failed": "Thất bại", + "cancelled": "Hủy", + "reviewed": "Đánh giá", + "approved": "Duyệt", + "rejected": "Từ chối", + "commented": "Bình luận", + "progress": "Tiến độ", + "updated": "Cập nhật", + "stale": "Quá hạn" + } + }, "settings": { "title": "Cài đặt nhóm", "name": "Tên", @@ -212,6 +233,9 @@ "escalationPolicy": "Chính sách Leo thang", "escalationMode": "Chế độ Leo thang", "escalationModeHint": "Kiểm soát cách xử lý action workspace nhạy cảm (pin, tag, xóa file người khác, set_template) khi agent yêu cầu.", + "blockerEscalation": "Escalation khi bị chặn", + "blockerEscalationEnabled": "Bật escalation khi bị chặn", + "blockerEscalationHint": "Khi member báo blocker, task tự fail và leader được thông báo để retry với hướng dẫn mới.", "escalationModeAuto": "Tự động (mặc định)", "escalationModeAutoDesc": "LLM lead đánh giá yêu cầu — từ chối hoặc tạo task review. Không bao giờ thực thi trực tiếp.", "escalationModeReview": "Review", @@ -259,8 +283,8 @@ "commentsAuditDesc": "Bình luận nhiệm vụ và lịch sử sự kiện đầy đủ", "autoRecovery": "Tự phục hồi", "autoRecoveryDesc": "Nhiệm vụ bị treo tự đặt lại sau thời gian chờ", - "escalationPolicy": "Chính sách leo thang", - "escalationPolicyDesc": "Kiểm soát những gì agent có thể tự thực hiện", + "escalationPolicy": "Escalation khi bị chặn", + "escalationPolicyDesc": "Member có thể báo blocker — tự fail task và thông báo leader để retry", "comingSoon": "Sắp ra mắt", "downgradeNote": "Hạ cấp an toàn — dữ liệu V2 được giữ lại nhưng ẩn đi.", "betaNote": "Một số tính năng đang ở giai đoạn Beta.", diff --git a/ui/web/src/i18n/locales/zh/teams.json b/ui/web/src/i18n/locales/zh/teams.json index 48a74128..f82a21b2 100644 --- a/ui/web/src/i18n/locales/zh/teams.json +++ b/ui/web/src/i18n/locales/zh/teams.json @@ -160,6 +160,27 @@ "empty": "文件为空或为二进制文件" } }, + "auditLogs": { + "title": "审计日志", + "empty": "暂无事件", + "loadMore": "加载更多", + "event": { + "created": "创建", + "claimed": "领取", + "assigned": "分配", + "dispatched": "派发", + "completed": "完成", + "failed": "失败", + "cancelled": "取消", + "reviewed": "审核", + "approved": "批准", + "rejected": "拒绝", + "commented": "评论", + "progress": "进度", + "updated": "更新", + "stale": "超时" + } + }, "settings": { "title": "Team设置", "name": "名称", @@ -212,6 +233,9 @@ "escalationPolicy": "升级策略", "escalationMode": "升级模式", "escalationModeHint": "控制Agent请求执行敏感工作空间操作(置顶、标记、删除他人文件、设置模板)时的处理方式。", + "blockerEscalation": "阻塞升级", + "blockerEscalationEnabled": "启用阻塞升级", + "blockerEscalationHint": "当成员标记阻塞时,任务自动失败并通知领导重试。", "escalationModeAuto": "自动(默认)", "escalationModeAutoDesc": "LLM负责人评估请求——拒绝或创建审核任务。从不直接执行。", "escalationModeReview": "审核", @@ -259,8 +283,8 @@ "commentsAuditDesc": "任务评论和完整事件历史", "autoRecovery": "自动恢复", "autoRecoveryDesc": "卡住的任务在超时后自动重置", - "escalationPolicy": "升级策略", - "escalationPolicyDesc": "控制代理可以自主执行的操作", + "escalationPolicy": "阻塞升级", + "escalationPolicyDesc": "成员可以标记阻塞 — 自动失败任务并通知领导重试", "comingSoon": "即将推出", "downgradeNote": "降级是安全的 — V2数据会保留但隐藏。", "betaNote": "部分功能目前处于Beta阶段。", diff --git a/ui/web/src/pages/teams/board/board-header.tsx b/ui/web/src/pages/teams/board/board-header.tsx index f220df72..96c5553b 100644 --- a/ui/web/src/pages/teams/board/board-header.tsx +++ b/ui/web/src/pages/teams/board/board-header.tsx @@ -1,10 +1,11 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { ArrowLeft, Settings, Trash2, Users } from "lucide-react"; +import { ArrowLeft, Clock, Settings, Trash2, Users } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { TeamData, TeamMemberData } from "@/types/team"; import { TeamFeaturesModal } from "../team-features-modal"; +import { TeamAuditLogsModal } from "../team-audit-logs-modal"; interface BoardHeaderProps { team: TeamData; @@ -18,6 +19,7 @@ interface BoardHeaderProps { export function BoardHeader({ team, members, onBack, onDelete, onSettings, onMembers }: BoardHeaderProps) { const { t } = useTranslation("teams"); const [featuresOpen, setFeaturesOpen] = useState(false); + const [auditLogsOpen, setAuditLogsOpen] = useState(false); const leadMember = members.find((m) => m.role === "lead"); const leadName = leadMember?.display_name || leadMember?.agent_key || team.lead_display_name || team.lead_agent_key; @@ -59,6 +61,10 @@ export function BoardHeader({ team, members, onBack, onDelete, onSettings, onMem {t("members.title")} + + ); } diff --git a/ui/web/src/pages/teams/team-audit-logs-modal.tsx b/ui/web/src/pages/teams/team-audit-logs-modal.tsx new file mode 100644 index 00000000..4ab13598 --- /dev/null +++ b/ui/web/src/pages/teams/team-audit-logs-modal.tsx @@ -0,0 +1,180 @@ +import { useState, useCallback, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useWs } from "@/hooks/use-ws"; +import { Methods } from "@/api/protocol"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { formatRelativeTime } from "@/lib/format"; +import type { TeamTaskEvent } from "@/types/team"; + +const LIMIT = 50; + +const EVENT_BADGE_CLASSES: Record = { + created: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300", + updated: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300", + claimed: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300", + assigned: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300", + dispatched: "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300", + completed: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300", + approved: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300", + failed: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300", + rejected: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300", + cancelled: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400", + commented: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300", + progress: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-300", + reviewed: "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300", + stale: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300", +}; + +interface EventDataDetails { + reason?: string; + comment_text?: string; + progress_percent?: number; + progress_step?: string; + [key: string]: unknown; +} + +function EventDataRow({ data }: { data: Record }) { + const [expanded, setExpanded] = useState(false); + const d = data as EventDataDetails; + const summary = d.reason || d.comment_text + || (d.progress_percent != null ? `${d.progress_percent}%${d.progress_step ? ` — ${d.progress_step}` : ""}` : null); + + if (!summary && Object.keys(data).length === 0) return null; + + return ( +
+ + {expanded && ( +
+          {JSON.stringify(data, null, 2)}
+        
+ )} + {!expanded && summary && ( + {summary} + )} +
+ ); +} + +interface TeamAuditLogsModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + teamId: string; +} + +export function TeamAuditLogsModal({ open, onOpenChange, teamId }: TeamAuditLogsModalProps) { + const { t } = useTranslation("teams"); + const ws = useWs(); + + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [hasMore, setHasMore] = useState(false); + const [error, setError] = useState(null); + + const fetchEvents = useCallback(async (offset: number, append: boolean) => { + setLoading(true); + setError(null); + try { + const res = await ws.call<{ events: TeamTaskEvent[]; count: number }>( + Methods.TEAMS_EVENTS_LIST, + { team_id: teamId, limit: LIMIT, offset }, + ); + const incoming = res.events ?? []; + setEvents((prev) => append ? [...prev, ...incoming] : incoming); + setHasMore(incoming.length === LIMIT); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load events"); + } finally { + setLoading(false); + } + }, [ws, teamId]); + + // Load on open + useEffect(() => { + if (open) { + setEvents([]); + fetchEvents(0, false); + } + }, [open]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleLoadMore = useCallback(() => { + fetchEvents(events.length, true); + }, [events.length, fetchEvents]); + + return ( + + + + {t("auditLogs.title")} + + +
+ {error && ( +

{error}

+ )} + {events.length === 0 && !loading && !error && ( +

{t("auditLogs.empty")}

+ )} + +
+ {events.map((e, i) => ( +
+ {/* Dot + vertical line */} +
+
+ {i < events.length - 1 &&
} +
+ + {/* Content */} +
+
+ + {t(`auditLogs.event.${e.event_type}`, { defaultValue: e.event_type })} + + + {e.actor_type === "human" ? "Human" : (e.actor_id?.slice(0, 8) ?? "—")} + + + {formatRelativeTime(e.created_at)} + +
+ {e.task_id && ( +
+ task: {e.task_id.slice(0, 8)} +
+ )} + {e.data && Object.keys(e.data).length > 0 && ( + + )} +
+
+ ))} +
+ + {loading && ( +

Loading...

+ )} + + {hasMore && !loading && ( +
+ +
+ )} +
+ +
+ ); +} diff --git a/ui/web/src/pages/teams/team-features-modal.tsx b/ui/web/src/pages/teams/team-features-modal.tsx index 73a7b31b..2e5ce24a 100644 --- a/ui/web/src/pages/teams/team-features-modal.tsx +++ b/ui/web/src/pages/teams/team-features-modal.tsx @@ -8,7 +8,7 @@ import { import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { - Check, Minus, + Check, ListTodo, FolderOpen, Lock, Bell, ClipboardCheck, BarChart3, MessageSquare, RotateCcw, ShieldCheck, } from "lucide-react"; @@ -26,9 +26,9 @@ const FEATURES = [ { key: "followupReminders", icon: Bell }, { key: "progressTracking", icon: BarChart3 }, { key: "autoRecovery", icon: RotateCcw }, - { key: "commentsAudit", icon: MessageSquare, comingSoon: true }, - { key: "escalationPolicy", icon: ShieldCheck, comingSoon: true }, - { key: "reviewWorkflow", icon: ClipboardCheck, comingSoon: true }, + { key: "commentsAudit", icon: MessageSquare }, + { key: "escalationPolicy", icon: ShieldCheck }, + { key: "reviewWorkflow", icon: ClipboardCheck }, ] as const; export function TeamFeaturesModal({ open, onOpenChange }: TeamFeaturesModalProps) { @@ -48,45 +48,33 @@ export function TeamFeaturesModal({ open, onOpenChange }: TeamFeaturesModalProps
- {/* Feature list */}
{FEATURES.map((f) => { const Icon = f.icon; - const dimmed = "comingSoon" in f && f.comingSoon; return (
-

- {t(`settings.versionModal.${f.key}`)} - {dimmed && ( - - {t("settings.versionModal.comingSoon")} - - )} +

+ {String(t(`settings.versionModal.${f.key}`))}

- {t(`settings.versionModal.${f.key}Desc`)} + {String(t(`settings.versionModal.${f.key}Desc`))}

- {dimmed ? ( - - ) : ( - - )} +
); })}
- {/* Notes */}

{t("settings.versionModal.betaNote")}

diff --git a/ui/web/src/pages/teams/team-settings-tab.tsx b/ui/web/src/pages/teams/team-settings-tab.tsx index 10eb3b4a..4d30816c 100644 --- a/ui/web/src/pages/teams/team-settings-tab.tsx +++ b/ui/web/src/pages/teams/team-settings-tab.tsx @@ -83,6 +83,8 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps) const [memberRequestsAutoDispatch, setMemberRequestsAutoDispatch] = useState(initMemberRequests.auto_dispatch ?? false); const [escalationMode, setEscalationMode] = useState(initial.escalation_mode ?? ""); const [escalationActions, setEscalationActions] = useState(initial.escalation_actions ?? []); + const initBlockerEscalation = initial.blocker_escalation ?? {}; + const [blockerEscalationEnabled, setBlockerEscalationEnabled] = useState(initBlockerEscalation.enabled ?? true); const [followupInterval, setFollowupInterval] = useState(initial.followup_interval_minutes ?? 30); const [followupMaxReminders, setFollowupMaxReminders] = useState(initial.followup_max_reminders ?? 0); const [workspaceScope, setWorkspaceScope] = useState(initial.workspace_scope ?? "isolated"); @@ -105,9 +107,9 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps) setNotifyDispatched(sn.dispatched ?? true); setNotifyProgress(sn.progress ?? true); setNotifyFailed(sn.failed ?? true); - setNotifyCompleted(sn.completed ?? false); - setNotifyCommented(sn.commented ?? false); - setNotifyNewTask(sn.new_task ?? false); + setNotifyCompleted(sn.completed ?? true); + setNotifyCommented(sn.commented ?? true); + setNotifyNewTask(sn.new_task ?? true); setNotifySlowTool(sn.slow_tool ?? true); setNotifyMode(sn.mode ?? "direct"); const smr = s.member_requests ?? {}; @@ -115,6 +117,8 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps) setMemberRequestsAutoDispatch(smr.auto_dispatch ?? false); setEscalationMode(s.escalation_mode ?? ""); setEscalationActions(s.escalation_actions ?? []); + const sbe = s.blocker_escalation ?? {}; + setBlockerEscalationEnabled(sbe.enabled ?? true); setFollowupInterval(s.followup_interval_minutes ?? 30); setFollowupMaxReminders(s.followup_max_reminders ?? 0); setWorkspaceScope(s.workspace_scope ?? "isolated"); @@ -149,6 +153,7 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps) settings.escalation_mode = escalationMode; if (escalationActions.length > 0) settings.escalation_actions = escalationActions; } + settings.blocker_escalation = { enabled: blockerEscalationEnabled }; if (followupInterval !== 30) settings.followup_interval_minutes = followupInterval; if (followupMaxReminders !== 0) settings.followup_max_reminders = followupMaxReminders; settings.workspace_scope = workspaceScope || "isolated"; @@ -158,7 +163,7 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps) } finally { setSaving(false); } - }, [teamId, allowUserIds, denyUserIds, allowChannels, denyChannels, notifyDispatched, notifyProgress, notifyFailed, notifyCompleted, notifyCommented, notifyNewTask, notifySlowTool, notifyMode, memberRequestsEnabled, memberRequestsAutoDispatch, escalationMode, escalationActions, followupInterval, followupMaxReminders, workspaceScope, updateTeamSettings, onSaved]); + }, [teamId, allowUserIds, denyUserIds, allowChannels, denyChannels, notifyDispatched, notifyProgress, notifyFailed, notifyCompleted, notifyCommented, notifyNewTask, notifySlowTool, notifyMode, memberRequestsEnabled, memberRequestsAutoDispatch, escalationMode, escalationActions, blockerEscalationEnabled, followupInterval, followupMaxReminders, workspaceScope, updateTeamSettings, onSaved]); const userOptions = knownUsers.map((u) => ({ value: u, label: u })); const channelOptions = CHANNEL_TYPES.map((c) => ({ value: c.value, label: c.label })); @@ -328,22 +333,24 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
- {/* Escalation Policy (coming soon) */} -
-

- {t("settings.escalationPolicy")} - {t("settings.versionModal.comingSoon")} -

+ {/* Blocker Escalation */} +
+

{t("settings.blockerEscalation")}

-
- {t("settings.escalationMode")} -

- {t("settings.escalationModeHint")} -

+
+
+
+ {t("settings.blockerEscalationEnabled")} +

+ {t("settings.blockerEscalationHint")} +

+
+ +
diff --git a/ui/web/src/types/team.ts b/ui/web/src/types/team.ts index 8e435db2..f23cb51a 100644 --- a/ui/web/src/types/team.ts +++ b/ui/web/src/types/team.ts @@ -33,6 +33,9 @@ export interface TeamAccessSettings { enabled?: boolean; auto_dispatch?: boolean; }; + blocker_escalation?: { + enabled?: boolean; + }; } export interface TeamData {