From 38b3a1e7899602ae50b857357752941a6b74fd21 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 7 May 2026 01:22:16 +0300 Subject: [PATCH] feat(team): update task automation state --- resources/pricing.json | 33 +- runtime.lock.json | 12 +- .../core/domain/taskGraphSemantics.ts | 33 +- .../renderer/adapters/TeamGraphAdapter.ts | 53 ++- .../renderer/ui/GraphNodePopover.tsx | 27 +- .../agent-graph/renderer/ui/GraphTaskCard.tsx | 3 +- .../core/domain/ActionableWorkAgenda.ts | 47 ++- .../core/domain/currentReviewCycle.ts | 2 + .../input/MemberWorkSyncTaskImpactResolver.ts | 42 ++- .../adapters/output/TeamTaskAgendaSource.ts | 8 +- src/main/services/team/TeamDataService.ts | 44 ++- src/main/services/team/TeamMemberResolver.ts | 10 +- .../team/TeamMemberRuntimeAdvisoryService.ts | 17 + .../services/team/TeamMessageFeedService.ts | 3 +- .../services/team/TeamProvisioningService.ts | 112 +++++- .../OpenCodeRuntimeDeliveryDiagnostics.ts | 32 ++ .../stallMonitor/TeamTaskStallNotifier.ts | 1 + .../TeamTaskStallSnapshotSource.ts | 48 ++- src/main/services/team/teamTaskActiveState.ts | 70 ++++ src/main/workers/team-fs-worker.ts | 10 +- .../components/sidebar/SidebarTaskItem.tsx | 9 +- .../components/sidebar/taskFiltersState.ts | 11 +- src/renderer/components/team/TaskTooltip.tsx | 9 +- .../team/activity/ActiveTasksBlock.tsx | 10 +- .../components/team/activity/ActivityItem.tsx | 77 ++++ .../team/dialogs/CreateTaskDialog.tsx | 4 +- .../team/dialogs/TaskDetailDialog.tsx | 32 +- .../components/team/kanban/KanbanBoard.tsx | 3 +- .../components/team/kanban/KanbanTaskCard.tsx | 8 +- .../components/team/members/MemberCard.tsx | 9 +- .../team/members/MemberDetailDialog.tsx | 22 +- .../team/members/MemberHoverCard.tsx | 22 +- .../components/team/members/MemberList.tsx | 11 +- .../team/members/MemberTasksTab.tsx | 9 +- .../team/messages/MessagesPanel.tsx | 3 + .../components/team/messages/StatusBlock.tsx | 4 +- .../team/taskLogs/TaskLogsPanel.tsx | 6 +- .../components/team/tasks/TaskRow.tsx | 9 +- src/renderer/store/index.ts | 5 +- src/renderer/store/slices/teamSlice.ts | 27 +- src/renderer/utils/memberHelpers.ts | 27 +- .../openCodeRuntimeDeliveryDiagnostics.ts | 3 + src/renderer/utils/pathNormalize.ts | 50 ++- src/renderer/utils/teamMessageFiltering.ts | 8 +- src/renderer/utils/teamTaskDisplayState.ts | 9 + src/shared/types/team.ts | 2 + src/shared/utils/reviewState.ts | 30 +- src/shared/utils/taskChangeState.ts | 13 +- src/shared/utils/teamAutomationMessages.ts | 16 + src/shared/utils/teamTaskState.ts | 115 ++++++ .../core/ActionableWorkAgenda.test.ts | 271 +++++++++++++++ .../core/SyncDecisionPolicy.test.ts | 36 ++ .../MemberWorkSyncTaskImpactResolver.test.ts | 86 +++++ .../output/TeamTaskAgendaSource.test.ts | 55 +++ ...OpenCodeRuntimeDeliveryDiagnostics.test.ts | 132 ++----- .../services/team/TeamDataService.test.ts | 239 +++++++++++++ .../services/team/TeamMemberResolver.test.ts | 80 +++++ .../TeamMemberRuntimeAdvisoryService.test.ts | 97 ++++++ .../team/TeamMessageFeedService.test.ts | 39 ++- .../TeamTaskStallNotifier.test.ts | 1 + .../TeamTaskStallSnapshotSource.test.ts | 17 +- .../services/team/teamTaskActiveState.test.ts | 329 ++++++++++++++++++ .../sidebar/taskFiltersState.test.ts | 32 ++ .../team/activity/ActivityItem.test.ts | 37 ++ .../team/members/MemberList.test.ts | 47 ++- .../agent-graph/taskGraphSemantics.test.ts | 75 ++++ test/renderer/utils/memberHelpers.test.ts | 46 ++- ...openCodeRuntimeDeliveryDiagnostics.test.ts | 21 ++ test/renderer/utils/pathNormalize.test.ts | 105 ++++++ .../utils/teamMessageFiltering.test.ts | 50 +++ test/shared/utils/reviewState.test.ts | 54 +++ test/shared/utils/taskChangeState.test.ts | 59 ++++ 72 files changed, 2756 insertions(+), 322 deletions(-) create mode 100644 src/main/services/team/teamTaskActiveState.ts create mode 100644 src/renderer/utils/teamTaskDisplayState.ts create mode 100644 src/shared/utils/teamAutomationMessages.ts create mode 100644 src/shared/utils/teamTaskState.ts create mode 100644 test/features/member-work-sync/core/SyncDecisionPolicy.test.ts create mode 100644 test/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.test.ts create mode 100644 test/main/services/team/teamTaskActiveState.test.ts create mode 100644 test/renderer/features/agent-graph/taskGraphSemantics.test.ts create mode 100644 test/renderer/utils/pathNormalize.test.ts diff --git a/resources/pricing.json b/resources/pricing.json index b8e4c05a..c45fff83 100644 --- a/resources/pricing.json +++ b/resources/pricing.json @@ -283,6 +283,7 @@ "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, + "supports_minimal_reasoning_effort": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, @@ -480,6 +481,7 @@ "supports_vision": true, "supports_prompt_caching": false, "supports_reasoning": true, + "supports_minimal_reasoning_effort": true, "supports_tool_choice": true }, "global.anthropic.claude-opus-4-7": { @@ -627,6 +629,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, @@ -656,6 +659,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, @@ -685,6 +689,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, @@ -713,6 +718,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, @@ -741,6 +747,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, @@ -1018,6 +1025,7 @@ "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, + "supports_minimal_reasoning_effort": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true @@ -1141,6 +1149,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, @@ -1711,6 +1720,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, @@ -1718,6 +1728,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, @@ -1853,6 +1864,7 @@ "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, + "supports_minimal_reasoning_effort": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, @@ -1880,6 +1892,7 @@ "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, + "supports_minimal_reasoning_effort": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, @@ -1901,6 +1914,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1934,6 +1948,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -1967,6 +1982,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -2001,6 +2017,7 @@ "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, + "supports_adaptive_thinking": true, "supports_assistant_prefill": false, "supports_computer_use": true, "supports_function_calling": true, @@ -2144,6 +2161,7 @@ "supports_assistant_prefill": true, "supports_function_calling": true, "supports_reasoning": true, + "supports_minimal_reasoning_effort": true, "supports_tool_choice": true }, "databricks/databricks-claude-sonnet-4": { @@ -2655,7 +2673,8 @@ "mode": "chat", "output_cost_per_token": 0.000025, "supports_function_calling": true, - "supports_vision": true + "supports_vision": true, + "supports_minimal_reasoning_effort": true }, "gmi/anthropic/claude-sonnet-4.5": { "input_cost_per_token": 0.000003, @@ -3304,6 +3323,7 @@ "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, + "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159, @@ -3322,6 +3342,7 @@ "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, + "supports_minimal_reasoning_effort": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, @@ -3343,6 +3364,7 @@ "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, + "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, @@ -3408,6 +3430,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, @@ -3786,6 +3809,7 @@ "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, + "supports_minimal_reasoning_effort": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, @@ -3814,6 +3838,7 @@ "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, + "supports_minimal_reasoning_effort": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, @@ -3841,6 +3866,7 @@ "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, + "supports_minimal_reasoning_effort": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, @@ -4107,6 +4133,7 @@ "output_cost_per_token": 0.000025, "supports_assistant_prefill": true, "supports_computer_use": true, + "supports_minimal_reasoning_effort": true, "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, @@ -4446,6 +4473,7 @@ "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, + "supports_minimal_reasoning_effort": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, @@ -4472,6 +4500,7 @@ "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, + "supports_minimal_reasoning_effort": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, @@ -4638,6 +4667,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, @@ -4778,6 +4808,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, + "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, diff --git a/runtime.lock.json b/runtime.lock.json index c6b60f81..7c6fc290 100644 --- a/runtime.lock.json +++ b/runtime.lock.json @@ -1,27 +1,27 @@ { - "version": "0.0.20", - "sourceRef": "v0.0.20", + "version": "0.0.21", + "sourceRef": "v0.0.21", "sourceRepository": "777genius/agent_teams_orchestrator", "releaseRepository": "777genius/claude_agent_teams_ui", "releaseTag": "v1.2.0", "assets": { "darwin-arm64": { - "file": "agent-teams-runtime-darwin-arm64-v0.0.20.tar.gz", + "file": "agent-teams-runtime-darwin-arm64-v0.0.21.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "darwin-x64": { - "file": "agent-teams-runtime-darwin-x64-v0.0.20.tar.gz", + "file": "agent-teams-runtime-darwin-x64-v0.0.21.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "linux-x64": { - "file": "agent-teams-runtime-linux-x64-v0.0.20.tar.gz", + "file": "agent-teams-runtime-linux-x64-v0.0.21.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "win32-x64": { - "file": "agent-teams-runtime-win32-x64-v0.0.20.zip", + "file": "agent-teams-runtime-win32-x64-v0.0.21.zip", "archiveKind": "zip", "binaryName": "claude-multimodel.exe" } diff --git a/src/features/agent-graph/core/domain/taskGraphSemantics.ts b/src/features/agent-graph/core/domain/taskGraphSemantics.ts index b629857e..53ed30ed 100644 --- a/src/features/agent-graph/core/domain/taskGraphSemantics.ts +++ b/src/features/agent-graph/core/domain/taskGraphSemantics.ts @@ -1,27 +1,34 @@ +import { + getTeamTaskWorkflowColumn, + isTeamTaskDeleted, + isTeamTaskFinishedForDependency, + isTeamTaskNeedsFixActionable, +} from '@shared/utils/teamTaskState'; + import type { KanbanColumnId, KanbanTaskState, TeamTask, TeamTaskWithKanban } from '@shared/types'; -type TaskColumnInput = Pick; +type TaskColumnInput = Pick< + TeamTaskWithKanban, + 'status' | 'reviewState' | 'kanbanColumn' | 'deletedAt' +>; type TaskReviewerInput = Pick; type TaskBlockInput = Pick; -type TaskBlockState = Pick; +type TaskBlockState = Pick< + TeamTaskWithKanban, + 'status' | 'reviewState' | 'kanbanColumn' | 'deletedAt' +>; export function resolveTaskGraphColumn(task: TaskColumnInput): KanbanColumnId { - if (task.reviewState === 'approved') return 'approved'; - if (task.reviewState === 'review' || task.reviewState === 'needsFix') return 'review'; - if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') { - return task.kanbanColumn; - } + const workflowColumn = getTeamTaskWorkflowColumn(task); + if (workflowColumn) return workflowColumn; + if (isTeamTaskNeedsFixActionable(task)) return 'review'; if (task.status === 'in_progress') return 'in_progress'; if (task.status === 'completed') return 'done'; return 'todo'; } export function isTaskInReviewCycle(task: TaskColumnInput): boolean { - return ( - task.reviewState === 'review' || - task.reviewState === 'needsFix' || - task.kanbanColumn === 'review' - ); + return isTeamTaskNeedsFixActionable(task) || getTeamTaskWorkflowColumn(task) === 'review'; } export function resolveTaskReviewer( @@ -43,6 +50,6 @@ export function isTaskBlocked( return blockedBy.some((taskId) => { const blocker = taskStateById.get(taskId); - return !blocker || (blocker.status !== 'completed' && blocker.status !== 'deleted'); + return !blocker || (!isTeamTaskFinishedForDependency(blocker) && !isTeamTaskDeleted(blocker)); }); } diff --git a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index 56da2f01..06bb7df7 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -25,6 +25,10 @@ import { } from '@shared/utils/idleNotificationSemantics'; import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { isLeadMember } from '@shared/utils/leadDetection'; +import { + isTeamTaskActivelyWorked, + isTeamTaskNeedsFixActionable, +} from '@shared/utils/teamTaskState'; import { buildOrderedVisibleTeamGraphOwnerIds } from '@shared/utils/teamGraphDefaultLayout'; import { @@ -41,6 +45,7 @@ import { import { isTaskBlocked, isTaskInReviewCycle, + resolveTaskGraphColumn, resolveTaskReviewer, } from '../../core/domain/taskGraphSemantics'; @@ -541,8 +546,17 @@ export class TeamGraphAdapter { spawn, pendingApprovalAgents?.has(member.name) ?? false ); + const currentTask = member.currentTaskId + ? data.tasks.find((task) => task.id === member.currentTaskId) + : undefined; + const displayableCurrentTask = + currentTask && isTeamTaskActivelyWorked(currentTask) ? currentTask : undefined; + const presentationMember = + member.currentTaskId && !displayableCurrentTask + ? { ...member, currentTaskId: null } + : member; const launchPresentation = buildMemberLaunchPresentation({ - member, + member: presentationMember, spawnStatus: spawn?.status, spawnLaunchState: spawn?.launchState, spawnLivenessSource: spawn?.livenessSource, @@ -579,10 +593,8 @@ export class TeamGraphAdapter { ? (launchPresentation.launchStatusLabel ?? undefined) : undefined, avatarUrl: resolveMemberAvatarUrl(member, avatarMap, 96), - currentTaskId: member.currentTaskId ?? undefined, - currentTaskSubject: member.currentTaskId - ? data.tasks.find((t) => t.id === member.currentTaskId)?.subject - : undefined, + currentTaskId: displayableCurrentTask?.id, + currentTaskSubject: displayableCurrentTask?.subject, pendingApproval: pendingApprovalAgents?.has(member.name) ?? false, exceptionTone: exception?.exceptionTone, exceptionLabel: exception?.exceptionLabel, @@ -632,12 +644,20 @@ export class TeamGraphAdapter { leadName?: string, activeTaskLogActivity?: Record ): void { - const taskStateById = new Map>(); + const taskStateById = new Map< + string, + Pick + >(); const taskDisplayIds = new Map(); const memberColorByName = new Map(); for (const t of data.tasks) { - taskStateById.set(t.id, { status: t.status }); + taskStateById.set(t.id, { + status: t.status, + ...(t.reviewState ? { reviewState: t.reviewState } : {}), + ...(t.kanbanColumn ? { kanbanColumn: t.kanbanColumn } : {}), + ...(t.deletedAt ? { deletedAt: t.deletedAt } : {}), + }); taskDisplayIds.set(t.id, t.displayId ?? `#${t.id.slice(0, 6)}`); } for (const member of data.members) { @@ -660,9 +680,19 @@ export class TeamGraphAdapter { const kanbanTaskState = data.kanbanState.tasks[task.id]; const reviewerName = resolveTaskReviewer(task, kanbanTaskState); const isReviewCycle = isTaskInReviewCycle(task); - - const taskStatus = TeamGraphAdapter.#mapTaskStatusLiteral(task.status); - const reviewState = TeamGraphAdapter.#mapReviewState(task.reviewState); + const graphColumn = resolveTaskGraphColumn(task); + const taskStatus = + graphColumn === 'approved' + ? 'completed' + : TeamGraphAdapter.#mapTaskStatusLiteral(task.status); + const reviewState = + graphColumn === 'approved' + ? 'approved' + : graphColumn === 'review' + ? isTeamTaskNeedsFixActionable(task) + ? 'needsFix' + : 'review' + : TeamGraphAdapter.#mapReviewState(task.reviewState); const blockedByDisplayIds = task.blockedBy?.length ? task.blockedBy.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`) @@ -686,7 +716,8 @@ export class TeamGraphAdapter { kind: 'task', label: task.displayId ?? `#${task.id.slice(0, 6)}`, sublabel: task.subject, - state: TeamGraphAdapter.#mapTaskStatus(task.status), + state: + graphColumn === 'approved' ? 'complete' : TeamGraphAdapter.#mapTaskStatus(task.status), taskStatus, reviewState, reviewerName: isReviewCycle ? reviewerName : null, diff --git a/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx index 6c762279..184d9590 100644 --- a/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx +++ b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx @@ -13,6 +13,7 @@ import { buildMemberAvatarMap, buildMemberLaunchPresentation, } from '@renderer/utils/memberHelpers'; +import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState'; import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation'; import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react'; @@ -309,6 +310,17 @@ const MemberPopoverContent = ({ const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]); const avatarSrc = node.avatarUrl ?? avatarMap.get(memberName) ?? agentAvatarUrl(memberName, 64); const member = teamMembers.find((candidate) => candidate.name === memberName) ?? null; + const currentTaskCandidate = + member?.currentTaskId && teamData + ? (teamData.tasks.find((task) => task.id === member.currentTaskId) ?? null) + : null; + const displayableCurrentTask = isDisplayableCurrentTask(currentTaskCandidate) + ? currentTaskCandidate + : null; + const currentTaskIndicatorId = + displayableCurrentTask?.id ?? (!teamData ? node.currentTaskId : undefined); + const currentTaskIndicatorSubject = + displayableCurrentTask?.subject ?? (!teamData ? node.currentTaskSubject : undefined); const provisioningPresentation = teamData && teamName ? buildTeamProvisioningPresentation({ @@ -320,7 +332,10 @@ const MemberPopoverContent = ({ : null; const launchPresentation = member ? buildMemberLaunchPresentation({ - member, + member: + member.currentTaskId && !displayableCurrentTask + ? { ...member, currentTaskId: null } + : member, spawnStatus: spawnEntry?.status, spawnLaunchState: spawnEntry?.launchState, spawnLivenessSource: spawnEntry?.livenessSource, @@ -444,7 +459,7 @@ const MemberPopoverContent = ({ {/* Context usage stays hidden for now because lead context telemetry is still incomplete. */} {/* Current task indicator — reuses same pattern as MemberCard */} - {node.currentTaskId && node.currentTaskSubject && ( + {currentTaskIndicatorId && currentTaskIndicatorSubject && (
{ e.stopPropagation(); - onOpenTask?.(node.currentTaskId!); + onOpenTask?.(currentTaskIndicatorId); onClose(); }} > - {node.currentTaskSubject.length > 30 - ? `${node.currentTaskSubject.slice(0, 30)}…` - : node.currentTaskSubject} + {currentTaskIndicatorSubject.length > 30 + ? `${currentTaskIndicatorSubject.slice(0, 30)}…` + : currentTaskIndicatorSubject}
)} diff --git a/src/features/agent-graph/renderer/ui/GraphTaskCard.tsx b/src/features/agent-graph/renderer/ui/GraphTaskCard.tsx index a051ba17..bf41c87b 100644 --- a/src/features/agent-graph/renderer/ui/GraphTaskCard.tsx +++ b/src/features/agent-graph/renderer/ui/GraphTaskCard.tsx @@ -7,6 +7,7 @@ import { useMemo } from 'react'; import { KanbanTaskCard } from '@renderer/components/team/kanban/KanbanTaskCard'; +import { isTeamTaskNeedsFixActionable } from '@shared/utils/teamTaskState'; import { isTaskBlocked, resolveTaskGraphColumn } from '../../core/domain/taskGraphSemantics'; import { useGraphActivityContext } from '../hooks/useGraphActivityContext'; @@ -49,7 +50,7 @@ function getGlowStyle(task: TeamTask, taskMap: ReadonlyMap): R boxShadow: '0 0 14px rgba(59, 130, 246, 0.4), inset 0 0 6px rgba(59, 130, 246, 0.08)', }; case 'review': - return task.reviewState === 'needsFix' + return isTeamTaskNeedsFixActionable(task) ? { boxShadow: '0 0 14px rgba(239, 68, 68, 0.4), inset 0 0 6px rgba(239, 68, 68, 0.08)' } : { boxShadow: '0 0 14px rgba(245, 158, 11, 0.4), inset 0 0 6px rgba(245, 158, 11, 0.08)' }; case 'approved': diff --git a/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts b/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts index 7c8bb66a..657b53fc 100644 --- a/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts +++ b/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts @@ -1,3 +1,10 @@ +import { + getTeamTaskWorkflowColumn, + isTeamTaskFinishedForDependency, + isTeamTaskNeedsFixActionable, + isTeamTaskTerminalForActionableWork, +} from '@shared/utils/teamTaskState'; + import { buildAgendaFingerprintPayload, canonicalizeAgendaFingerprintPayload, @@ -19,6 +26,7 @@ export interface MemberWorkSyncTaskLike { status: string; owner?: string | null; reviewState?: string | null; + kanbanColumn?: string | null; needsClarification?: 'lead' | 'user' | null; blockedBy?: string[]; blocks?: string[]; @@ -45,10 +53,6 @@ export interface BuildActionableWorkAgendaInput { hash: (canonicalPayload: string) => string; } -function isCompletedOrDeleted(task: MemberWorkSyncTaskLike): boolean { - return task.status === 'completed' || task.status === 'deleted' || Boolean(task.deletedAt); -} - function getActiveMemberNames(members: MemberWorkSyncMemberLike[]): Set { return new Set( members @@ -114,7 +118,9 @@ export function buildActionableWorkAgenda( if (activeMemberNames.has(memberName)) { for (const task of input.tasks) { - if (!task.id || isCompletedOrDeleted(task)) { + const workflowColumn = getTeamTaskWorkflowColumn(task); + const isReviewWorkflow = workflowColumn === 'review'; + if (!task.id || (isTeamTaskTerminalForActionableWork(task) && !isReviewWorkflow)) { continue; } @@ -128,7 +134,7 @@ export function buildActionableWorkAgenda( const dependency = tasksByReference.get(dependencyId) ?? null; if (!dependency || dependency.status === 'deleted' || dependency.deletedAt) { brokenDependencyIds.push(dependencyId); - } else if (dependency.status !== 'completed') { + } else if (!isTeamTaskFinishedForDependency(dependency)) { waitingDependencyIds.push(dependencyId); } } @@ -174,11 +180,13 @@ export function buildActionableWorkAgenda( continue; } - const reviewOwner = resolveCurrentReviewOwner({ - reviewState: task.reviewState, - kanbanReviewer: input.kanbanReviewersByTaskId?.[task.id] ?? null, - historyEvents: task.historyEvents, - }); + const reviewOwner = isReviewWorkflow + ? resolveCurrentReviewOwner({ + reviewState: task.reviewState, + kanbanReviewer: input.kanbanReviewersByTaskId?.[task.id] ?? null, + historyEvents: task.historyEvents, + }) + : null; if (reviewOwner && sameMemberName(reviewOwner.reviewer, memberName)) { items.push({ @@ -199,6 +207,10 @@ export function buildActionableWorkAgenda( continue; } + if (isReviewWorkflow) { + continue; + } + if (!sameMemberName(owner, memberName)) { continue; } @@ -214,18 +226,17 @@ export function buildActionableWorkAgenda( if ( task.status === 'pending' || task.status === 'in_progress' || - task.reviewState === 'needsFix' + isTeamTaskNeedsFixActionable(task) ) { items.push({ ...base, kind: 'work', priority: 'normal', - reason: - task.reviewState === 'needsFix' - ? 'review_changes_requested' - : task.status === 'pending' - ? 'owned_pending_task' - : 'owned_in_progress_task', + reason: isTeamTaskNeedsFixActionable(task) + ? 'review_changes_requested' + : task.status === 'pending' + ? 'owned_pending_task' + : 'owned_in_progress_task', evidence: { status: task.status, owner: memberName, diff --git a/src/features/member-work-sync/core/domain/currentReviewCycle.ts b/src/features/member-work-sync/core/domain/currentReviewCycle.ts index 47261959..f5b4cdd9 100644 --- a/src/features/member-work-sync/core/domain/currentReviewCycle.ts +++ b/src/features/member-work-sync/core/domain/currentReviewCycle.ts @@ -6,6 +6,8 @@ export interface ReviewHistoryEventLike { timestamp?: string; actor?: string; reviewer?: string; + from?: string; + to?: string; } export interface CurrentReviewOwner { diff --git a/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver.ts b/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver.ts index 8b0835bb..3163755e 100644 --- a/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver.ts +++ b/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver.ts @@ -1,4 +1,8 @@ import { isLeadMember } from '@shared/utils/leadDetection'; +import { + getTeamTaskWorkflowColumn, + isTeamTaskTerminalForActionableWork, +} from '@shared/utils/teamTaskState'; import { normalizeMemberName, resolveCurrentReviewOwner } from '../../../core/domain'; @@ -20,10 +24,6 @@ export interface MemberWorkSyncTaskImpactResolverResult { diagnostics: string[]; } -function isTerminalTask(task: Pick): boolean { - return task.status === 'completed' || task.status === 'deleted' || Boolean(task.deletedAt); -} - function isDeletedTask(task: Pick): boolean { return task.status === 'deleted' || Boolean(task.deletedAt); } @@ -127,13 +127,6 @@ export class MemberWorkSyncTaskImpactResolver { addMember(task.owner); - const reviewOwner = resolveCurrentReviewOwner({ - reviewState: task.reviewState, - kanbanReviewer: kanban.tasks[task.id]?.reviewer ?? null, - historyEvents: task.historyEvents, - }); - addMember(reviewOwner?.reviewer); - if (!normalizeMemberName(task.owner)) { addLead(); addDiagnostic('task_owner_missing'); @@ -142,7 +135,23 @@ export class MemberWorkSyncTaskImpactResolver { addDiagnostic('task_owner_inactive'); } - if (task.reviewState === 'review' && !reviewOwner?.reviewer) { + const taskKanbanColumn = kanban.tasks[task.id]?.column; + const taskWorkflowColumn = getTeamTaskWorkflowColumn({ + ...task, + ...(taskKanbanColumn ? { kanbanColumn: taskKanbanColumn } : {}), + }); + + const reviewOwner = + taskWorkflowColumn === 'review' + ? resolveCurrentReviewOwner({ + reviewState: task.reviewState, + kanbanReviewer: kanban.tasks[task.id]?.reviewer ?? null, + historyEvents: task.historyEvents, + }) + : null; + addMember(reviewOwner?.reviewer); + + if (taskWorkflowColumn === 'review' && !reviewOwner?.reviewer) { addLead(); addDiagnostic('task_reviewer_missing'); } @@ -166,7 +175,14 @@ export class MemberWorkSyncTaskImpactResolver { } for (const candidate of tasks) { - if (candidate.id === task.id || isTerminalTask(candidate)) { + const kanbanColumn = kanban.tasks[candidate.id]?.column; + if ( + candidate.id === task.id || + isTeamTaskTerminalForActionableWork({ + ...candidate, + ...(kanbanColumn ? { kanbanColumn } : {}), + }) + ) { continue; } if ( diff --git a/src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts b/src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts index b1b01a5a..5550c5d5 100644 --- a/src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts +++ b/src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts @@ -122,7 +122,13 @@ export class TeamTaskAgendaSource implements MemberWorkSyncAgendaSourcePort { teamName: input.teamName, memberName: input.memberName, generatedAt: this.deps.clock.now().toISOString(), - tasks, + tasks: tasks.map((task) => { + const kanbanColumn = kanban.tasks[task.id]?.column; + return { + ...task, + ...(kanbanColumn ? { kanbanColumn } : {}), + }; + }), members: members.map(toMemberLike), kanbanReviewersByTaskId: Object.fromEntries( Object.entries(kanban.tasks).map(([taskId, value]) => [taskId, value.reviewer ?? null]) diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index de1bad7a..b27ba729 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -14,7 +14,7 @@ import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSema import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { migrateProviderBackendId } from '@shared/utils/providerBackend'; -import { getKanbanColumnFromReviewState, getReviewStateFromTask } from '@shared/utils/reviewState'; +import { getReviewStateFromTask } from '@shared/utils/reviewState'; import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors'; @@ -62,6 +62,7 @@ import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificatio import { TeamTaskReader } from './TeamTaskReader'; import { TeamTaskWriter } from './TeamTaskWriter'; import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver'; +import { getTeamTaskWorkflowColumn, selectCurrentActiveTeamTask } from './teamTaskActiveState'; import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes'; import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository'; @@ -550,13 +551,7 @@ export class TeamDataService { const launchIdentity = teamMeta?.launchIdentity; const leadName = 'team-lead'; const ownedTasks = tasks.filter((task) => task.owner === leadName); - const currentTask = - ownedTasks.find( - (task) => - task.status === 'in_progress' && - task.reviewState !== 'approved' && - task.kanbanColumn !== 'approved' - ) ?? null; + const currentTask = selectCurrentActiveTeamTask(ownedTasks); members.unshift({ name: leadName, @@ -600,12 +595,24 @@ export class TeamDataService { task: Pick, kanbanTaskState?: KanbanState['tasks'][string] ): 'none' | 'review' | 'needsFix' | 'approved' { - return getReviewStateFromTask({ + const kanbanColumn = kanbanTaskState?.column; + const reviewState = getReviewStateFromTask({ historyEvents: task.historyEvents, reviewState: task.reviewState, status: task.status, - kanbanColumn: kanbanTaskState?.column, + ...(kanbanColumn ? { kanbanColumn } : {}), }); + const workflowColumn = getTeamTaskWorkflowColumn({ + status: task.status, + reviewState, + ...(kanbanColumn ? { kanbanColumn } : {}), + }); + + if (workflowColumn) { + return workflowColumn; + } + + return reviewState; } private attachKanbanCompatibility( @@ -614,14 +621,27 @@ export class TeamDataService { ): TeamTaskWithKanban { const reviewState = this.resolveTaskReviewState(task, kanbanTaskState); const reviewer = this.resolveReviewerFromHistory(task, kanbanTaskState, reviewState) ?? null; + const kanbanColumn = this.resolveTaskKanbanColumn(task, kanbanTaskState, reviewState); return { ...task, reviewState, - kanbanColumn: getKanbanColumnFromReviewState(reviewState), + ...(kanbanColumn ? { kanbanColumn } : {}), reviewer, }; } + private resolveTaskKanbanColumn( + task: Pick, + kanbanTaskState?: KanbanState['tasks'][string], + reviewState: 'none' | 'review' | 'needsFix' | 'approved' = 'none' + ): 'review' | 'approved' | undefined { + return getTeamTaskWorkflowColumn({ + status: task.status, + reviewState, + ...(kanbanTaskState?.column ? { kanbanColumn: kanbanTaskState.column } : {}), + }); + } + /** * Extract reviewer name from the current review cycle history. * For legacy boards that stored reviewer only in kanban state, preserve that @@ -1023,7 +1043,7 @@ export class TeamDataService { const info = teamInfoMap.get(task.teamName)!; const kanbanTaskState = kanbanByTeam.get(task.teamName)?.tasks[task.id]; const reviewState = this.resolveTaskReviewState(task, kanbanTaskState); - const kanbanColumn = getKanbanColumnFromReviewState(reviewState); + const kanbanColumn = this.resolveTaskKanbanColumn(task, kanbanTaskState, reviewState); // IPC payload safety: GlobalTask lists can be enormous (especially comments and large nested fields). // Return a "light" task object and defer heavy details to team/task detail views. diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index a2f9d6c3..fc209f53 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -9,6 +9,8 @@ import { import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId'; +import { selectCurrentActiveTeamTask } from './teamTaskActiveState'; + import type { PersistedTeamLaunchSnapshot, TeamConfig, @@ -282,13 +284,7 @@ export class TeamMemberResolver { const members: TeamMemberSnapshot[] = []; for (const name of names) { const ownedTasks = tasks.filter((task) => task.owner === name); - const currentTask = - ownedTasks.find( - (task) => - task.status === 'in_progress' && - task.reviewState !== 'approved' && - task.kanbanColumn !== 'approved' - ) ?? null; + const currentTask = selectCurrentActiveTeamTask(ownedTasks); const configMember = configMemberMap.get(name); const metaMember = metaMemberMap.get(name); const launchMember = launchMemberMap.get(name); diff --git a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts index 47d912a8..4f83608d 100644 --- a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts +++ b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts @@ -47,6 +47,8 @@ const QUOTA_EXHAUSTED_TOKENS = [ 'quota exceeded', 'quota exhausted', 'insufficient credits', + 'key limit exceeded', + 'total limit', ]; const RATE_LIMITED_TOKENS = [ 'rate limit', @@ -83,6 +85,18 @@ const PROVIDER_OVERLOADED_TOKENS = [ 'service unavailable', '503', ]; +const PROTOCOL_PROOF_MISSING_TOKENS = [ + 'non_visible_tool_without_task_progress', + 'visible_reply_still_required', + 'visible_reply_ack_only_still_requires_answer', + 'plain_text_ack_only_still_requires_answer', + 'visible_reply_destination_not_found_yet', + 'visible_reply_missing_relayofmessageid', + 'did not create a visible reply', + 'did not create a visible message_send reply', + 'did not create a visible reply or task progress proof', + 'without the required relayofmessageid correlation', +]; const logger = createLogger('Service:TeamMemberRuntimeAdvisory'); interface CachedRuntimeAdvisory { @@ -123,6 +137,9 @@ function classifyRetryReason(message: string | undefined): MemberRuntimeAdvisory if (includesAnyToken(normalized, PROVIDER_OVERLOADED_TOKENS)) { return 'provider_overloaded'; } + if (includesAnyToken(normalized, PROTOCOL_PROOF_MISSING_TOKENS)) { + return 'protocol_proof_missing'; + } return 'backend_error'; } diff --git a/src/main/services/team/TeamMessageFeedService.ts b/src/main/services/team/TeamMessageFeedService.ts index b4de64c9..74089ba7 100644 --- a/src/main/services/team/TeamMessageFeedService.ts +++ b/src/main/services/team/TeamMessageFeedService.ts @@ -131,8 +131,7 @@ function buildSyntheticBootstrapMessages(config: TeamConfig): InboxMessage[] { member && member.name?.trim() && member.name.trim().toLowerCase() !== normalizedLeadName && - member.removedAt == null && - (member as { isActive?: unknown }).isActive !== false + member.removedAt == null ) .map((member) => ({ from: leadName, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 0fdb92c4..1f2f75fb 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -84,6 +84,12 @@ import { createLogger } from '@shared/utils/logger'; import { migrateProviderBackendId } from '@shared/utils/providerBackend'; import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; +import { + getTeamTaskWorkflowColumn, + isTeamTaskActivelyWorked, + isTeamTaskDeleted, + isTeamTaskNeedsFixActionable, +} from '@shared/utils/teamTaskState'; import { isTeamInternalControlMessageText, stripExactInternalControlEchoPrefix, @@ -163,7 +169,10 @@ import { type OpenCodePromptDeliveryLedgerStore, type OpenCodePromptDeliveryStatus, } from './opencode/delivery/OpenCodePromptDeliveryLedger'; -import { selectOpenCodeRuntimeDeliveryReason } from './opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics'; +import { + isActionRequiredOpenCodeRuntimeDeliveryReason, + selectOpenCodeRuntimeDeliveryReason, +} from './opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics'; import { decideOpenCodePromptDeliveryRepair, type OpenCodePromptDeliveryHardFailureKind, @@ -4392,16 +4401,34 @@ function getAgentLanguageInstruction(): string { return `IMPORTANT: Communicate in ${languageName}. All messages, summaries, and task descriptions MUST be in ${languageName}.`; } +function isTaskBoardSnapshotWorkCandidate(task: TeamTask): boolean { + if (!task.id || task.id.startsWith('_internal') || isTeamTaskDeleted(task)) { + return false; + } + + const workflowColumn = getTeamTaskWorkflowColumn(task); + if (workflowColumn === 'review' || workflowColumn === 'approved') { + return false; + } + + return ( + task.status === 'pending' || + isTeamTaskNeedsFixActionable(task) || + isTeamTaskActivelyWorked(task) + ); +} + /** Build a full task board snapshot for the lead. */ function buildTaskBoardSnapshot(tasks: TeamTask[]): string { - const active = tasks.filter( - (t) => (t.status === 'pending' || t.status === 'in_progress') && !t.id.startsWith('_internal') - ); + const active = tasks.filter(isTaskBoardSnapshotWorkCandidate); if (active.length === 0) return '\nNo pending tasks on the board.\n'; const lines = active.map((t) => { const owner = t.owner ? ` (owner: ${t.owner})` : ' (unassigned)'; const desc = t.description ? ` — ${t.description.slice(0, 120)}` : ''; + const stateLabel = [t.status, isTeamTaskNeedsFixActionable(t) ? 'needsFix' : null] + .filter(Boolean) + .join(', '); const deps = t.blockedBy?.length ? ` [blocked by: ${t.blockedBy .map((id) => tasks.find((candidate) => candidate.id === id)) @@ -4409,9 +4436,9 @@ function buildTaskBoardSnapshot(tasks: TeamTask[]): string { .map((task) => formatTaskDisplayLabel(task)) .join(', ')}]` : ''; - return ` - ${formatTaskDisplayLabel(t)} (taskId: ${t.id}) [${t.status}]${owner} ${t.subject}${deps}${desc}`; + return ` - ${formatTaskDisplayLabel(t)} (taskId: ${t.id}) [${stateLabel}]${owner} ${t.subject}${deps}${desc}`; }); - return `\nCurrent task board (in_progress/pending):\n${lines.join('\n')}\n`; + return `\nCurrent actionable task board (pending/in_progress/needsFix):\n${lines.join('\n')}\n`; } function buildDeterministicLaunchHydrationPrompt( @@ -7523,16 +7550,56 @@ export class TeamProvisioningService { ...extra, }) ); - if ( - event === 'opencode_prompt_delivery_terminal_failure' && - record.status === 'failed_terminal' - ) { + const shouldNotifyTerminalFailure = + event === 'opencode_prompt_delivery_terminal_failure' && record.status === 'failed_terminal'; + const shouldNotifyActionRequiredRetry = + !shouldNotifyTerminalFailure && + this.shouldNotifyOpenCodeRuntimeDeliveryBeforeTerminal(record); + if (shouldNotifyTerminalFailure || shouldNotifyActionRequiredRetry) { void this.fireOpenCodeRuntimeDeliveryErrorNotification(record).catch((error) => { logger.warn( `[${record.teamName}] Failed to fire OpenCode runtime delivery error notification for ${record.memberName}: ${getErrorMessage(error)}` ); }); + return; } + if (this.shouldSurfaceOpenCodeRuntimeDeliveryAdvisory(record)) { + this.emitOpenCodeRuntimeDeliveryAdvisoryEvent(record); + } + } + + private shouldSurfaceOpenCodeRuntimeDeliveryAdvisory( + record: OpenCodePromptDeliveryLedgerRecord + ): boolean { + if (!selectOpenCodeRuntimeDeliveryReason(record)) { + return false; + } + if (record.status === 'failed_terminal') { + return true; + } + if (record.status === 'responded') { + return false; + } + return ( + record.responseState === 'session_error' || + record.responseState === 'tool_error' || + record.responseState === 'permission_blocked' || + record.responseState === 'reconcile_failed' + ); + } + + private shouldNotifyOpenCodeRuntimeDeliveryBeforeTerminal( + record: OpenCodePromptDeliveryLedgerRecord + ): boolean { + if (!this.shouldSurfaceOpenCodeRuntimeDeliveryAdvisory(record)) { + return false; + } + if (record.status === 'failed_terminal') { + return false; + } + return isActionRequiredOpenCodeRuntimeDeliveryReason( + selectOpenCodeRuntimeDeliveryReason(record) + ); } private async fireOpenCodeRuntimeDeliveryErrorNotification( @@ -7596,7 +7663,8 @@ export class TeamProvisioningService { ); } - const eventKey = `opencode_runtime_delivery_error:${record.teamName}:${record.memberName}:${record.id}`; + const reasonKey = this.getOpenCodeRuntimeDeliveryAdvisoryReasonKey(record); + const eventKey = `opencode_runtime_delivery_error:${record.teamName}:${record.memberName}:${record.id}:${reasonKey}`; const now = Date.now(); this.pruneOpenCodeRuntimeDeliveryAdvisoryEventDedupe(now); if (this.openCodeRuntimeDeliveryAdvisoryEventSentAt.has(eventKey)) { @@ -7626,6 +7694,20 @@ export class TeamProvisioningService { } } + private getOpenCodeRuntimeDeliveryAdvisoryReasonKey( + record: OpenCodePromptDeliveryLedgerRecord + ): string { + const reason = + selectOpenCodeRuntimeDeliveryReason(record) ?? record.responseState ?? record.status; + const normalized = reason + .toLowerCase() + .replace(/https?:\/\/\S+/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 96); + return normalized || 'unknown'; + } + private async notifyLeadAboutOpenCodeRuntimeDeliveryError(input: { record: OpenCodePromptDeliveryLedgerRecord; reason: string; @@ -27536,18 +27618,14 @@ export class TeamProvisioningService { try { const taskReader = new TeamTaskReader(); const tasks = await taskReader.getTasks(run.teamName); - const active = tasks.filter( - (t) => - (t.status === 'pending' || t.status === 'in_progress') && - !t.id.startsWith('_internal') - ); + const active = tasks.filter(isTaskBoardSnapshotWorkCandidate); if (active.length === 0) return; const board = buildTaskBoardSnapshot(tasks); const message = [ `Reconnected and ready. Begin executing tasks now.`, `Execute tasks sequentially and keep the board + user updated:`, - `- Identify the next READY task (pending, not blocked by incomplete dependencies).`, + `- Identify the next READY task (pending or needsFix, not blocked by incomplete dependencies).`, `- If the task is unassigned, set yourself as owner.`, `- BEFORE doing any work on a task: mark it started (in_progress).`, `- Immediately SendMessage "user" that you started task # (what you're doing + next step).`, diff --git a/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts b/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts index d66cf4a6..648ec547 100644 --- a/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts +++ b/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts @@ -19,6 +19,25 @@ const GENERIC_DELIVERY_DIAGNOSTIC_TOKENS = [ 'visible_reply_ack_only_still_requires_answer', 'visible_reply_destination_not_found_yet', 'visible_reply_missing_relayofmessageid', + 'non_visible_tool_without_task_progress', +] as const; + +const ACTION_REQUIRED_DELIVERY_ERROR_TOKENS = [ + 'auth_unavailable', + 'no auth available', + 'authentication_failed', + 'unauthorized', + 'forbidden', + 'invalid api key', + 'api key', + 'does not have access', + 'please run /login', + 'insufficient credits', + 'quota exceeded', + 'quota exhausted', + 'capacity exceeded', + 'key limit exceeded', + 'total limit', ] as const; export function normalizeOpenCodeRuntimeDeliveryDiagnostic( @@ -61,6 +80,16 @@ export function selectOpenCodeRuntimeDeliveryReason( return normalized.length > 0 ? 'OpenCode runtime delivery did not complete.' : null; } +export function isActionRequiredOpenCodeRuntimeDeliveryReason( + message: string | null | undefined +): boolean { + const normalized = normalizeOpenCodeRuntimeDeliveryDiagnostic(message)?.toLowerCase(); + if (!normalized) { + return false; + } + return ACTION_REQUIRED_DELIVERY_ERROR_TOKENS.some((token) => normalized.includes(token)); +} + function getOpenCodeRuntimeDeliveryStateFallback( record: OpenCodePromptDeliveryLedgerRecord ): string | null { @@ -88,6 +117,9 @@ function getOpenCodeRuntimeDeliveryStateFallback( ) { return 'OpenCode created a reply without the required relayOfMessageId correlation.'; } + if (reason === 'non_visible_tool_without_task_progress') { + return 'OpenCode used tools, but did not create a visible reply or task progress proof.'; + } return null; } diff --git a/src/main/services/team/stallMonitor/TeamTaskStallNotifier.ts b/src/main/services/team/stallMonitor/TeamTaskStallNotifier.ts index e5b3adb5..e218cea6 100644 --- a/src/main/services/team/stallMonitor/TeamTaskStallNotifier.ts +++ b/src/main/services/team/stallMonitor/TeamTaskStallNotifier.ts @@ -107,6 +107,7 @@ export class TeamTaskStallNotifier { taskRefs: [args.alert.taskRef], actionMode: 'do', source: 'system_notification', + messageKind: 'task_stall_remediation', }; await this.inboxWriter.sendMessage(args.teamName, request); return true; diff --git a/src/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.ts b/src/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.ts index 336c6df3..8e1a31e6 100644 --- a/src/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.ts +++ b/src/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.ts @@ -10,6 +10,7 @@ import { isBoardTaskExactLogsReadEnabled } from '../taskLogs/exact/featureGates' import { TeamKanbanManager } from '../TeamKanbanManager'; import { TeamMembersMetaStore } from '../TeamMembersMetaStore'; import { TeamTaskReader } from '../TeamTaskReader'; +import { getTeamTaskWorkflowColumn, isTeamTaskActivelyWorked } from '../teamTaskActiveState'; import { BoardTaskActivityBatchIndexer } from './BoardTaskActivityBatchIndexer'; import { OpenCodeTaskStallEvidenceSource } from './OpenCodeTaskStallEvidenceSource'; @@ -87,12 +88,47 @@ export class TeamTaskStallSnapshotSource { this.kanbanManager.getState(teamName), this.membersMetaStore.getMembers(teamName).catch(() => []), ]); - const allTasks = [...activeTasks, ...deletedTasks]; + const withWorkflowOverlay = (task: TeamTask): TeamTask => { + const kanbanColumn = kanbanState.tasks[task.id]?.column; + const workflowColumn = getTeamTaskWorkflowColumn({ + ...task, + ...(kanbanColumn ? { kanbanColumn } : {}), + }); + if (workflowColumn) { + return task.reviewState !== workflowColumn + ? { ...task, reviewState: workflowColumn } + : task; + } + return task.reviewState === 'review' || task.reviewState === 'approved' + ? { ...task, reviewState: 'none' } + : task; + }; + const workflowActiveTasks = activeTasks.map(withWorkflowOverlay); + const allTasks = [...workflowActiveTasks, ...deletedTasks]; const allTasksById = new Map(allTasks.map((task) => [task.id, task] as const)); - const inProgressTasks = activeTasks.filter( - (task) => task.status === 'in_progress' && task.reviewState !== 'review' - ); - const reviewOpenTasks = activeTasks.filter((task) => task.reviewState === 'review'); + const inProgressTasks = workflowActiveTasks.filter((task) => { + const kanbanColumn = kanbanState.tasks[task.id]?.column; + const workflowColumn = getTeamTaskWorkflowColumn({ + ...task, + ...(kanbanColumn ? { kanbanColumn } : {}), + }); + return ( + workflowColumn !== 'review' && + isTeamTaskActivelyWorked({ + ...task, + ...(kanbanColumn ? { kanbanColumn } : {}), + }) + ); + }); + const reviewOpenTasks = workflowActiveTasks.filter((task) => { + const kanbanColumn = kanbanState.tasks[task.id]?.column; + return ( + getTeamTaskWorkflowColumn({ + ...task, + ...(kanbanColumn ? { kanbanColumn } : {}), + }) === 'review' + ); + }); const resolvedReviewersByTaskId = buildResolvedReviewerIndex(activeTasks, kanbanState); const activityReadsEnabled = isBoardTaskActivityReadEnabled(); const exactReadsEnabled = isBoardTaskExactLogsReadEnabled(); @@ -157,7 +193,7 @@ export class TeamTaskStallSnapshotSource { transcriptFiles: transcriptContext.transcriptFiles, activityReadsEnabled, exactReadsEnabled, - activeTasks, + activeTasks: workflowActiveTasks, deletedTasks, allTasksById, inProgressTasks, diff --git a/src/main/services/team/teamTaskActiveState.ts b/src/main/services/team/teamTaskActiveState.ts new file mode 100644 index 00000000..059575d6 --- /dev/null +++ b/src/main/services/team/teamTaskActiveState.ts @@ -0,0 +1,70 @@ +import { isTeamTaskActivelyWorked } from '@shared/utils/teamTaskState'; + +import type { TeamTaskWithKanban } from '@shared/types'; + +export { + getTeamTaskWorkflowColumn, + isTeamTaskFinalForCompletionNotification, + isTeamTaskActivelyWorked, + isTeamTaskFinishedForDependency, + isTeamTaskNeedsFixActionable, + isTeamTaskTerminalForActionableWork, +} from '@shared/utils/teamTaskState'; + +function parseIsoTime(value: string | undefined): number { + if (!value) return 0; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function getActiveWorkStartedAt(task: TeamTaskWithKanban): number { + const workIntervals = task.workIntervals ?? []; + for (let index = workIntervals.length - 1; index >= 0; index--) { + const interval = workIntervals[index]; + if (interval && !interval.completedAt) { + const startedAt = parseIsoTime(interval.startedAt); + if (startedAt > 0) { + return startedAt; + } + } + } + + const historyEvents = task.historyEvents ?? []; + for (let index = historyEvents.length - 1; index >= 0; index--) { + const event = historyEvents[index]; + if (event?.type === 'status_changed' && event.to === 'in_progress') { + const startedAt = parseIsoTime(event.timestamp); + if (startedAt > 0) { + return startedAt; + } + } + } + + return Math.max(parseIsoTime(task.updatedAt), parseIsoTime(task.createdAt)); +} + +function compareCurrentActiveTasks(left: TeamTaskWithKanban, right: TeamTaskWithKanban): number { + const byStartedAt = getActiveWorkStartedAt(right) - getActiveWorkStartedAt(left); + if (byStartedAt !== 0) return byStartedAt; + + const byUpdatedAt = parseIsoTime(right.updatedAt) - parseIsoTime(left.updatedAt); + if (byUpdatedAt !== 0) return byUpdatedAt; + + const byCreatedAt = parseIsoTime(right.createdAt) - parseIsoTime(left.createdAt); + if (byCreatedAt !== 0) return byCreatedAt; + + const leftLabel = left.displayId ?? left.id; + const rightLabel = right.displayId ?? right.id; + return leftLabel.localeCompare(rightLabel, undefined, { + numeric: true, + sensitivity: 'base', + }); +} + +export function selectCurrentActiveTeamTask( + tasks: readonly T[] +): T | null { + const activeTasks = tasks.filter(isTeamTaskActivelyWorked); + if (activeTasks.length === 0) return null; + return [...activeTasks].sort(compareCurrentActiveTasks)[0] ?? null; +} diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index d3afb416..f471ecdb 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -1239,7 +1239,9 @@ function normalizeFallbackReviewState(value: unknown, status: string): string { if (status === 'in_progress' || status === 'deleted') return 'none'; if (status === 'pending') return reviewState === 'needsFix' ? 'needsFix' : 'none'; if (status === 'completed') { - return reviewState === 'review' || reviewState === 'approved' ? reviewState : 'none'; + return reviewState === 'review' || reviewState === 'approved' || reviewState === 'needsFix' + ? reviewState + : 'none'; } return reviewState; } @@ -1444,9 +1446,11 @@ async function readTasksDirForTeam( parsed.status === 'deleted' ? (parsed.status as string) : 'pending'; + const derivedReviewState = deriveReviewStateFromEvents(historyEvents); const reviewState = - deriveReviewStateFromEvents(historyEvents) ?? - normalizeFallbackReviewState(parsed.reviewState, status); + derivedReviewState !== null + ? normalizeFallbackReviewState(derivedReviewState, status) + : normalizeFallbackReviewState(parsed.reviewState, status); const task = { id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '', diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index ef6d959b..13557262 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -9,7 +9,10 @@ import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/membe import { nameColorSet } from '@renderer/utils/projectColor'; import { projectColor } from '@renderer/utils/projectColor'; import { projectLabelFromPath } from '@renderer/utils/taskGrouping'; -import { getTaskKanbanColumn } from '@shared/utils/reviewState'; +import { + getTeamTaskWorkflowColumn, + isTeamTaskNeedsFixActionable, +} from '@shared/utils/teamTaskState'; import { format, isThisYear, isToday, isYesterday } from 'date-fns'; import { CheckCircle2, Circle, Eye, Loader2, ShieldCheck, Trash2 } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; @@ -105,7 +108,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({ } }, [isRenaming, displaySubject]); - const reviewColumn = getTaskKanbanColumn(task); + const reviewColumn = getTeamTaskWorkflowColumn(task); const cfg = reviewColumn === 'approved' ? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const) @@ -212,7 +215,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({ ))} {displaySubject} - {task.reviewState === 'needsFix' && ( + {isTeamTaskNeedsFixActionable(task) && ( diff --git a/src/renderer/components/sidebar/taskFiltersState.ts b/src/renderer/components/sidebar/taskFiltersState.ts index 1cc7a86e..a02dee58 100644 --- a/src/renderer/components/sidebar/taskFiltersState.ts +++ b/src/renderer/components/sidebar/taskFiltersState.ts @@ -1,7 +1,10 @@ import { useSyncExternalStore } from 'react'; import { getSnapshot, getUnreadCount, subscribe } from '@renderer/services/commentReadStorage'; -import { getTaskKanbanColumn } from '@shared/utils/reviewState'; +import { + getTeamTaskWorkflowColumn, + isTeamTaskNeedsFixActionable, +} from '@shared/utils/teamTaskState'; export type TaskStatusFilterId = | 'todo' @@ -50,10 +53,10 @@ export function taskMatchesStatus( if (statusIds.size === 0) return false; if (statusIds.size === STATUS_OPTIONS.length) return task.status !== 'deleted'; - const kanbanColumn = getTaskKanbanColumn(task); - const inNeedsFix = task.reviewState === 'needsFix'; + const kanbanColumn = getTeamTaskWorkflowColumn(task); + const inNeedsFix = isTeamTaskNeedsFixActionable(task); const inTodo = task.status === 'pending' && !kanbanColumn && !inNeedsFix; - const inProgress = task.status === 'in_progress' && !kanbanColumn; + const inProgress = task.status === 'in_progress' && !kanbanColumn && !inNeedsFix; const inDone = task.status === 'completed' && !kanbanColumn && !inNeedsFix; const inReview = kanbanColumn === 'review'; const inApproved = kanbanColumn === 'approved'; diff --git a/src/renderer/components/team/TaskTooltip.tsx b/src/renderer/components/team/TaskTooltip.tsx index b777c22f..5382ef16 100644 --- a/src/renderer/components/team/TaskTooltip.tsx +++ b/src/renderer/components/team/TaskTooltip.tsx @@ -7,7 +7,10 @@ import { useStore } from '@renderer/store'; import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers'; import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils'; -import { getTaskKanbanColumn } from '@shared/utils/reviewState'; +import { + getTeamTaskWorkflowColumn, + isTeamTaskNeedsFixActionable, +} from '@shared/utils/teamTaskState'; import { formatTaskDisplayLabel, taskMatchesRef } from '@shared/utils/taskIdentity'; import { useShallow } from 'zustand/react/shallow'; @@ -29,7 +32,7 @@ const STATUS_COLORS: Record = { }; function getEffectiveColumn(task: TeamTaskWithKanban): string { - const reviewColumn = getTaskKanbanColumn(task); + const reviewColumn = getTeamTaskWorkflowColumn(task); if (reviewColumn) return reviewColumn; if (task.status === 'pending') return 'todo'; if (task.status === 'completed') return 'done'; @@ -159,7 +162,7 @@ export const TaskTooltip = memo(function TaskTooltip({ > {label} - {task.reviewState === 'needsFix' ? ( + {isTeamTaskNeedsFixActionable(task) ? ( diff --git a/src/renderer/components/team/activity/ActiveTasksBlock.tsx b/src/renderer/components/team/activity/ActiveTasksBlock.tsx index ac55ed8c..034c4974 100644 --- a/src/renderer/components/team/activity/ActiveTasksBlock.tsx +++ b/src/renderer/components/team/activity/ActiveTasksBlock.tsx @@ -10,7 +10,9 @@ import { buildMemberColorMap, displayMemberName, } from '@renderer/utils/memberHelpers'; +import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; +import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState'; import { ChevronRight } from 'lucide-react'; import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; @@ -27,7 +29,7 @@ interface ActiveTasksBlockProps { interface ActivityEntry { member: ResolvedTeamMember; - task: TeamTaskWithKanban | undefined; + task: TeamTaskWithKanban; taskId: string; kind: 'working' | 'reviewing'; } @@ -53,8 +55,8 @@ export const ActiveTasksBlock = memo(function ActiveTasksBlock({ for (const m of members) { if (!m.currentTaskId) continue; const task = taskMap.get(m.currentTaskId); - // Defense-in-depth: hide banner for approved/completed tasks even if currentTaskId is stale - if (task && (task.reviewState === 'approved' || task.status === 'completed')) continue; + // Defense-in-depth: hide stale currentTaskId until backend refresh clears it. + if (!isDisplayableCurrentTask(task)) continue; workingMemberNames.add(m.name); entries.push({ member: m, task, taskId: m.currentTaskId, kind: 'working' }); } @@ -63,7 +65,7 @@ export const ActiveTasksBlock = memo(function ActiveTasksBlock({ for (const m of members) { if (workingMemberNames.has(m.name)) continue; const reviewTask = tasks.find( - (t) => t.reviewer === m.name && (t.reviewState === 'review' || t.kanbanColumn === 'review') + (t) => t.reviewer === m.name && getTeamTaskWorkflowColumn(t) === 'review' ); if (reviewTask) { entries.push({ member: m, task: reviewTask, taskId: reviewTask.id, kind: 'reviewing' }); diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 366e5521..592b399a 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -63,6 +63,7 @@ import { getKnownSlashCommand, parseStandaloneSlashCommand, } from '@shared/utils/slashCommands'; +import { isTaskStallRemediationMessage } from '@shared/utils/teamAutomationMessages'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { AlertTriangle, @@ -379,6 +380,68 @@ const PassiveIdlePeerSummaryRow = ({ ); }; +const TaskStallRemediationRow = ({ + teamName, + recipientName, + recipientColor, + taskRef, + timestamp, + onMemberNameClick, + onTaskIdClick, +}: { + teamName: string; + recipientName: string; + recipientColor?: string; + taskRef?: NonNullable[number]; + timestamp: string; + onMemberNameClick?: (memberName: string) => void; + onTaskIdClick?: (taskId: string) => void; +}): React.JSX.Element => { + const taskLabel = taskRef + ? formatTaskDisplayLabel({ id: taskRef.taskId, displayId: taskRef.displayId }) + : null; + + return ( +
+ + automation + + + stall nudge + + + + + Asked teammate to continue stalled task + {taskRef && taskLabel ? ( + <> + {' '} + + + ) : null} + + + {timestamp} + +
+ ); +}; + const BootstrapSystemRow = ({ teamName, eventKind, @@ -926,6 +989,20 @@ export const ActivityItem = memo( ); } + if (isTaskStallRemediationMessage(message)) { + return ( + + ); + } + if (bootstrapDisplay) { return ( t.status !== 'deleted' && getTaskKanbanColumn(t) !== 'approved' + (t) => t.status !== 'deleted' && getTeamTaskWorkflowColumn(t) !== 'approved' ); const toggleBlockedBy = (taskId: string): void => { diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index bbc41c65..2e903d37 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -54,7 +54,11 @@ import { } from '@renderer/utils/taskChangeRequest'; import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; import { isLeadMember } from '@shared/utils/leadDetection'; -import { getTaskKanbanColumn } from '@shared/utils/reviewState'; +import { + getTeamTaskWorkflowColumn, + isTeamTaskFinishedForDependency, + isTeamTaskNeedsFixActionable, +} from '@shared/utils/teamTaskState'; import { deriveTaskDisplayId, formatTaskDisplayLabel, @@ -598,12 +602,10 @@ export const TaskDetailDialog = ({ ); } - const kanbanColumn = - kanbanTaskState?.column ?? - getTaskKanbanColumn({ - reviewState: currentTask.reviewState, - kanbanColumn: currentTask.kanbanColumn, - }); + const kanbanColumn = getTeamTaskWorkflowColumn({ + ...currentTask, + ...(kanbanTaskState?.column ? { kanbanColumn: kanbanTaskState.column } : {}), + }); const status = currentTask.status; const statusStyle = kanbanColumn && KANBAN_COLUMN_DISPLAY[kanbanColumn] @@ -659,13 +661,13 @@ export const TaskDetailDialog = ({ {formatTaskDisplayLabel(currentTask)} - {(currentTask.reviewState === 'approved' || currentTask.reviewState === 'review') && + {(kanbanColumn === 'approved' || kanbanColumn === 'review') && currentTask.reviewer && currentTask.reviewer !== 'user' ? ( (() => { const reviewerColor = colorMap.get(currentTask.reviewer); const colors = - currentTask.reviewState === 'review' + kanbanColumn === 'review' ? getTeamColorSet('blue') : getTeamColorSet(reviewerColor ?? ''); const reviewerBadgeStyle = { @@ -677,7 +679,7 @@ export const TaskDetailDialog = ({ }; const lastReviewEvent = currentTask.historyEvents ?.filter((e) => - currentTask.reviewState === 'approved' + kanbanColumn === 'approved' ? e.type === 'review_approved' : e.type === 'review_requested' || e.type === 'review_started' ) @@ -731,7 +733,7 @@ export const TaskDetailDialog = ({ {statusLabel}
)} - {currentTask.reviewState === 'needsFix' ? ( + {isTeamTaskNeedsFixActionable(currentTask) ? ( @@ -941,7 +943,9 @@ export const TaskDetailDialog = ({ {blockedByIds.map((id) => { const depTask = taskMap.get(id); - const isCompleted = depTask?.status === 'completed'; + const isCompleted = depTask + ? isTeamTaskFinishedForDependency(depTask) + : false; const label = depTask ? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}` : `#${deriveTaskDisplayId(id)}`; @@ -977,7 +981,9 @@ export const TaskDetailDialog = ({ {blocksIds.map((id) => { const depTask = taskMap.get(id); - const isCompleted = depTask?.status === 'completed'; + const isCompleted = depTask + ? isTeamTaskFinishedForDependency(depTask) + : false; const label = depTask ? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}` : `#${deriveTaskDisplayId(id)}`; diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index 21e1487c..eb55d9b6 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -9,6 +9,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui import { useResizableColumns } from '@renderer/hooks/useResizableColumns'; import { cn } from '@renderer/lib/utils'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { isTeamTaskNeedsFixActionable } from '@shared/utils/teamTaskState'; import { CheckCircle2, ChevronDown, @@ -161,7 +162,7 @@ function estimateGridSkeletonCardHeight( if (task.subject.length > 54) height += 10; if (task.subject.length > 92) height += 8; if (task.needsClarification) height += 16; - if (task.reviewState === 'needsFix') height += 14; + if (isTeamTaskNeedsFixActionable(task)) height += 14; if ((task.blockedBy?.length ?? 0) > 0) height += 18; if ((task.blocks?.length ?? 0) > 0) height += 18; diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index 608290e3..0377c1d0 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -13,6 +13,10 @@ import { buildTaskChangeRequestOptions, canDisplayTaskChangesForOptions, } from '@renderer/utils/taskChangeRequest'; +import { + isTeamTaskFinishedForDependency, + isTeamTaskNeedsFixActionable, +} from '@shared/utils/teamTaskState'; import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { ArrowLeftFromLine, @@ -65,7 +69,7 @@ const DependencyBadge = ({ onScrollToTask, }: DependencyBadgeProps): React.JSX.Element => { const depTask = taskMap.get(taskId); - const isCompleted = depTask?.status === 'completed'; + const isCompleted = depTask ? isTeamTaskFinishedForDependency(depTask) : false; const label = depTask ? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}` : `#${deriveTaskDisplayId(taskId)}`; @@ -334,7 +338,7 @@ export const KanbanTaskCard = memo( {task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'} ) : null} - {task.reviewState === 'needsFix' ? ( + {isTeamTaskNeedsFixActionable(task) ? ( diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 60ed3d0b..7008575b 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -163,8 +163,15 @@ export const MemberCard = memo(function MemberCard({ selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : [] ); const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]); + const presentationMember = + member.currentTaskId && !currentTask + ? { + ...member, + currentTaskId: null, + } + : member; const launchPresentation = buildMemberLaunchPresentation({ - member, + member: presentationMember, spawnStatus, spawnLaunchState, spawnLivenessSource, diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index 92458c37..0df100a5 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -18,7 +18,9 @@ import { getRuntimeMemorySourceLabel, resolveMemberRuntimeSummary, } from '@renderer/utils/memberRuntimeSummary'; +import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState'; import { isLeadMember } from '@shared/utils/leadDetection'; +import { isTeamTaskFinishedForDependency } from '@shared/utils/teamTaskState'; import { BarChart3, FileText, @@ -155,12 +157,22 @@ export const MemberDetailDialog = ({ }, [member, memberMessages, members, tasks, teamName]); const inProgressTasks = useMemo( - () => memberTasks.filter((t) => t.status === 'in_progress').length, + () => memberTasks.filter(isDisplayableCurrentTask).length, [memberTasks] ); + const currentTaskCandidate = useMemo( + () => + member?.currentTaskId + ? (tasks.find((task) => task.id === member.currentTaskId) ?? null) + : null, + [member?.currentTaskId, tasks] + ); + const displayableCurrentTask = isDisplayableCurrentTask(currentTaskCandidate) + ? currentTaskCandidate + : null; const completedTasks = useMemo( - () => memberTasks.filter((t) => t.status === 'completed').length, + () => memberTasks.filter(isTeamTaskFinishedForDependency).length, [memberTasks] ); @@ -255,7 +267,11 @@ export const MemberDetailDialog = ({
t.id === member.currentTaskId) ?? null) + : null; + const currentTask = isDisplayableCurrentTask(currentTaskCandidate) ? currentTaskCandidate : null; + const presentationMember = + member.currentTaskId && !currentTask + ? { + ...member, + currentTaskId: null, + } + : member; const launchPresentation = buildMemberLaunchPresentation({ - member, + member: presentationMember, spawnStatus: spawnEntry?.status, spawnLaunchState: spawnEntry?.launchState, spawnLivenessSource: spawnEntry?.livenessSource, @@ -171,15 +184,12 @@ export const MemberHoverCard = memo(function MemberHoverCard({ const showCopyDiagnostics = hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) && hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload); - const currentTask: TeamTaskWithKanban | null = member.currentTaskId - ? (tasks.find((t) => t.id === member.currentTaskId) ?? null) - : null; const reviewTask: TeamTaskWithKanban | null = tasks ? (tasks.find( (task) => task.reviewer === member.name && - task.id !== member.currentTaskId && - (task.reviewState === 'review' || task.kanbanColumn === 'review') + task.id !== currentTask?.id && + getTeamTaskWorkflowColumn(task) === 'review' ) ?? null) : null; diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index 95fc1934..334d8c2c 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -2,7 +2,9 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary'; +import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState'; import { isLeadMember } from '@shared/utils/leadDetection'; +import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState'; import { MemberCard } from './MemberCard'; @@ -425,7 +427,7 @@ export const MemberList = memo(function MemberList({ const result = new Map(); if (!taskMap) return result; for (const task of taskMap.values()) { - if (task.reviewer && (task.reviewState === 'review' || task.kanbanColumn === 'review')) { + if (task.reviewer && getTeamTaskWorkflowColumn(task) === 'review') { result.set(task.reviewer, task); } } @@ -455,11 +457,14 @@ export const MemberList = memo(function MemberList({
{activeMembers.map((member) => { - const currentTask = + const currentTaskCandidate = member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null; + const currentTask = isDisplayableCurrentTask(currentTaskCandidate) + ? currentTaskCandidate + : null; const reviewCandidate = reviewTaskByMember.get(member.name) ?? null; const reviewTask = - reviewCandidate && reviewCandidate.id !== member.currentTaskId ? reviewCandidate : null; + reviewCandidate && reviewCandidate.id !== currentTask?.id ? reviewCandidate : null; const spawnEntry = memberSpawnStatuses?.get(member.name); const runtimeEntry = memberRuntimeEntries?.get(member.name); return ( diff --git a/src/renderer/components/team/members/MemberTasksTab.tsx b/src/renderer/components/team/members/MemberTasksTab.tsx index f3150d81..1ef96423 100644 --- a/src/renderer/components/team/members/MemberTasksTab.tsx +++ b/src/renderer/components/team/members/MemberTasksTab.tsx @@ -7,7 +7,10 @@ import { TASK_STATUS_LABELS, TASK_STATUS_STYLES, } from '@renderer/utils/memberHelpers'; -import { getTaskKanbanColumn } from '@shared/utils/reviewState'; +import { + getTeamTaskWorkflowColumn, + isTeamTaskNeedsFixActionable, +} from '@shared/utils/teamTaskState'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import type { TeamTaskWithKanban } from '@shared/types'; @@ -44,7 +47,7 @@ export const MemberTasksTab = ({ tasks, onTaskClick }: MemberTasksTabProps): Rea
{visibleTasks.map((task) => { - const col = getTaskKanbanColumn(task); + const col = getTeamTaskWorkflowColumn(task); const style = col && KANBAN_COLUMN_DISPLAY[col] ? { bg: KANBAN_COLUMN_DISPLAY[col].bg, text: KANBAN_COLUMN_DISPLAY[col].text } @@ -71,7 +74,7 @@ export const MemberTasksTab = ({ tasks, onTaskClick }: MemberTasksTabProps): Rea > {label} - {task.reviewState === 'needsFix' ? ( + {isTeamTaskNeedsFixActionable(task) ? ( diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 8a3e3841..4d0ad8ce 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -23,6 +23,7 @@ import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics'; import { isLeadMember } from '@shared/utils/leadDetection'; +import { isTaskStallRemediationMessage } from '@shared/utils/teamAutomationMessages'; import { CheckCheck, ChevronsDownUp, @@ -587,6 +588,7 @@ export const MessagesPanel = memo(function MessagesPanel({ const activityTimelineMessages = useMemo(() => { return filterTeamMessages(effectiveMessages, { + includeAutomationEvents: true, includePassiveIdlePeerSummariesWhenNoiseHidden: true, leadNames, timeWindow, @@ -600,6 +602,7 @@ export const MessagesPanel = memo(function MessagesPanel({ effectiveMessages.filter( (m) => m.messageKind !== 'task_comment_notification' && + !isTaskStallRemediationMessage(m) && !shouldExcludeInboxTextFromReplyCandidates(typeof m.text === 'string' ? m.text : '') ), [effectiveMessages] diff --git a/src/renderer/components/team/messages/StatusBlock.tsx b/src/renderer/components/team/messages/StatusBlock.tsx index dc272180..116a0dee 100644 --- a/src/renderer/components/team/messages/StatusBlock.tsx +++ b/src/renderer/components/team/messages/StatusBlock.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { computePendingCrossTeamReplies } from '@renderer/utils/crossTeamPendingReplies'; +import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState'; import { ChevronRight } from 'lucide-react'; import { ActiveTasksBlock } from '../activity/ActiveTasksBlock'; @@ -55,8 +56,7 @@ export const StatusBlock = ({ return members.some((m) => { if (!m.currentTaskId) return false; const task = tMap.get(m.currentTaskId); - if (task && (task.reviewState === 'approved' || task.status === 'completed')) return false; - return true; + return isDisplayableCurrentTask(task); }); }, [members, tasks]); diff --git a/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx b/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx index 96dcda35..d12948e6 100644 --- a/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx +++ b/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; import { isTaskLogActivityChangeEvent } from '@renderer/utils/teamChangeEvents'; +import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState'; import { ExecutionSessionsSection } from './ExecutionSessionsSection'; import { isBoardTaskActivityUiEnabled, isBoardTaskExactLogsUiEnabled } from './featureGates'; @@ -66,7 +67,8 @@ export const TaskLogsPanel = ({ const countReloadTimerRef = useRef | null>(null); const countRequestSeqRef = useRef(0); const hasTaskLogStream = availableTabs.includes('stream'); - const taskLogActivityTrackingEnabled = task.status === 'in_progress' && hasTaskLogStream; + const taskIsActivelyWorked = isDisplayableCurrentTask(task); + const taskLogActivityTrackingEnabled = taskIsActivelyWorked && hasTaskLogStream; const taskLogSummaryEnabled = hasOpenedContent && hasTaskLogStream; useEffect(() => { @@ -261,7 +263,7 @@ export const TaskLogsPanel = ({ teamName={teamName} taskId={task.id} taskStatus={task.status} - liveEnabled={isOpen && task.status === 'in_progress'} + liveEnabled={isOpen && taskIsActivelyWorked} /> ) : null} diff --git a/src/renderer/components/team/tasks/TaskRow.tsx b/src/renderer/components/team/tasks/TaskRow.tsx index 5e188cda..f2c067e8 100644 --- a/src/renderer/components/team/tasks/TaskRow.tsx +++ b/src/renderer/components/team/tasks/TaskRow.tsx @@ -5,7 +5,10 @@ import { REVIEW_STATE_DISPLAY, TASK_STATUS_LABELS, } from '@renderer/utils/memberHelpers'; -import { getTaskKanbanColumn } from '@shared/utils/reviewState'; +import { + getTeamTaskWorkflowColumn, + isTeamTaskNeedsFixActionable, +} from '@shared/utils/teamTaskState'; import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import type { TeamTaskWithKanban } from '@shared/types'; @@ -17,7 +20,7 @@ interface TaskRowProps { export const TaskRow = memo(function TaskRow({ task }: TaskRowProps): React.JSX.Element { const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? []; const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? []; - const kanbanColumn = getTaskKanbanColumn(task); + const kanbanColumn = getTeamTaskWorkflowColumn(task); return ( @@ -35,7 +38,7 @@ export const TaskRow = memo(function TaskRow({ task }: TaskRowProps): React.JSX. ? KANBAN_COLUMN_DISPLAY[kanbanColumn].label : (TASK_STATUS_LABELS[task.status] ?? task.status)} - {task.reviewState === 'needsFix' ? ( + {isTeamTaskNeedsFixActionable(task) ? ( diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 01b87cbc..8d7fa9d9 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -13,6 +13,7 @@ import { canDisplayTaskChangesForOptions, } from '@renderer/utils/taskChangeRequest'; import { isTaskLogActivityChangeEvent } from '@renderer/utils/teamChangeEvents'; +import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState'; import { createLogger } from '@shared/utils/logger'; import { isVersionOlder, normalizeVersion } from '@shared/utils/version'; import { create } from 'zustand'; @@ -736,7 +737,7 @@ export function initializeNotificationListeners(): () => void { } const candidateTasks = teamData.tasks.filter((task) => { - if (task.status !== 'in_progress') { + if (!isDisplayableCurrentTask(task)) { return false; } return canDisplayTaskChangesForOptions(buildTaskChangeRequestOptions(task)); @@ -770,7 +771,7 @@ export function initializeNotificationListeners(): () => void { } const currentTask = currentTeamData.tasks.find((task) => task.id === nextTask.id); - if (currentTask?.status !== 'in_progress') { + if (!isDisplayableCurrentTask(currentTask)) { continue; } diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 79e405f8..d5a19810 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -13,12 +13,15 @@ import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; -import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; -import { getTaskKanbanColumn } from '@shared/utils/reviewState'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout'; import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId'; +import { + getTeamTaskWorkflowColumn, + isTeamTaskFinalForCompletionNotification, + isTeamTaskNeedsFixActionable, +} from '@shared/utils/teamTaskState'; import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics'; import { getWorktreeNavigationState } from '../utils/stateResetHelpers'; @@ -1387,11 +1390,12 @@ function detectStatusChangeNotifications( if (!oldTask) continue; // Detect kanbanColumn change to 'approved' (status stays 'completed', column changes) - const taskKanbanColumn = getTaskKanbanColumn(task); - const oldTaskKanbanColumn = getTaskKanbanColumn(oldTask); + const taskKanbanColumn = getTeamTaskWorkflowColumn(task); + const oldTaskKanbanColumn = getTeamTaskWorkflowColumn(oldTask); const becameApproved = taskKanbanColumn === 'approved' && oldTaskKanbanColumn !== 'approved'; const becameReview = taskKanbanColumn === 'review' && oldTaskKanbanColumn !== 'review'; - const becameNeedsFix = task.reviewState === 'needsFix' && oldTask.reviewState !== 'needsFix'; + const becameNeedsFix = + isTeamTaskNeedsFixActionable(task) && !isTeamTaskNeedsFixActionable(oldTask); const statusChanged = oldTask.status !== task.status; if (!statusChanged && !becameApproved && !becameReview && !becameNeedsFix) continue; @@ -1686,7 +1690,7 @@ function detectAllTasksCompletedNotification( for (const [teamName, tasks] of teamTasks) { if (tasks.length === 0) continue; - const allCompleted = tasks.every((t) => t.status === 'completed' || t.status === 'deleted'); + const allCompleted = tasks.every(isTeamTaskFinalForCompletionNotification); if (!allCompleted) { // Reset so we can notify again if tasks become all-completed later notifiedAllCompletedTeams.delete(teamName); @@ -1697,8 +1701,7 @@ function detectAllTasksCompletedNotification( // Check that at least one task was NOT completed before (real transition) const oldTeamTasks = oldTasks.filter((t) => t.teamName === teamName); const wasAlreadyAllCompleted = - oldTeamTasks.length > 0 && - oldTeamTasks.every((t) => t.status === 'completed' || t.status === 'deleted'); + oldTeamTasks.length > 0 && oldTeamTasks.every(isTeamTaskFinalForCompletionNotification); if (wasAlreadyAllCompleted) { notifiedAllCompletedTeams.add(teamName); continue; @@ -3045,13 +3048,13 @@ export const createTeamSlice: StateCreator = (set, ); } notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:${task.status}`); - if (task.reviewState === 'needsFix') { + if (isTeamTaskNeedsFixActionable(task)) { notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:needsFix`); } - if (getTaskKanbanColumn(task) === 'approved') { + if (getTeamTaskWorkflowColumn(task) === 'approved') { notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:approved`); } - if (getTaskKanbanColumn(task) === 'review') { + if (getTeamTaskWorkflowColumn(task) === 'review') { notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:review`); } // Seed comment keys to prevent false notifications @@ -3069,7 +3072,7 @@ export const createTeamSlice: StateCreator = (set, teamTasksMap.set(task.teamName, list); } for (const [teamName, teamTasks] of teamTasksMap) { - if (teamTasks.every((t) => t.status === 'completed' || t.status === 'deleted')) { + if (teamTasks.every(isTeamTaskFinalForCompletionNotification)) { notifiedAllCompletedTeams.add(teamName); } } diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index 70af94e2..4c43e506 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -332,7 +332,8 @@ function isOpenCodeRuntimeDeliveryAdvisoryMessage(message: string | undefined): displayMessage.startsWith('OpenCode returned an empty assistant turn') || displayMessage.startsWith('OpenCode accepted the prompt') || displayMessage.startsWith('OpenCode responded, but did not create') || - displayMessage.startsWith('OpenCode created a reply without') + displayMessage.startsWith('OpenCode created a reply without') || + displayMessage.startsWith('OpenCode used tools, but did not create') ); } @@ -360,6 +361,9 @@ function formatRuntimeAdvisoryDisplayMessage(message: string | undefined): strin ) { return 'OpenCode created a reply without the required relayOfMessageId correlation.'; } + if (trimmed === 'non_visible_tool_without_task_progress') { + return 'OpenCode used tools, but did not create a visible reply or task progress proof.'; + } if ( trimmed.startsWith( 'OpenCode bootstrap MCP did not complete required tools before assistant response:' @@ -389,6 +393,8 @@ function formatRuntimeAdvisoryBaseLabel( return 'Network error'; case 'provider_overloaded': return providerLabel ? `${providerLabel} overload` : 'Provider overload'; + case 'protocol_proof_missing': + return providerId === 'opencode' ? 'OpenCode proof missing' : 'Protocol proof missing'; case 'backend_error': case 'unknown': if ( @@ -416,6 +422,8 @@ function formatRuntimeAdvisoryBaseLabel( return 'Network retry'; case 'provider_overloaded': return providerLabel ? `${providerLabel} overload retry` : 'Provider overload retry'; + case 'protocol_proof_missing': + return providerId === 'opencode' ? 'OpenCode proof missing' : 'Protocol proof missing'; case 'backend_error': case 'unknown': return 'Provider retry'; @@ -458,6 +466,13 @@ function formatRuntimeAdvisoryTitle( 'Provider is temporarily overloaded.', advisory.message ); + case 'protocol_proof_missing': + return appendRuntimeAdvisoryRawMessage( + providerId === 'opencode' + ? 'OpenCode delivery completed without required visible/progress proof.' + : 'Runtime delivery completed without required protocol proof.', + advisory.message + ); case 'backend_error': case 'unknown': if ( @@ -509,6 +524,13 @@ function formatRuntimeAdvisoryTitle( 'Provider is temporarily overloaded. SDK is retrying automatically.', advisory.message ); + case 'protocol_proof_missing': + return appendRuntimeAdvisoryRawMessage( + providerId === 'opencode' + ? 'OpenCode delivery is waiting for required visible/progress proof.' + : 'Runtime delivery is waiting for required protocol proof.', + advisory.message + ); case 'backend_error': case 'unknown': return appendRuntimeAdvisoryRawMessage( @@ -565,6 +587,9 @@ export function getMemberRuntimeAdvisoryTone( if (!advisory) { return null; } + if (advisory.reasonCode === 'protocol_proof_missing') { + return 'warning'; + } return advisory.kind === 'api_error' ? 'error' : 'warning'; } diff --git a/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts b/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts index 37dd17b3..1482a527 100644 --- a/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts +++ b/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts @@ -46,6 +46,9 @@ function formatOpenCodeRuntimeDeliveryFailureReason(reason: string | null | unde ) { return 'OpenCode created a reply without the required relayOfMessageId correlation.'; } + if (normalized === 'non_visible_tool_without_task_progress') { + return 'OpenCode used tools, but did not create a visible reply or task progress proof.'; + } return ''; } diff --git a/src/renderer/utils/pathNormalize.ts b/src/renderer/utils/pathNormalize.ts index ba49d75c..5144f6b1 100644 --- a/src/renderer/utils/pathNormalize.ts +++ b/src/renderer/utils/pathNormalize.ts @@ -1,3 +1,10 @@ +import { + getTeamTaskWorkflowColumn, + isTeamTaskDeleted, + isTeamTaskFinishedForDependency, + isTeamTaskNeedsFixActionable, +} from '@shared/utils/teamTaskState'; + import type { GlobalTask } from '@shared/types'; export function normalizePath(p: string): string { @@ -15,10 +22,29 @@ export interface TaskStatusCounts { completed: number; } -function incrementStatus(counts: TaskStatusCounts, status: string): TaskStatusCounts { - if (status === 'pending') return { ...counts, pending: counts.pending + 1 }; - if (status === 'in_progress') return { ...counts, inProgress: counts.inProgress + 1 }; - if (status === 'completed') return { ...counts, completed: counts.completed + 1 }; +function incrementTaskStatus( + counts: TaskStatusCounts, + task: { + status: string; + reviewState?: string | null; + kanbanColumn?: string | null; + deletedAt?: string | null; + } +): TaskStatusCounts { + if (isTeamTaskDeleted(task)) return counts; + if (getTeamTaskWorkflowColumn(task) === 'approved') { + return { ...counts, completed: counts.completed + 1 }; + } + if (isTeamTaskNeedsFixActionable(task)) { + return task.status === 'in_progress' + ? { ...counts, inProgress: counts.inProgress + 1 } + : { ...counts, pending: counts.pending + 1 }; + } + if (task.status === 'pending') return { ...counts, pending: counts.pending + 1 }; + if (isTeamTaskFinishedForDependency(task)) { + return { ...counts, completed: counts.completed + 1 }; + } + if (task.status === 'in_progress') return { ...counts, inProgress: counts.inProgress + 1 }; return counts; } @@ -29,7 +55,7 @@ export function buildTaskCountsByProject(tasks: GlobalTask[]): Map task status counts (ignores deleted). */ export function buildTaskCountsByOwner( - tasks: { owner?: string | null; status: string }[] + tasks: { + owner?: string | null; + status: string; + reviewState?: string | null; + kanbanColumn?: string | null; + deletedAt?: string | null; + }[] ): Map { const map = new Map(); for (const task of tasks) { const owner = task.owner?.trim(); - if (!owner || task.status === 'deleted') continue; + if (!owner || isTeamTaskDeleted(task)) continue; const key = owner.toLowerCase(); const counts = map.get(key) ?? { pending: 0, inProgress: 0, completed: 0 }; - map.set(key, incrementStatus(counts, task.status)); + map.set(key, incrementTaskStatus(counts, task)); } return map; } diff --git a/src/renderer/utils/teamMessageFiltering.ts b/src/renderer/utils/teamMessageFiltering.ts index 91568dc5..589accc7 100644 --- a/src/renderer/utils/teamMessageFiltering.ts +++ b/src/renderer/utils/teamMessageFiltering.ts @@ -4,6 +4,7 @@ import { } from '@renderer/utils/bootstrapPromptSanitizer'; import { shouldKeepIdleMessageInActivityWhenNoiseHidden } from '@renderer/utils/idleNotificationSemantics'; import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; +import { isTaskStallRemediationMessage } from '@shared/utils/teamAutomationMessages'; import { isTeamInternalControlMessageEnvelope } from '@shared/utils/teamInternalControlMessages'; import type { InboxMessage } from '@shared/types'; @@ -111,6 +112,7 @@ export function filterTeamMessages( messages: InboxMessage[], options: { includePassiveIdlePeerSummariesWhenNoiseHidden?: boolean; + includeAutomationEvents?: boolean; leadNames?: Iterable; timeWindow?: { start: number; end: number } | null; filter: TeamMessagesFilter; @@ -119,6 +121,7 @@ export function filterTeamMessages( ): InboxMessage[] { const { includePassiveIdlePeerSummariesWhenNoiseHidden = false, + includeAutomationEvents = false, leadNames: rawLeadNames, timeWindow, filter, @@ -127,7 +130,10 @@ export function filterTeamMessages( const leadNames = normalizeLeadNames(rawLeadNames); let list = messages.filter( - (m) => m.messageKind !== 'task_comment_notification' && !isTeamInternalControlMessageEnvelope(m) + (m) => + m.messageKind !== 'task_comment_notification' && + (includeAutomationEvents || !isTaskStallRemediationMessage(m)) && + !isTeamInternalControlMessageEnvelope(m) ); if (timeWindow) { list = list.filter((m) => { diff --git a/src/renderer/utils/teamTaskDisplayState.ts b/src/renderer/utils/teamTaskDisplayState.ts new file mode 100644 index 00000000..f5c6ddcb --- /dev/null +++ b/src/renderer/utils/teamTaskDisplayState.ts @@ -0,0 +1,9 @@ +import { isTeamTaskActivelyWorked } from '@shared/utils/teamTaskState'; + +import type { TeamTaskWithKanban } from '@shared/types'; + +export function isDisplayableCurrentTask( + task: TeamTaskWithKanban | null | undefined +): task is TeamTaskWithKanban { + return Boolean(task && isTeamTaskActivelyWorked(task)); +} diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 4d87e1a4..bec4cb53 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -434,6 +434,7 @@ export type InboxMessageKind = | 'slash_command' | 'slash_command_result' | 'task_comment_notification' + | 'task_stall_remediation' | 'member_work_sync_nudge' | 'agent_error'; @@ -828,6 +829,7 @@ export interface MemberRuntimeAdvisory { | 'codex_native_timeout' | 'network_error' | 'provider_overloaded' + | 'protocol_proof_missing' | 'backend_error' | 'unknown'; message?: string; diff --git a/src/shared/utils/reviewState.ts b/src/shared/utils/reviewState.ts index 231cdd9b..cd366553 100644 --- a/src/shared/utils/reviewState.ts +++ b/src/shared/utils/reviewState.ts @@ -14,18 +14,8 @@ export function normalizeReviewState(value: unknown): TeamReviewState { } export function getReviewStateFromTask(task: ReviewStateLike): TeamReviewState { - // Prefer derivation from historyEvents when available - if (Array.isArray(task.historyEvents) && task.historyEvents.length > 0) { - const derived = getDerivedReviewStateFromHistory({ - historyEvents: task.historyEvents as TaskHistoryEvent[], - }); - if (derived) { - return derived; - } - } - const fallbackStatus = typeof task.status === 'string' ? task.status : null; - const normalizeFallback = (value: unknown): TeamReviewState | null => { + const normalizeForStatus = (value: unknown): TeamReviewState | null => { const explicit = normalizeReviewState(value); if (explicit === 'none') return null; @@ -36,16 +26,28 @@ export function getReviewStateFromTask(task: ReviewStateLike): TeamReviewState { return explicit === 'needsFix' ? 'needsFix' : 'none'; } if (fallbackStatus === 'completed') { - return explicit === 'review' || explicit === 'approved' ? explicit : 'none'; + return explicit === 'review' || explicit === 'approved' || explicit === 'needsFix' + ? explicit + : 'none'; } return explicit; }; - const explicit = normalizeFallback(task.reviewState); + // Prefer derivation from historyEvents when available + if (Array.isArray(task.historyEvents) && task.historyEvents.length > 0) { + const derived = getDerivedReviewStateFromHistory({ + historyEvents: task.historyEvents as TaskHistoryEvent[], + }); + if (derived !== null) { + return normalizeForStatus(derived) ?? 'none'; + } + } + + const explicit = normalizeForStatus(task.reviewState); if (explicit) return explicit; if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') { - return normalizeFallback(task.kanbanColumn) ?? 'none'; + return normalizeForStatus(task.kanbanColumn) ?? 'none'; } return 'none'; diff --git a/src/shared/utils/taskChangeState.ts b/src/shared/utils/taskChangeState.ts index e0c7e4e0..30c0139c 100644 --- a/src/shared/utils/taskChangeState.ts +++ b/src/shared/utils/taskChangeState.ts @@ -1,4 +1,5 @@ import { getReviewStateFromTask } from './reviewState'; +import { getTeamTaskWorkflowColumn } from './teamTaskState'; import type { TeamReviewState } from '@shared/types'; @@ -9,6 +10,7 @@ interface TaskChangeStateLike { reviewState?: TeamReviewState | null; historyEvents?: unknown[]; kanbanColumn?: 'review' | 'approved' | null; + deletedAt?: string | null; } function getEffectiveReviewState(task: TaskChangeStateLike): TeamReviewState { @@ -17,8 +19,15 @@ function getEffectiveReviewState(task: TaskChangeStateLike): TeamReviewState { export function getTaskChangeStateBucket(task: TaskChangeStateLike): TaskChangeStateBucket { const reviewState = getEffectiveReviewState(task); - if (reviewState === 'approved') return 'approved'; - if (reviewState === 'review') return 'review'; + const workflowColumn = getTeamTaskWorkflowColumn({ + status: task.status ?? '', + reviewState, + kanbanColumn: task.kanbanColumn, + deletedAt: task.deletedAt, + }); + if (workflowColumn === 'approved') return 'approved'; + if (workflowColumn === 'review') return 'review'; + if (reviewState === 'needsFix') return 'active'; return task.status === 'completed' ? 'completed' : 'active'; } diff --git a/src/shared/utils/teamAutomationMessages.ts b/src/shared/utils/teamAutomationMessages.ts new file mode 100644 index 00000000..9c86a94e --- /dev/null +++ b/src/shared/utils/teamAutomationMessages.ts @@ -0,0 +1,16 @@ +import type { InboxMessage } from '@shared/types'; + +type AutomationMessageLike = Pick; + +export function isTaskStallRemediationMessage(message: AutomationMessageLike): boolean { + if (message.messageKind === 'task_stall_remediation') { + return true; + } + + const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + return ( + message.source === 'system_notification' && + message.from === 'system' && + messageId.startsWith('task-stall:') + ); +} diff --git a/src/shared/utils/teamTaskState.ts b/src/shared/utils/teamTaskState.ts new file mode 100644 index 00000000..961b0f1a --- /dev/null +++ b/src/shared/utils/teamTaskState.ts @@ -0,0 +1,115 @@ +export interface TeamTaskStateLike { + status: string; + reviewState?: string | null; + kanbanColumn?: string | null; + deletedAt?: string | null; +} + +export type TeamTaskWorkflowColumn = 'review' | 'approved'; + +export function isTeamTaskApproved(task: TeamTaskStateLike): boolean { + if (isTeamTaskDeleted(task) || task.status === 'pending') { + return false; + } + + if (task.kanbanColumn === 'approved') { + return true; + } + + if (task.kanbanColumn === 'review') { + return false; + } + + return task.reviewState === 'approved'; +} + +export function isTeamTaskDeleted(task: TeamTaskStateLike): boolean { + return task.status === 'deleted' || Boolean(task.deletedAt); +} + +export function isTeamTaskActivelyWorked(task: TeamTaskStateLike): boolean { + return ( + task.status === 'in_progress' && + getTeamTaskWorkflowColumn(task) !== 'review' && + !isTeamTaskApproved(task) && + !isTeamTaskDeleted(task) + ); +} + +export function isTeamTaskNeedsFixActionable(task: TeamTaskStateLike): boolean { + return ( + task.reviewState === 'needsFix' && + !isTeamTaskDeleted(task) && + getTeamTaskWorkflowColumn(task) === undefined + ); +} + +export function isTeamTaskFinishedForDependency(task: TeamTaskStateLike): boolean { + const workflowColumn = getTeamTaskWorkflowColumn(task); + if (workflowColumn === 'approved') { + return true; + } + if (workflowColumn === 'review' || isTeamTaskNeedsFixActionable(task)) { + return false; + } + return task.status === 'completed'; +} + +export function isTeamTaskTerminalForActionableWork(task: TeamTaskStateLike): boolean { + if (isTeamTaskDeleted(task)) { + return true; + } + + const workflowColumn = getTeamTaskWorkflowColumn(task); + if (workflowColumn === 'approved') { + return true; + } + + if (workflowColumn === 'review' || isTeamTaskNeedsFixActionable(task)) { + return false; + } + return task.status === 'completed'; +} + +export function isTeamTaskFinalForCompletionNotification(task: TeamTaskStateLike): boolean { + if (isTeamTaskDeleted(task)) { + return true; + } + + const workflowColumn = getTeamTaskWorkflowColumn(task); + if (workflowColumn === 'approved') { + return true; + } + + if (workflowColumn === 'review' || isTeamTaskNeedsFixActionable(task)) { + return false; + } + + return task.status === 'completed'; +} + +export function getTeamTaskWorkflowColumn( + task: TeamTaskStateLike +): TeamTaskWorkflowColumn | undefined { + if (isTeamTaskDeleted(task) || task.status === 'pending') { + return undefined; + } + + if (task.kanbanColumn === 'approved') { + return 'approved'; + } + + if (task.kanbanColumn === 'review') { + return 'review'; + } + + if (task.reviewState === 'approved') { + return 'approved'; + } + + if (task.reviewState === 'review') { + return 'review'; + } + + return undefined; +} diff --git a/test/features/member-work-sync/core/ActionableWorkAgenda.test.ts b/test/features/member-work-sync/core/ActionableWorkAgenda.test.ts index 5c269201..c81270c3 100644 --- a/test/features/member-work-sync/core/ActionableWorkAgenda.test.ts +++ b/test/features/member-work-sync/core/ActionableWorkAgenda.test.ts @@ -43,6 +43,177 @@ describe('buildActionableWorkAgenda', () => { ]); }); + it('does not keep stale terminal task state in the work agenda', () => { + const agenda = buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'jack', + generatedAt: '2026-05-06T19:06:07.257Z', + members: [{ name: 'jack' }], + tasks: [ + { + id: 'task-completed', + displayId: '#6d4db591', + subject: 'Completed after stale work-sync status', + status: 'completed', + owner: 'jack', + }, + { + id: 'task-deleted', + subject: 'Deleted after stale work-sync status', + status: 'in_progress', + owner: 'jack', + deletedAt: '2026-05-06T19:06:07.257Z', + }, + { + id: 'task-review-approved', + subject: 'Approved review after stale work-sync status', + status: 'in_progress', + owner: 'jack', + reviewState: 'approved', + }, + { + id: 'task-kanban-approved', + subject: 'Approved kanban after stale work-sync status', + status: 'in_progress', + owner: 'jack', + kanbanColumn: 'approved', + }, + { + id: 'task-stale-needsfix-approved', + subject: 'Approved task after stale needsFix status', + status: 'in_progress', + owner: 'jack', + reviewState: 'needsFix', + kanbanColumn: 'approved', + }, + ], + hash, + }); + + expect(agenda.items).toEqual([]); + }); + + it('projects reopened in-progress work after a previous completion', () => { + const agenda = buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'jack', + generatedAt: '2026-05-06T18:56:19.173Z', + members: [{ name: 'jack' }], + tasks: [ + { + id: 'task-reopened', + displayId: '#6d4db591', + subject: 'Reopened work', + status: 'in_progress', + owner: 'jack', + historyEvents: [ + { + id: 'evt-completed', + type: 'status_changed', + timestamp: '2026-05-06T18:50:05.662Z', + from: 'in_progress', + to: 'completed', + }, + { + id: 'evt-reopened', + type: 'status_changed', + timestamp: '2026-05-06T18:56:19.173Z', + from: 'completed', + to: 'in_progress', + }, + ], + }, + ], + hash, + }); + + expect(agenda.items.map((item) => [item.taskId, item.reason])).toEqual([ + ['task-reopened', 'owned_in_progress_task'], + ]); + }); + + it('does not treat approved dependencies as waiting blockers', () => { + const agenda = buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'jack', + generatedAt: '2026-05-06T19:06:07.257Z', + members: [{ name: 'jack' }], + tasks: [ + { + id: 'task-approved', + subject: 'Approved dependency', + status: 'in_progress', + owner: 'alice', + kanbanColumn: 'approved', + }, + { + id: 'task-dependent', + subject: 'Depends on approved task', + status: 'in_progress', + owner: 'jack', + blockedBy: ['task-approved'], + }, + ], + hash, + }); + + expect(agenda.items.map((item) => [item.taskId, item.reason])).toEqual([ + ['task-dependent', 'owned_in_progress_task'], + ]); + }); + + it('keeps dependencies blocked while completed work is still in review', () => { + const agenda = buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'jack', + generatedAt: '2026-05-06T19:06:07.257Z', + members: [{ name: 'jack' }, { name: 'alice' }], + tasks: [ + { + id: 'task-review', + subject: 'Dependency waiting for review', + status: 'completed', + owner: 'alice', + reviewState: 'review', + kanbanColumn: 'review', + }, + { + id: 'task-dependent', + subject: 'Depends on reviewed task', + status: 'in_progress', + owner: 'jack', + blockedBy: ['task-review'], + }, + ], + hash, + }); + + expect(agenda.items).toEqual([]); + }); + + it('does not let stale kanban approved hide a reopened pending task', () => { + const agenda = buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'jack', + generatedAt: '2026-05-06T19:06:07.257Z', + members: [{ name: 'jack' }], + tasks: [ + { + id: 'task-reopened-pending', + subject: 'Reopened pending work', + status: 'pending', + owner: 'jack', + kanbanColumn: 'approved', + }, + ], + hash, + }); + + expect(agenda.items.map((item) => [item.taskId, item.reason])).toEqual([ + ['task-reopened-pending', 'owned_pending_task'], + ]); + }); + it('assigns active review work to the current-cycle reviewer only', () => { const agenda = buildActionableWorkAgenda({ teamName: 'team-a', @@ -78,6 +249,98 @@ describe('buildActionableWorkAgenda', () => { }); }); + it('keeps completed tasks actionable for the current reviewer while workflow is review', () => { + const agenda = buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'alice', + generatedAt: '2026-04-29T00:00:00.000Z', + members: [{ name: 'alice' }, { name: 'bob' }], + tasks: [ + { + id: 'task-review', + subject: 'Review completed work', + status: 'completed', + owner: 'bob', + reviewState: 'review', + historyEvents: [ + { + id: 'evt-review', + type: 'review_requested', + timestamp: '2026-04-29T00:00:00.000Z', + reviewer: 'alice', + }, + ], + }, + ], + hash, + }); + + expect(agenda.items).toHaveLength(1); + expect(agenda.items[0]).toMatchObject({ + taskId: 'task-review', + kind: 'review', + assignee: 'alice', + }); + }); + + it('does not assign owner work while stale in-progress task is in review workflow', () => { + const ownerAgenda = buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'bob', + generatedAt: '2026-04-29T00:00:00.000Z', + members: [{ name: 'alice' }, { name: 'bob' }], + tasks: [ + { + id: 'task-review', + subject: 'Review in progress status', + status: 'in_progress', + owner: 'bob', + reviewState: 'none', + kanbanColumn: 'review', + historyEvents: [ + { + id: 'evt-review', + type: 'review_requested', + timestamp: '2026-04-29T00:00:00.000Z', + reviewer: 'alice', + }, + ], + }, + ], + hash, + }); + const reviewerAgenda = buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'alice', + generatedAt: '2026-04-29T00:00:00.000Z', + members: [{ name: 'alice' }, { name: 'bob' }], + tasks: [ + { + id: 'task-review', + subject: 'Review in progress status', + status: 'in_progress', + owner: 'bob', + reviewState: 'none', + kanbanColumn: 'review', + historyEvents: [ + { + id: 'evt-review', + type: 'review_requested', + timestamp: '2026-04-29T00:00:00.000Z', + reviewer: 'alice', + }, + ], + }, + ], + hash, + }); + + expect(ownerAgenda.items).toEqual([]); + expect(reviewerAgenda.items.map((item) => [item.taskId, item.kind, item.reason])).toEqual([ + ['task-review', 'review', 'current_cycle_review_assigned'], + ]); + }); + it('does not resurrect a stale reviewer after review was approved', () => { const agenda = buildActionableWorkAgenda({ teamName: 'team-a', @@ -257,12 +520,20 @@ describe('buildActionableWorkAgenda', () => { owner: 'bob', reviewState: 'needsFix', }, + { + id: 'task-2', + subject: 'Fix completed review', + status: 'completed', + owner: 'bob', + reviewState: 'needsFix', + }, ], hash, }); expect(agenda.items.map((item) => [item.taskId, item.kind, item.reason])).toEqual([ ['task-1', 'work', 'review_changes_requested'], + ['task-2', 'work', 'review_changes_requested'], ]); }); diff --git a/test/features/member-work-sync/core/SyncDecisionPolicy.test.ts b/test/features/member-work-sync/core/SyncDecisionPolicy.test.ts new file mode 100644 index 00000000..81293c5a --- /dev/null +++ b/test/features/member-work-sync/core/SyncDecisionPolicy.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { decideMemberWorkSyncStatus } from '@features/member-work-sync/core/domain'; + +import type { MemberWorkSyncAgenda, MemberWorkSyncReport } from '@features/member-work-sync/contracts'; + +describe('decideMemberWorkSyncStatus', () => { + it('returns caught_up when canonical filtering leaves no actionable work', () => { + const agenda: MemberWorkSyncAgenda = { + teamName: 'forge-labs', + memberName: 'jack', + generatedAt: '2026-05-06T19:06:07.257Z', + fingerprint: 'agenda-empty', + items: [], + diagnostics: [], + }; + const staleReport: MemberWorkSyncReport = { + teamName: 'forge-labs', + memberName: 'jack', + state: 'still_working', + agendaFingerprint: 'stale-owned-in-progress-task', + reportedAt: '2026-05-06T19:00:26.089Z', + accepted: true, + }; + + const decision = decideMemberWorkSyncStatus({ + agenda, + latestAcceptedReport: staleReport, + nowIso: '2026-05-06T19:06:07.257Z', + }); + + expect(decision.state).toBe('caught_up'); + expect(decision.acceptedReport).toBeUndefined(); + expect(decision.diagnostics).toContain('agenda_empty'); + }); +}); diff --git a/test/features/member-work-sync/main/MemberWorkSyncTaskImpactResolver.test.ts b/test/features/member-work-sync/main/MemberWorkSyncTaskImpactResolver.test.ts index 7fe02e87..7ecc1179 100644 --- a/test/features/member-work-sync/main/MemberWorkSyncTaskImpactResolver.test.ts +++ b/test/features/member-work-sync/main/MemberWorkSyncTaskImpactResolver.test.ts @@ -129,4 +129,90 @@ describe('MemberWorkSyncTaskImpactResolver', () => { diagnostics: [], }); }); + + it('does not target owners of already approved dependent tasks', async () => { + const tasks: TeamTask[] = [ + { + id: 'task-a', + subject: 'Changed dependency', + status: 'completed', + owner: 'alice', + }, + { + id: 'task-approved-dependent', + subject: 'Already approved dependent', + status: 'in_progress', + owner: 'tom', + blockedBy: ['task-a'], + }, + ]; + const resolver = new MemberWorkSyncTaskImpactResolver({ + taskReader: { getTasks: vi.fn(async () => tasks) }, + kanbanManager: { + getState: vi.fn(async () => ({ + tasks: { + 'task-approved-dependent': { + column: 'approved', + movedAt: '2026-05-06T19:06:07.257Z', + }, + }, + })), + }, + activeMemberSource: { + loadActiveMemberNames: vi.fn(async () => ['alice', 'team-lead', 'tom']), + }, + } as never); + + await expect(resolver.resolve({ teamName: 'team-a', taskId: 'task-a' })).resolves.toEqual({ + memberNames: ['alice'], + fallbackTeamWide: false, + diagnostics: [], + }); + }); + + it('does not treat stale review state as reviewer-missing when kanban says approved', async () => { + const tasks: TeamTask[] = [ + { + id: 'task-approved', + subject: 'Approved after review', + status: 'in_progress', + owner: 'alice', + reviewState: 'review', + historyEvents: [ + { + id: 'evt-review', + type: 'review_requested', + timestamp: '2026-05-06T19:00:00.000Z', + from: 'none', + to: 'review', + reviewer: 'bob', + }, + ], + }, + ]; + const resolver = new MemberWorkSyncTaskImpactResolver({ + taskReader: { getTasks: vi.fn(async () => tasks) }, + kanbanManager: { + getState: vi.fn(async () => ({ + tasks: { + 'task-approved': { + column: 'approved', + movedAt: '2026-05-06T19:06:07.257Z', + }, + }, + })), + }, + activeMemberSource: { + loadActiveMemberNames: vi.fn(async () => ['alice', 'bob', 'team-lead']), + }, + } as never); + + await expect( + resolver.resolve({ teamName: 'team-a', taskId: 'task-approved' }) + ).resolves.toEqual({ + memberNames: ['alice'], + fallbackTeamWide: false, + diagnostics: [], + }); + }); }); diff --git a/test/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.test.ts b/test/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.test.ts new file mode 100644 index 00000000..6ce94a7d --- /dev/null +++ b/test/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { TeamTaskAgendaSource } from '@features/member-work-sync/main/adapters/output/TeamTaskAgendaSource'; + +describe('TeamTaskAgendaSource', () => { + it('applies kanban approved overlay before building member work agenda', async () => { + const source = new TeamTaskAgendaSource({ + configReader: { + getConfig: vi.fn(async () => ({ + members: [{ name: 'jack', agentType: 'developer' }], + })), + }, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-approved', + displayId: '#6d4db591', + subject: 'Approved through kanban', + status: 'in_progress', + owner: 'jack', + reviewState: 'none', + }, + ]), + }, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName: 'forge-labs', + reviewers: [], + tasks: { + 'task-approved': { + column: 'approved', + movedAt: '2026-05-06T19:06:07.257Z', + }, + }, + })), + }, + membersMetaStore: { + getMembers: vi.fn(async () => []), + }, + hash: { + sha256Hex: vi.fn((value: string) => `h${value.length}`), + }, + clock: { + now: () => new Date('2026-05-06T19:06:07.257Z'), + }, + } as never); + + const result = await source.loadAgenda({ + teamName: 'forge-labs', + memberName: 'jack', + }); + + expect(result.agenda.items).toEqual([]); + }); +}); diff --git a/test/main/services/team/OpenCodeRuntimeDeliveryDiagnostics.test.ts b/test/main/services/team/OpenCodeRuntimeDeliveryDiagnostics.test.ts index 4ba27b7f..80d209a0 100644 --- a/test/main/services/team/OpenCodeRuntimeDeliveryDiagnostics.test.ts +++ b/test/main/services/team/OpenCodeRuntimeDeliveryDiagnostics.test.ts @@ -1,117 +1,47 @@ import { describe, expect, it } from 'vitest'; -import { selectOpenCodeRuntimeDeliveryReason } from '../../../../src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics'; - -import type { OpenCodePromptDeliveryLedgerRecord } from '../../../../src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger'; - -function record( - input: Partial -): OpenCodePromptDeliveryLedgerRecord { - return { - id: 'opencode-prompt:test', - teamName: 'forge-labs', - memberName: 'bob', - laneId: 'secondary:opencode:bob', - runId: 'run-1', - runtimeSessionId: 'ses-1', - inboxMessageId: 'msg-1', - inboxTimestamp: '2026-05-06T18:31:36.478Z', - source: 'watcher', - messageKind: null, - replyRecipient: 'team-lead', - actionMode: null, - taskRefs: [], - payloadHash: 'sha256:test', - status: 'failed_terminal', - responseState: 'not_observed', - attempts: 3, - maxAttempts: 3, - acceptanceUnknown: false, - nextAttemptAt: null, - lastAttemptAt: null, - lastObservedAt: null, - acceptedAt: null, - respondedAt: null, - failedAt: '2026-05-06T18:33:42.896Z', - inboxReadCommittedAt: null, - inboxReadCommitError: null, - prePromptCursor: null, - postPromptCursor: null, - deliveredUserMessageId: null, - observedAssistantMessageId: null, - observedAssistantPreview: null, - observedToolCallNames: [], - observedVisibleMessageId: null, - visibleReplyMessageId: null, - visibleReplyInbox: null, - visibleReplyCorrelation: null, - lastReason: null, - diagnostics: [], - createdAt: '2026-05-06T18:31:36.636Z', - updatedAt: '2026-05-06T18:33:42.896Z', - ...input, - }; -} +import { + isActionRequiredOpenCodeRuntimeDeliveryReason, + selectOpenCodeRuntimeDeliveryReason, +} from '../../../../src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics'; describe('OpenCodeRuntimeDeliveryDiagnostics', () => { - it('skips internal bootstrap MCP diagnostics when a provider error is available', () => { - const reason = selectOpenCodeRuntimeDeliveryReason( - record({ - responseState: 'empty_assistant_turn', - lastReason: 'empty_assistant_turn', - diagnostics: [ - 'OpenCode app MCP was reattached before message delivery.', - 'OpenCode bootstrap MCP did not complete required tools before assistant response: runtime_bootstrap_checkin, member_briefing', - 'Latest assistant message msg_1 failed with APIError - Insufficient credits. Add more credits.', - 'empty_assistant_turn', - ], - }) - ); + it('treats OpenRouter key limit errors as action-required delivery failures', () => { + const reason = + 'Key limit exceeded (total limit). Manage it using https://openrouter.ai/settings/keys'; - expect(reason).toBe('Insufficient credits. Add more credits.'); + expect(isActionRequiredOpenCodeRuntimeDeliveryReason(reason)).toBe(true); }); - it('falls back to empty assistant turn when diagnostics are only internal noise', () => { - const reason = selectOpenCodeRuntimeDeliveryReason( - record({ - responseState: 'empty_assistant_turn', - lastReason: 'empty_assistant_turn', - diagnostics: [ - 'OpenCode bridge command timed out', - 'OpenCode bootstrap MCP did not complete required tools before assistant response: runtime_bootstrap_checkin, member_briefing', - 'empty_assistant_turn', - ], - }) + it('does not treat protocol proof repair reasons as action-required provider failures', () => { + expect(isActionRequiredOpenCodeRuntimeDeliveryReason('visible_reply_still_required')).toBe( + false ); - - expect(reason).toBe('OpenCode returned an empty assistant turn.'); }); - it('maps missing visible reply proof to a readable protocol error', () => { - const reason = selectOpenCodeRuntimeDeliveryReason( - record({ - responseState: 'responded_non_visible_tool', - lastReason: 'visible_reply_still_required', - diagnostics: [ - 'OpenCode bootstrap MCP did not complete required tools before assistant response: runtime_bootstrap_checkin, member_briefing', - 'visible_reply_still_required', - ], - }) - ); + it('selects a concrete OpenCode runtime delivery diagnostic before generic fallback text', () => { + const record = { + diagnostics: [ + 'Latest assistant message for opencode session abc failed with APIError - Key limit exceeded (total limit). Manage it using https://openrouter.ai/settings/keys', + ], + lastReason: 'OpenCode runtime delivery failed', + responseState: 'session_error', + status: 'accepted', + } as Parameters[0]; - expect(reason).toBe('OpenCode responded, but did not create a visible message_send reply.'); + expect(selectOpenCodeRuntimeDeliveryReason(record)).toContain('Key limit exceeded'); }); - it('never exposes only internal generic bootstrap diagnostics as the user-facing reason', () => { - const reason = selectOpenCodeRuntimeDeliveryReason( - record({ - diagnostics: [ - 'OpenCode app MCP was reattached before message delivery.', - 'OpenCode bootstrap MCP did not complete required tools before assistant response: runtime_bootstrap_checkin, member_briefing', - ], - }) - ); + it('formats non-visible tool progress failures without exposing the internal reason code', () => { + const record = { + diagnostics: ['non_visible_tool_without_task_progress'], + lastReason: 'non_visible_tool_without_task_progress', + responseState: 'responded_non_visible_tool', + status: 'failed_terminal', + } as Parameters[0]; - expect(reason).toBe('OpenCode runtime delivery did not complete.'); + expect(selectOpenCodeRuntimeDeliveryReason(record)).toBe( + 'OpenCode used tools, but did not create a visible reply or task progress proof.' + ); }); }); diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index d36d1379..02b672ee 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -1908,6 +1908,187 @@ describe('TeamDataService', () => { }); }); + it('preserves kanban approved overlay even when task status is still in_progress', async () => { + const harness = createGetTeamDataHarness({ + config: { + name: 'My team', + members: [{ name: 'jack', role: 'developer' }], + }, + getTasks: async () => [ + { + id: 'task-approved', + subject: 'Approved but stale status', + status: 'in_progress', + owner: 'jack', + reviewState: 'none', + }, + ], + getState: async () => ({ + teamName: 'my-team', + reviewers: [], + tasks: { + 'task-approved': { + column: 'approved', + movedAt: '2026-05-06T19:06:07.257Z', + }, + }, + }), + }); + + const data = await harness.service.getTeamData('my-team'); + + expect(data.tasks[0]).toMatchObject({ + id: 'task-approved', + status: 'in_progress', + reviewState: 'approved', + kanbanColumn: 'approved', + }); + expect(harness.resolveMembersSpy).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Array), + expect.any(Array), + expect.arrayContaining([ + expect.objectContaining({ + id: 'task-approved', + kanbanColumn: 'approved', + }), + ]), + expect.any(Object) + ); + }); + + it('lets current kanban approved overlay win over stale review history', async () => { + const harness = createGetTeamDataHarness({ + config: { + name: 'My team', + members: [{ name: 'jack', role: 'developer' }], + }, + getTasks: async () => [ + { + id: 'task-approved', + subject: 'Approved after review', + status: 'in_progress', + owner: 'jack', + reviewState: 'none', + historyEvents: [ + { + id: 'review-started', + type: 'review_started', + timestamp: '2026-05-06T19:00:00.000Z', + to: 'review', + }, + ], + }, + ], + getState: async () => ({ + teamName: 'my-team', + reviewers: [], + tasks: { + 'task-approved': { + column: 'approved', + movedAt: '2026-05-06T19:06:07.257Z', + }, + }, + }), + }); + + const data = await harness.service.getTeamData('my-team'); + + expect(data.tasks[0]).toMatchObject({ + id: 'task-approved', + status: 'in_progress', + reviewState: 'approved', + kanbanColumn: 'approved', + reviewer: null, + }); + }); + + it('lets current kanban review overlay win over stale approved review state', async () => { + const harness = createGetTeamDataHarness({ + config: { + name: 'My team', + members: [{ name: 'jack', role: 'developer' }], + }, + getTasks: async () => [ + { + id: 'task-review', + subject: 'Moved back to review', + status: 'completed', + owner: 'jack', + reviewState: 'approved', + }, + ], + getState: async () => ({ + teamName: 'my-team', + reviewers: [], + tasks: { + 'task-review': { + column: 'review', + reviewer: 'carol', + movedAt: '2026-05-06T19:06:07.257Z', + }, + }, + }), + }); + + const data = await harness.service.getTeamData('my-team'); + + expect(data.tasks[0]).toMatchObject({ + id: 'task-review', + status: 'completed', + reviewState: 'review', + kanbanColumn: 'review', + reviewer: 'carol', + }); + }); + + it('does not preserve stale kanban approved overlay for reopened pending tasks', async () => { + const harness = createGetTeamDataHarness({ + config: { + name: 'My team', + members: [{ name: 'jack', role: 'developer' }], + }, + getTasks: async () => [ + { + id: 'task-reopened', + subject: 'Reopened pending task', + status: 'pending', + owner: 'jack', + reviewState: 'none', + historyEvents: [ + { + id: 'review-approved', + type: 'review_approved', + timestamp: '2026-05-06T19:00:00.000Z', + from: 'review', + to: 'approved', + actor: 'carol', + }, + ], + }, + ], + getState: async () => ({ + teamName: 'my-team', + reviewers: [], + tasks: { + 'task-reopened': { + column: 'approved', + movedAt: '2026-05-06T19:06:07.257Z', + }, + }, + }), + }); + + const data = await harness.service.getTeamData('my-team'); + + expect(data.tasks[0]).toMatchObject({ + id: 'task-reopened', + status: 'pending', + reviewState: 'none', + }); + expect(data.tasks[0]?.kanbanColumn).toBeUndefined(); + }); + it('applies kanban overlay review state in global task projections', async () => { const service = new TeamDataService( { @@ -1967,6 +2148,64 @@ describe('TeamDataService', () => { }); }); + it('lets kanban approved overlay win over stale review history in global task projections', async () => { + const service = new TeamDataService( + { + listTeams: vi.fn(async () => [ + { + teamName: 'my-team', + displayName: 'My team', + projectPath: '/repo', + }, + ]), + } as never, + { + getAllTasks: vi.fn(async () => [ + { + id: 'task-global-approved', + teamName: 'my-team', + subject: 'Global approved task', + status: 'completed', + owner: 'bob', + reviewState: 'none', + historyEvents: [ + { + id: 'evt-review', + type: 'review_started', + to: 'review', + timestamp: '2026-03-01T09:00:00.000Z', + }, + ], + }, + ]), + } as never, + {} as never, + {} as never, + {} as never, + { + getState: vi.fn(async () => ({ + teamName: 'my-team', + reviewers: [], + tasks: { + 'task-global-approved': { + column: 'approved', + reviewer: 'carol', + movedAt: '2026-03-01T10:00:00.000Z', + }, + }, + })), + } as never + ); + + const tasks = await service.getAllTasks(); + + expect(tasks[0]).toMatchObject({ + id: 'task-global-approved', + reviewState: 'approved', + kanbanColumn: 'approved', + }); + }); + it('propagates leadSessionId for kanban-driven review transitions', async () => { const requestReviewMock = vi.fn(); const approveReviewMock = vi.fn(); diff --git a/test/main/services/team/TeamMemberResolver.test.ts b/test/main/services/team/TeamMemberResolver.test.ts index 66066540..a8a0c09a 100644 --- a/test/main/services/team/TeamMemberResolver.test.ts +++ b/test/main/services/team/TeamMemberResolver.test.ts @@ -41,6 +41,86 @@ describe('TeamMemberResolver', () => { expect(lead?.agentType).toBe('team-lead'); }); + it('does not expose completed, deleted, or approved tasks as current work', () => { + const resolver = new TeamMemberResolver(); + const config: TeamConfig = { + name: 'Team', + members: [ + { name: 'team-lead', agentType: 'team-lead', role: 'lead' }, + { name: 'alice', agentType: 'general-purpose' }, + { name: 'bob', agentType: 'general-purpose' }, + { name: 'carol', agentType: 'general-purpose' }, + { name: 'dave', agentType: 'general-purpose' }, + ], + }; + const tasks: TeamTaskWithKanban[] = [ + { + id: 'task-completed', + subject: 'Done', + status: 'completed', + owner: 'alice', + }, + { + id: 'task-deleted', + subject: 'Deleted', + status: 'deleted', + owner: 'bob', + deletedAt: '2026-05-06T00:00:00.000Z', + }, + { + id: 'task-approved-review', + subject: 'Approved review', + status: 'in_progress', + owner: 'carol', + reviewState: 'approved', + }, + { + id: 'task-approved-kanban', + subject: 'Approved kanban', + status: 'in_progress', + owner: 'dave', + kanbanColumn: 'approved', + }, + { + id: 'task-review-kanban', + subject: 'Review kanban', + status: 'in_progress', + owner: 'dave', + kanbanColumn: 'review', + }, + ]; + + const members = resolver.resolveMembers(config, [], [], tasks); + + expect(members.find((member) => member.name === 'alice')?.currentTaskId).toBeNull(); + expect(members.find((member) => member.name === 'bob')?.currentTaskId).toBeNull(); + expect(members.find((member) => member.name === 'carol')?.currentTaskId).toBeNull(); + expect(members.find((member) => member.name === 'dave')?.currentTaskId).toBeNull(); + }); + + it('keeps real in-progress task as current work', () => { + const resolver = new TeamMemberResolver(); + const config: TeamConfig = { + name: 'Team', + members: [ + { name: 'team-lead', agentType: 'team-lead', role: 'lead' }, + { name: 'alice', agentType: 'general-purpose' }, + ], + }; + const tasks: TeamTaskWithKanban[] = [ + { + id: 'task-active', + subject: 'Active', + status: 'in_progress', + owner: 'alice', + }, + ]; + + const members = resolver.resolveMembers(config, [], [], tasks); + + expect(members.find((member) => member.name === 'alice')?.currentTaskId).toBe('task-active'); + }); + it('filters out "user" pseudo-member even when present in config, meta, or inboxNames', () => { const resolver = new TeamMemberResolver(); const config: TeamConfig = { diff --git a/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts b/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts index 847b0dfc..d474d23e 100644 --- a/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts +++ b/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts @@ -163,6 +163,10 @@ describe('TeamMemberRuntimeAdvisoryService', () => { 'All credentials for model claude-opus-4-6 are cooling down via provider claude.', ], ['auth_error', 'Authentication failed due to invalid API key.'], + [ + 'quota_exhausted', + 'Key limit exceeded (total limit). Manage it using https://openrouter.ai/settings/keys', + ], ['codex_native_timeout', 'Codex native exec timed out after 120000ms.'], ['network_error', 'Fetch failed because the network connection timed out.'], ['provider_overloaded', 'Service unavailable: provider temporarily unavailable (503).'], @@ -357,6 +361,99 @@ describe('TeamMemberRuntimeAdvisoryService', () => { expect(advisory?.message).not.toContain('Latest assistant message'); }); + it('classifies terminal OpenCode protocol proof failures as warnings, not provider errors', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-')); + setClaudeBasePathOverride(tmpDir); + + const teamName = 'relay-works'; + const laneId = 'secondary:opencode:jack'; + const nowIso = new Date().toISOString(); + const laneDir = path.join( + tmpDir, + 'teams', + teamName, + '.opencode-runtime', + 'lanes', + encodeURIComponent(laneId) + ); + await fs.mkdir(laneDir, { recursive: true }); + await fs.writeFile( + path.join(tmpDir, 'teams', teamName, '.opencode-runtime', 'lanes.json'), + JSON.stringify({ + version: 1, + updatedAt: nowIso, + lanes: { + [laneId]: { laneId, state: 'active', updatedAt: nowIso }, + }, + }), + 'utf8' + ); + await fs.writeFile( + path.join(laneDir, 'opencode-prompt-delivery-ledger.json'), + JSON.stringify({ + schemaVersion: 1, + updatedAt: nowIso, + data: [ + { + id: 'opencode-prompt:proof-missing', + teamName, + memberName: 'jack', + laneId, + runId: 'run-1', + runtimeSessionId: 'ses-1', + inboxMessageId: 'msg-1', + inboxTimestamp: nowIso, + source: 'watcher', + messageKind: null, + replyRecipient: 'team-lead', + actionMode: null, + taskRefs: [{ taskId: 'task-1', displayId: 'task-1', teamName }], + payloadHash: 'sha256:test', + status: 'failed_terminal', + responseState: 'responded_non_visible_tool', + attempts: 3, + maxAttempts: 3, + acceptanceUnknown: false, + nextAttemptAt: null, + lastAttemptAt: nowIso, + lastObservedAt: nowIso, + acceptedAt: nowIso, + respondedAt: nowIso, + failedAt: nowIso, + inboxReadCommittedAt: null, + inboxReadCommitError: null, + prePromptCursor: null, + postPromptCursor: null, + deliveredUserMessageId: 'delivered-1', + observedAssistantMessageId: 'assistant-1', + observedAssistantPreview: null, + observedToolCallNames: ['task_get'], + observedVisibleMessageId: null, + visibleReplyMessageId: null, + visibleReplyInbox: null, + visibleReplyCorrelation: null, + lastReason: 'non_visible_tool_without_task_progress', + diagnostics: ['non_visible_tool_without_task_progress'], + createdAt: nowIso, + updatedAt: nowIso, + }, + ], + }), + 'utf8' + ); + + const service = new TeamMemberRuntimeAdvisoryService({ + findMemberLogs: vi.fn(async () => []), + }); + const advisory = await service.getMemberAdvisory(teamName, 'jack'); + + expect(advisory).toMatchObject({ + kind: 'api_error', + reasonCode: 'protocol_proof_missing', + message: 'OpenCode used tools, but did not create a visible reply or task progress proof.', + }); + }); + it('suppresses stale OpenCode prompt delivery advisories after a visible runtime reply exists', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-')); setClaudeBasePathOverride(tmpDir); diff --git a/test/main/services/team/TeamMessageFeedService.test.ts b/test/main/services/team/TeamMessageFeedService.test.ts index 6afdbd28..dc15b120 100644 --- a/test/main/services/team/TeamMessageFeedService.test.ts +++ b/test/main/services/team/TeamMessageFeedService.test.ts @@ -252,7 +252,7 @@ Messages: expect(getInboxMessages).toHaveBeenCalledTimes(2); }); - it('adds UI-only OpenCode bootstrap start rows for side-lane teammates', async () => { + it('adds UI-only bootstrap start rows for side-lane teammates', async () => { const opencodeConfig: TeamConfig = { name: 'relay-works-14', description: 'relay-works-14 team for provisioning flow', @@ -281,7 +281,7 @@ Messages: from: 'team-lead', to: 'bob', source: 'system_notification', - messageId: 'opencode-bootstrap-start:relay-works-14:bob', + messageId: 'bootstrap-start:relay-works-14:bob', timestamp: '2026-04-30T17:42:26.947Z', }); expect(feed.messages[0]?.text).toContain('Provider override for this teammate: opencode.'); @@ -292,4 +292,39 @@ Messages: 'The team has already been created and you are being attached as a persistent teammate.' ); }); + + it('keeps UI-only bootstrap start rows for members with stale inactive config flags', async () => { + const configWithStaleInactiveMember: TeamConfig = { + name: 'atlas-hq', + description: 'atlas-hq team for provisioning flow', + members: [ + { name: 'team-lead', role: 'Lead', providerId: 'codex' }, + { + name: 'alice', + role: 'reviewer', + providerId: 'anthropic', + model: 'claude-opus-4-6', + joinedAt: 1778102486293, + isActive: false, + } as NonNullable[number], + ], + }; + const service = new TeamMessageFeedService({ + getConfig: vi.fn(async () => configWithStaleInactiveMember), + getInboxMessages: vi.fn(async () => []), + getLeadSessionMessages: vi.fn(async () => []), + getSentMessages: vi.fn(async () => []), + }); + + const feed = await service.getFeed('atlas-hq'); + + expect(feed.messages).toHaveLength(1); + expect(feed.messages[0]).toMatchObject({ + from: 'team-lead', + to: 'alice', + source: 'system_notification', + messageId: 'bootstrap-start:atlas-hq:alice', + timestamp: '2026-05-06T21:21:26.293Z', + }); + }); }); diff --git a/test/main/services/team/stallMonitor/TeamTaskStallNotifier.test.ts b/test/main/services/team/stallMonitor/TeamTaskStallNotifier.test.ts index 3c792f02..111713ad 100644 --- a/test/main/services/team/stallMonitor/TeamTaskStallNotifier.test.ts +++ b/test/main/services/team/stallMonitor/TeamTaskStallNotifier.test.ts @@ -68,6 +68,7 @@ describe('TeamTaskStallNotifier', () => { taskRefs: [alert.taskRef], actionMode: 'do', source: 'system_notification', + messageKind: 'task_stall_remediation', }) ); expect(teamProvisioningService.relayOpenCodeMemberInboxMessages).toHaveBeenCalledWith( diff --git a/test/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.test.ts b/test/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.test.ts index 2b2b4dd0..ff4b760e 100644 --- a/test/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.test.ts +++ b/test/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.test.ts @@ -37,6 +37,8 @@ describe('TeamTaskStallSnapshotSource', () => { }, ], }, + { id: 'task-approved', subject: 'Approved', status: 'in_progress' }, + { id: 'task-reopened', subject: 'Reopened', status: 'pending', reviewState: 'approved' }, ]; const deletedTasks = [{ id: 'task-deleted', subject: 'D', status: 'deleted' }]; const transcriptContext = { @@ -98,6 +100,10 @@ describe('TeamTaskStallSnapshotSource', () => { movedAt: '2026-04-19T12:00:00.000Z', reviewer: 'alice', }, + 'task-approved': { + column: 'approved', + movedAt: '2026-04-19T12:05:00.000Z', + }, }, })), }; @@ -136,23 +142,30 @@ describe('TeamTaskStallSnapshotSource', () => { ); const snapshot = await source.getSnapshot('demo'); + const expectedWorkflowActiveTasks = [ + activeTasks[0], + activeTasks[1], + { ...activeTasks[2], reviewState: 'approved' }, + { ...activeTasks[3], reviewState: 'none' }, + ]; expect(snapshot).not.toBeNull(); expect(batchIndexer.buildIndex).toHaveBeenCalledWith({ teamName: 'demo', - tasks: [...activeTasks, ...deletedTasks], + tasks: [...expectedWorkflowActiveTasks, ...deletedTasks], messages: rawMessages, }); expect(freshnessReader.readSignals).toHaveBeenCalledWith('/tmp/project', ['task-a', 'task-b']); expect(exactRowReader.parseFiles).toHaveBeenCalledWith(['/tmp/project/session-a.jsonl', '/tmp/project/session-b.jsonl']); expect(openCodeEvidenceSource.readEvidence).toHaveBeenCalledWith({ teamName: 'demo', - tasks: [activeTasks[0], activeTasks[1]], + tasks: [expectedWorkflowActiveTasks[0], expectedWorkflowActiveTasks[1]], providerByMemberName: new Map([ ['team-lead', 'codex'], ['alice', 'opencode'], ]), }); + expect(snapshot?.activeTasks).toEqual(expectedWorkflowActiveTasks); expect(snapshot?.inProgressTasks.map((task) => task.id)).toEqual(['task-a']); expect(snapshot?.reviewOpenTasks.map((task) => task.id)).toEqual(['task-b']); expect(snapshot?.leadName).toBe('team-lead'); diff --git a/test/main/services/team/teamTaskActiveState.test.ts b/test/main/services/team/teamTaskActiveState.test.ts new file mode 100644 index 00000000..2cb34fe4 --- /dev/null +++ b/test/main/services/team/teamTaskActiveState.test.ts @@ -0,0 +1,329 @@ +import { describe, expect, it } from 'vitest'; + +import { + getTeamTaskWorkflowColumn, + isTeamTaskActivelyWorked, + isTeamTaskFinalForCompletionNotification, + isTeamTaskFinishedForDependency, + isTeamTaskNeedsFixActionable, + isTeamTaskTerminalForActionableWork, + selectCurrentActiveTeamTask, +} from '../../../../src/main/services/team/teamTaskActiveState'; + +import type { TeamTaskWithKanban } from '../../../../src/shared/types'; + +describe('isTeamTaskActivelyWorked', () => { + it('accepts only canonical active work', () => { + expect( + isTeamTaskActivelyWorked({ + status: 'in_progress', + reviewState: 'none', + }) + ).toBe(true); + }); + + it('rejects terminal and approved task states', () => { + expect( + isTeamTaskActivelyWorked({ + status: 'completed', + reviewState: 'none', + }) + ).toBe(false); + expect( + isTeamTaskActivelyWorked({ + status: 'deleted', + reviewState: 'none', + deletedAt: '2026-05-06T00:00:00.000Z', + }) + ).toBe(false); + expect( + isTeamTaskActivelyWorked({ + status: 'deleted', + reviewState: 'approved', + deletedAt: '2026-05-06T00:00:00.000Z', + }) + ).toBe(false); + expect( + isTeamTaskActivelyWorked({ + status: 'in_progress', + reviewState: 'approved', + }) + ).toBe(false); + expect( + isTeamTaskActivelyWorked({ + status: 'in_progress', + reviewState: 'none', + kanbanColumn: 'approved', + }) + ).toBe(false); + expect( + isTeamTaskActivelyWorked({ + status: 'in_progress', + reviewState: 'none', + kanbanColumn: 'review', + }) + ).toBe(false); + }); + + it('does not treat current kanban review as terminal even with stale approved review state', () => { + const task = { + status: 'in_progress', + reviewState: 'approved', + kanbanColumn: 'review', + }; + + expect(isTeamTaskFinishedForDependency(task)).toBe(false); + expect(isTeamTaskTerminalForActionableWork(task)).toBe(false); + }); + + it('does not treat completed review workflow as dependency-finished', () => { + const task = { + status: 'completed', + reviewState: 'review', + kanbanColumn: 'review', + }; + + expect(isTeamTaskFinishedForDependency(task)).toBe(false); + expect(isTeamTaskTerminalForActionableWork(task)).toBe(false); + }); + + it('does not treat needsFix tasks as dependency-finished or actionable-terminal', () => { + const task = { + status: 'completed', + reviewState: 'needsFix', + }; + + expect(isTeamTaskFinishedForDependency(task)).toBe(false); + expect(isTeamTaskTerminalForActionableWork(task)).toBe(false); + }); + + it('lets current approved overlay win over stale needsFix for dependency and actionable terminal checks', () => { + const task = { + status: 'in_progress', + reviewState: 'needsFix', + kanbanColumn: 'approved', + }; + + expect(isTeamTaskFinishedForDependency(task)).toBe(true); + expect(isTeamTaskTerminalForActionableWork(task)).toBe(true); + }); +}); + +describe('getTeamTaskWorkflowColumn', () => { + it('keeps stale in-progress approved overlay visible as approved', () => { + expect( + getTeamTaskWorkflowColumn({ + status: 'in_progress', + reviewState: 'none', + kanbanColumn: 'approved', + }) + ).toBe('approved'); + }); + + it('does not treat reopened pending tasks as approved from stale kanban overlay', () => { + expect( + getTeamTaskWorkflowColumn({ + status: 'pending', + reviewState: 'none', + kanbanColumn: 'approved', + }) + ).toBeUndefined(); + }); + + it('does not treat reopened pending tasks as review or approved from stale review state', () => { + expect( + getTeamTaskWorkflowColumn({ + status: 'pending', + reviewState: 'review', + }) + ).toBeUndefined(); + expect( + getTeamTaskWorkflowColumn({ + status: 'pending', + reviewState: 'approved', + }) + ).toBeUndefined(); + }); + + it('prefers current kanban approved over stale review state', () => { + expect( + getTeamTaskWorkflowColumn({ + status: 'in_progress', + reviewState: 'review', + kanbanColumn: 'approved', + }) + ).toBe('approved'); + }); + + it('prefers current kanban review over stale approved review state', () => { + expect( + getTeamTaskWorkflowColumn({ + status: 'in_progress', + reviewState: 'approved', + kanbanColumn: 'review', + }) + ).toBe('review'); + }); + + it('does not treat deleted tasks as approved from stale review state', () => { + expect( + getTeamTaskWorkflowColumn({ + status: 'deleted', + reviewState: 'approved', + deletedAt: '2026-05-06T00:00:00.000Z', + }) + ).toBeUndefined(); + }); +}); + +describe('isTeamTaskNeedsFixActionable', () => { + it('treats needsFix as actionable only when no current workflow overlay wins', () => { + expect( + isTeamTaskNeedsFixActionable({ + status: 'completed', + reviewState: 'needsFix', + }) + ).toBe(true); + expect( + isTeamTaskNeedsFixActionable({ + status: 'in_progress', + reviewState: 'needsFix', + kanbanColumn: 'approved', + }) + ).toBe(false); + expect( + isTeamTaskNeedsFixActionable({ + status: 'completed', + reviewState: 'needsFix', + kanbanColumn: 'review', + }) + ).toBe(false); + }); +}); + +describe('isTeamTaskFinalForCompletionNotification', () => { + it('does not notify all-completed while a completed task is still in review', () => { + expect( + isTeamTaskFinalForCompletionNotification({ + status: 'completed', + reviewState: 'review', + kanbanColumn: 'review', + }) + ).toBe(false); + }); + + it('does not notify all-completed while a task needs fixes', () => { + expect( + isTeamTaskFinalForCompletionNotification({ + status: 'completed', + reviewState: 'needsFix', + }) + ).toBe(false); + }); + + it('treats approved overlay and plain completed tasks as final for completion notifications', () => { + expect( + isTeamTaskFinalForCompletionNotification({ + status: 'in_progress', + reviewState: 'needsFix', + kanbanColumn: 'approved', + }) + ).toBe(true); + expect( + isTeamTaskFinalForCompletionNotification({ + status: 'completed', + reviewState: 'none', + }) + ).toBe(true); + expect( + isTeamTaskFinalForCompletionNotification({ + status: 'deleted', + reviewState: 'needsFix', + deletedAt: '2026-05-06T00:00:00.000Z', + }) + ).toBe(true); + }); +}); + +describe('selectCurrentActiveTeamTask', () => { + it('selects the latest active work interval instead of the first display id', () => { + const tasks: TeamTaskWithKanban[] = [ + { + id: 'task-a', + displayId: '1', + subject: 'Older active task', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-05-06T10:00:00.000Z' }], + }, + { + id: 'task-b', + displayId: '2', + subject: 'Newer active task', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-05-06T11:00:00.000Z' }], + }, + ]; + + const selected = selectCurrentActiveTeamTask(tasks); + + expect(selected?.id).toBe('task-b'); + }); + + it('ignores approved active-looking tasks when selecting current work', () => { + const tasks: TeamTaskWithKanban[] = [ + { + id: 'task-approved', + displayId: '1', + subject: 'Approved stale task', + status: 'in_progress', + reviewState: 'none', + kanbanColumn: 'approved', + workIntervals: [{ startedAt: '2026-05-06T12:00:00.000Z' }], + }, + { + id: 'task-active', + displayId: '2', + subject: 'Active task', + status: 'in_progress', + reviewState: 'none', + workIntervals: [{ startedAt: '2026-05-06T10:00:00.000Z' }], + }, + ]; + + const selected = selectCurrentActiveTeamTask(tasks); + + expect(selected?.id).toBe('task-active'); + }); + + it('falls back to history when the open work interval timestamp is invalid', () => { + const tasks: TeamTaskWithKanban[] = [ + { + id: 'task-a', + displayId: '1', + subject: 'Corrupt interval but newer history', + status: 'in_progress', + workIntervals: [{ startedAt: 'not-a-date' }], + historyEvents: [ + { + id: 'event-a', + type: 'status_changed', + from: 'pending', + to: 'in_progress', + timestamp: '2026-05-06T12:00:00.000Z', + }, + ], + }, + { + id: 'task-b', + displayId: '2', + subject: 'Older active task', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-05-06T11:00:00.000Z' }], + }, + ]; + + const selected = selectCurrentActiveTeamTask(tasks); + + expect(selected?.id).toBe('task-a'); + }); +}); diff --git a/test/renderer/components/sidebar/taskFiltersState.test.ts b/test/renderer/components/sidebar/taskFiltersState.test.ts index dcce0001..e996d179 100644 --- a/test/renderer/components/sidebar/taskFiltersState.test.ts +++ b/test/renderer/components/sidebar/taskFiltersState.test.ts @@ -6,12 +6,44 @@ describe('taskFiltersState', () => { it('treats needsFix as distinct from normal todo/done buckets', () => { const pendingNeedsFixTask = { status: 'pending', reviewState: 'needsFix' as const }; const completedNeedsFixTask = { status: 'completed', reviewState: 'needsFix' as const }; + const activeNeedsFixTask = { status: 'in_progress', reviewState: 'needsFix' as const }; const normalPendingTask = { status: 'pending', reviewState: 'none' as const }; expect(taskMatchesStatus(pendingNeedsFixTask, new Set(['needs_fix']))).toBe(true); expect(taskMatchesStatus(completedNeedsFixTask, new Set(['needs_fix']))).toBe(true); + expect(taskMatchesStatus(activeNeedsFixTask, new Set(['needs_fix']))).toBe(true); expect(taskMatchesStatus(pendingNeedsFixTask, new Set(['todo']))).toBe(false); expect(taskMatchesStatus(completedNeedsFixTask, new Set(['done']))).toBe(false); + expect(taskMatchesStatus(activeNeedsFixTask, new Set(['in_progress']))).toBe(false); expect(taskMatchesStatus(normalPendingTask, new Set(['todo']))).toBe(true); }); + + it('treats completed review workflow as review, not done', () => { + const completedReviewTask = { + status: 'completed', + reviewState: 'review' as const, + kanbanColumn: 'review' as const, + }; + + expect(taskMatchesStatus(completedReviewTask, new Set(['review']))).toBe(true); + expect(taskMatchesStatus(completedReviewTask, new Set(['done']))).toBe(false); + }); + + it('lets current workflow overlay win over stale needsFix in filters', () => { + const approvedTask = { + status: 'in_progress', + reviewState: 'needsFix' as const, + kanbanColumn: 'approved' as const, + }; + const reviewTask = { + status: 'completed', + reviewState: 'needsFix' as const, + kanbanColumn: 'review' as const, + }; + + expect(taskMatchesStatus(approvedTask, new Set(['approved']))).toBe(true); + expect(taskMatchesStatus(approvedTask, new Set(['needs_fix']))).toBe(false); + expect(taskMatchesStatus(reviewTask, new Set(['review']))).toBe(true); + expect(taskMatchesStatus(reviewTask, new Set(['needs_fix']))).toBe(false); + }); }); diff --git a/test/renderer/components/team/activity/ActivityItem.test.ts b/test/renderer/components/team/activity/ActivityItem.test.ts index 58580b34..4c11635e 100644 --- a/test/renderer/components/team/activity/ActivityItem.test.ts +++ b/test/renderer/components/team/activity/ActivityItem.test.ts @@ -541,4 +541,41 @@ describe('ActivityItem legacy system message fallback', () => { await Promise.resolve(); }); }); + + it('renders task stall remediation as a compact automation row', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + const message: InboxMessage = { + from: 'system', + to: 'jack', + text: 'Task #1c24a4c4 may be stalled after a low-signal progress update.', + summary: 'Potential stalled task', + timestamp: new Date('2026-04-13T13:36:00.000Z').toISOString(), + read: true, + source: 'system_notification', + messageKind: 'task_stall_remediation', + messageId: 'task-stall:demo:task-a:epoch-a', + taskRefs: [{ taskId: 'task-a', displayId: '#1c24a4c4', teamName: 'my-team' }], + }; + + await act(async () => { + root.render(React.createElement(ActivityItem, { message, teamName: 'my-team' })); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('automation'); + expect(host.textContent).toContain('stall nudge'); + expect(host.textContent).toContain('jack'); + expect(host.textContent).toContain('#1c24a4c4'); + expect(host.textContent).not.toContain('may be stalled after a low-signal progress update'); + expect(host.textContent).not.toContain('Do not send acknowledgement-only replies'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/team/members/MemberList.test.ts b/test/renderer/components/team/members/MemberList.test.ts index 02516601..b5880a44 100644 --- a/test/renderer/components/team/members/MemberList.test.ts +++ b/test/renderer/components/team/members/MemberList.test.ts @@ -2,7 +2,7 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { MemberSpawnStatusEntry, ResolvedTeamMember } from '@shared/types'; +import type { MemberSpawnStatusEntry, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; vi.mock('@renderer/components/team/members/MemberCard', () => ({ MemberCard: ({ @@ -10,6 +10,8 @@ vi.mock('@renderer/components/team/members/MemberCard', () => ({ spawnError, spawnStatus, spawnLaunchState, + currentTask, + reviewTask, onRestartMember, onSkipMemberForLaunch, }: { @@ -17,6 +19,8 @@ vi.mock('@renderer/components/team/members/MemberCard', () => ({ spawnError?: string; spawnStatus?: string; spawnLaunchState?: string; + currentTask?: TeamTaskWithKanban | null; + reviewTask?: TeamTaskWithKanban | null; onRestartMember?: (memberName: string) => void; onSkipMemberForLaunch?: (memberName: string) => void; }) => @@ -24,6 +28,12 @@ vi.mock('@renderer/components/team/members/MemberCard', () => ({ 'div', { 'data-testid': `member-${member.name}` }, spawnError ?? '', + currentTask + ? React.createElement('span', { 'data-testid': `current-${member.name}` }, currentTask.id) + : null, + reviewTask + ? React.createElement('span', { 'data-testid': `review-${member.name}` }, reviewTask.id) + : null, onRestartMember && (spawnStatus === 'error' || spawnLaunchState === 'failed_to_start') ? React.createElement( 'button', @@ -195,6 +205,41 @@ describe('MemberList spawn-status memoization', () => { }); }); + it('shows a review task when a stale currentTaskId points at the same non-active task', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const members: ResolvedTeamMember[] = [{ ...member, currentTaskId: 'task-review' }]; + const reviewTask: TeamTaskWithKanban = { + id: 'task-review', + subject: 'Review this', + status: 'completed', + reviewState: 'review', + kanbanColumn: 'review', + reviewer: 'bob', + }; + + await act(async () => { + root.render( + React.createElement(MemberList, { + members, + isTeamAlive: true, + taskMap: new Map([[reviewTask.id, reviewTask]]), + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('[data-testid="current-bob"]')).toBeNull(); + expect(host.querySelector('[data-testid="review-bob"]')?.textContent).toBe('task-review'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('passes skip callbacks to failed member cards and rerenders when the callback changes', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); diff --git a/test/renderer/features/agent-graph/taskGraphSemantics.test.ts b/test/renderer/features/agent-graph/taskGraphSemantics.test.ts new file mode 100644 index 00000000..0e266a44 --- /dev/null +++ b/test/renderer/features/agent-graph/taskGraphSemantics.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import { + isTaskBlocked, + resolveTaskGraphColumn, +} from '@features/agent-graph/core/domain/taskGraphSemantics'; + +import type { TeamTaskWithKanban } from '@shared/types'; + +describe('taskGraphSemantics', () => { + it('uses workflow column semantics for graph columns', () => { + expect(resolveTaskGraphColumn({ status: 'in_progress', kanbanColumn: 'approved' })).toBe( + 'approved' + ); + expect(resolveTaskGraphColumn({ status: 'pending', kanbanColumn: 'approved' })).toBe('todo'); + expect(resolveTaskGraphColumn({ status: 'pending', kanbanColumn: 'review' })).toBe('todo'); + expect( + resolveTaskGraphColumn({ + status: 'in_progress', + reviewState: 'needsFix', + kanbanColumn: 'approved', + }) + ).toBe('approved'); + expect( + resolveTaskGraphColumn({ + status: 'in_progress', + reviewState: 'review', + kanbanColumn: 'approved', + }) + ).toBe('approved'); + expect( + resolveTaskGraphColumn({ + status: 'deleted', + reviewState: 'approved', + deletedAt: '2026-05-06T19:06:07.257Z', + }) + ).toBe('todo'); + expect(resolveTaskGraphColumn({ status: 'pending', reviewState: 'needsFix' })).toBe('review'); + }); + + it('treats approved blockers as finished dependencies', () => { + const taskStateById = new Map< + string, + Pick + >([ + ['completed', { status: 'completed' }], + ['soft-deleted', { status: 'in_progress', deletedAt: '2026-05-06T19:06:07.257Z' }], + ['review-approved', { status: 'in_progress', reviewState: 'approved' }], + ['kanban-approved', { status: 'in_progress', kanbanColumn: 'approved' }], + ]); + + expect(isTaskBlocked({ blockedBy: ['completed'] }, taskStateById)).toBe(false); + expect(isTaskBlocked({ blockedBy: ['soft-deleted'] }, taskStateById)).toBe(false); + expect(isTaskBlocked({ blockedBy: ['review-approved'] }, taskStateById)).toBe(false); + expect(isTaskBlocked({ blockedBy: ['kanban-approved'] }, taskStateById)).toBe(false); + }); + + it('keeps blockers active while completed work is still in review', () => { + const taskStateById = new Map< + string, + Pick + >([ + [ + 'completed-review', + { + status: 'completed', + reviewState: 'review', + kanbanColumn: 'review', + }, + ], + ]); + + expect(isTaskBlocked({ blockedBy: ['completed-review'] }, taskStateById)).toBe(true); + }); +}); diff --git a/test/renderer/utils/memberHelpers.test.ts b/test/renderer/utils/memberHelpers.test.ts index 651fad05..7c0e1ea5 100644 --- a/test/renderer/utils/memberHelpers.test.ts +++ b/test/renderer/utils/memberHelpers.test.ts @@ -6,6 +6,7 @@ import { getSpawnCardClass, getMemberRuntimeAdvisoryLabel, getMemberRuntimeAdvisoryTitle, + getMemberRuntimeAdvisoryTone, isOpenCodeRelaunchActionable, } from '@renderer/utils/memberHelpers'; @@ -703,15 +704,18 @@ describe('memberHelpers spawn-aware presence', () => { const advisory = { kind: 'api_error' as const, observedAt: '2026-04-07T09:00:00.000Z', - reasonCode: 'backend_error' as const, + reasonCode: 'protocol_proof_missing' as const, message: 'visible_reply_still_required', }; - expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode delivery error'); + expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode proof missing'); + expect(getMemberRuntimeAdvisoryTone(advisory)).toBe('warning'); const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode'); - expect(title).toContain('OpenCode runtime delivery error.'); + expect(title).toContain( + 'OpenCode delivery completed without required visible/progress proof.' + ); expect(title).toContain('OpenCode responded, but did not create a visible message_send reply.'); expect(title).not.toContain('visible_reply_still_required'); }); @@ -732,6 +736,42 @@ describe('memberHelpers spawn-aware presence', () => { expect(title).not.toContain('runtime_bootstrap_checkin'); }); + it('formats non-visible tool progress advisory reasons before showing them in titles', () => { + const title = getMemberRuntimeAdvisoryTitle( + { + kind: 'api_error', + observedAt: '2026-04-07T09:00:00.000Z', + reasonCode: 'protocol_proof_missing', + message: 'non_visible_tool_without_task_progress', + }, + 'opencode' + ); + + expect( + getMemberRuntimeAdvisoryLabel( + { + kind: 'api_error', + observedAt: '2026-04-07T09:00:00.000Z', + reasonCode: 'protocol_proof_missing', + message: 'non_visible_tool_without_task_progress', + }, + 'opencode' + ) + ).toBe('OpenCode proof missing'); + expect( + getMemberRuntimeAdvisoryTone({ + kind: 'api_error', + observedAt: '2026-04-07T09:00:00.000Z', + reasonCode: 'protocol_proof_missing', + message: 'non_visible_tool_without_task_progress', + }) + ).toBe('warning'); + expect(title).toContain( + 'OpenCode used tools, but did not create a visible reply or task progress proof.' + ); + expect(title).not.toContain('non_visible_tool_without_task_progress'); + }); + it('renders Codex native timeout separately from network errors', () => { const advisory = { kind: 'api_error' as const, diff --git a/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts b/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts index 763980e0..f538a623 100644 --- a/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts +++ b/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts @@ -77,4 +77,25 @@ describe('openCodeRuntimeDeliveryDiagnostics', () => { reason: 'visible_reply_still_required', }); }); + + it('surfaces missing task progress proof as a readable failure', () => { + const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({ + deliveredToInbox: true, + messageId: 'msg-progress-required', + runtimeDelivery: { + providerId: 'opencode', + attempted: true, + delivered: false, + responsePending: false, + responseState: 'responded_non_visible_tool', + ledgerStatus: 'failed_terminal', + reason: 'non_visible_tool_without_task_progress', + diagnostics: ['non_visible_tool_without_task_progress'], + }, + }); + + expect(diagnostics.warning).toBe( + 'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. Reason: OpenCode used tools, but did not create a visible reply or task progress proof.' + ); + }); }); diff --git a/test/renderer/utils/pathNormalize.test.ts b/test/renderer/utils/pathNormalize.test.ts new file mode 100644 index 00000000..c0818662 --- /dev/null +++ b/test/renderer/utils/pathNormalize.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; + +import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize'; + +describe('pathNormalize task counts', () => { + it('counts approved tasks as completed instead of in-progress', () => { + const counts = buildTaskCountsByOwner([ + { + owner: 'jack', + status: 'in_progress', + kanbanColumn: 'approved', + }, + { + owner: 'jack', + status: 'in_progress', + reviewState: 'approved', + }, + ]); + + expect(counts.get('jack')).toEqual({ + pending: 0, + inProgress: 0, + completed: 2, + }); + }); + + it('ignores soft-deleted tasks even when status is stale', () => { + const counts = buildTaskCountsByOwner([ + { + owner: 'jack', + status: 'in_progress', + deletedAt: '2026-05-06T19:06:07.257Z', + }, + ]); + + expect(counts.get('jack')).toBeUndefined(); + }); + + it('keeps reopened pending tasks pending when kanban approved is stale', () => { + const counts = buildTaskCountsByOwner([ + { + owner: 'jack', + status: 'pending', + kanbanColumn: 'approved', + }, + ]); + + expect(counts.get('jack')).toEqual({ + pending: 1, + inProgress: 0, + completed: 0, + }); + }); + + it('counts needsFix tasks as actionable instead of completed', () => { + const counts = buildTaskCountsByOwner([ + { + owner: 'jack', + status: 'completed', + reviewState: 'needsFix', + }, + { + owner: 'jack', + status: 'in_progress', + reviewState: 'needsFix', + }, + ]); + + expect(counts.get('jack')).toEqual({ + pending: 1, + inProgress: 1, + completed: 0, + }); + }); + + it('does not count review workflow tasks as completed owner progress', () => { + const counts = buildTaskCountsByOwner([ + { + owner: 'jack', + status: 'completed', + reviewState: 'review', + kanbanColumn: 'review', + }, + ]); + + expect(counts.get('jack')).toBeUndefined(); + }); + + it('lets current approved overlay win over stale needsFix in task counts', () => { + const counts = buildTaskCountsByOwner([ + { + owner: 'jack', + status: 'in_progress', + reviewState: 'needsFix', + kanbanColumn: 'approved', + }, + ]); + + expect(counts.get('jack')).toEqual({ + pending: 0, + inProgress: 0, + completed: 1, + }); + }); +}); diff --git a/test/renderer/utils/teamMessageFiltering.test.ts b/test/renderer/utils/teamMessageFiltering.test.ts index 0b079e9d..02c46d7b 100644 --- a/test/renderer/utils/teamMessageFiltering.test.ts +++ b/test/renderer/utils/teamMessageFiltering.test.ts @@ -534,4 +534,54 @@ Messages: expect(result).toHaveLength(1); expect(result[0].messageId).toBe('msg-2'); }); + + it('hides task stall remediation automation rows from conversational message counts by default', () => { + const messages = [ + makeMessage({ + messageId: 'task-stall:demo:task-a:epoch-a', + from: 'system', + to: 'jack', + source: 'system_notification', + messageKind: 'task_stall_remediation', + summary: 'Potential stalled task', + text: 'Task #abcd1234 may be stalled.', + }), + makeMessage({ + messageId: 'msg-2', + text: 'Visible message', + }), + ]; + + const result = filterTeamMessages(messages, { + timeWindow: null, + filter: { from: new Set(), to: new Set(), showNoise: true }, + searchQuery: '', + }); + + expect(result.map((message) => message.messageId)).toEqual(['msg-2']); + }); + + it('can include task stall remediation automation rows for the activity timeline', () => { + const messages = [ + makeMessage({ + messageId: 'task-stall:demo:task-a:legacy-epoch', + from: 'system', + to: 'jack', + source: 'system_notification', + summary: 'Potential stalled task', + text: 'Task #abcd1234 may be stalled.', + }), + ]; + + const result = filterTeamMessages(messages, { + includeAutomationEvents: true, + timeWindow: null, + filter: { from: new Set(), to: new Set(), showNoise: true }, + searchQuery: '', + }); + + expect(result.map((message) => message.messageId)).toEqual([ + 'task-stall:demo:task-a:legacy-epoch', + ]); + }); }); diff --git a/test/shared/utils/reviewState.test.ts b/test/shared/utils/reviewState.test.ts index d9b5d453..b3cea794 100644 --- a/test/shared/utils/reviewState.test.ts +++ b/test/shared/utils/reviewState.test.ts @@ -111,6 +111,39 @@ describe('reviewState utils', () => { ).toBe('none'); }); + it('lets canonical pending status clear stale review history when no reopen event exists', () => { + expect( + getReviewStateFromTask({ + status: 'pending', + historyEvents: [ + { + id: '1', + timestamp: '2026-01-01T00:00:00Z', + type: 'review_approved', + from: 'review', + to: 'approved', + actor: 'alice', + }, + ], + }) + ).toBe('none'); + expect( + getReviewStateFromTask({ + status: 'pending', + historyEvents: [ + { + id: '1', + timestamp: '2026-01-01T00:00:00Z', + type: 'review_requested', + from: 'none', + to: 'review', + reviewer: 'bob', + }, + ], + }) + ).toBe('none'); + }); + it('falls back to persisted legacy reviewState when history has no review signal', () => { expect( getReviewStateFromTask({ @@ -139,4 +172,25 @@ describe('reviewState utils', () => { 'needsFix' ); }); + + it('keeps completed needsFix as a non-final review correction state', () => { + expect(getReviewStateFromTask({ reviewState: 'needsFix', status: 'completed' })).toBe( + 'needsFix' + ); + expect( + getReviewStateFromTask({ + status: 'completed', + historyEvents: [ + { + id: '1', + timestamp: '2026-01-01T00:00:00Z', + type: 'review_changes_requested', + from: 'review', + to: 'needsFix', + actor: 'reviewer', + }, + ], + }) + ).toBe('needsFix'); + }); }); diff --git a/test/shared/utils/taskChangeState.test.ts b/test/shared/utils/taskChangeState.test.ts index 18705758..85837d8d 100644 --- a/test/shared/utils/taskChangeState.test.ts +++ b/test/shared/utils/taskChangeState.test.ts @@ -67,4 +67,63 @@ describe('taskChangeState utils', () => { }) ).toBe('active'); }); + + it('treats in-progress tasks approved through kanban overlay as approved', () => { + const bucket = getTaskChangeStateBucket({ + status: 'in_progress', + kanbanColumn: 'approved', + }); + + expect(bucket).toBe('approved'); + expect(isTaskChangeSummaryCacheable(bucket)).toBe(true); + }); + + it('does not treat pending tasks with stale approved kanban overlay as approved', () => { + expect( + getTaskChangeStateBucket({ + status: 'pending', + kanbanColumn: 'approved', + }) + ).toBe('active'); + }); + + it('does not treat pending tasks with stale review kanban overlay as review', () => { + expect( + getTaskChangeStateBucket({ + status: 'pending', + kanbanColumn: 'review', + }) + ).toBe('active'); + }); + + it('lets current kanban review overlay win over stale approved review state', () => { + expect( + getTaskChangeStateBucket({ + status: 'completed', + reviewState: 'approved', + kanbanColumn: 'review', + }) + ).toBe('review'); + }); + + it('does not cache completed tasks that still need fixes', () => { + const bucket = getTaskChangeStateBucket({ + status: 'completed', + reviewState: 'needsFix', + }); + + expect(bucket).toBe('active'); + expect(isTaskChangeSummaryCacheable(bucket)).toBe(false); + }); + + it('lets current approved overlay win over stale needsFix for change summary caching', () => { + const bucket = getTaskChangeStateBucket({ + status: 'completed', + reviewState: 'needsFix', + kanbanColumn: 'approved', + }); + + expect(bucket).toBe('approved'); + expect(isTaskChangeSummaryCacheable(bucket)).toBe(true); + }); });