feat(team): update task automation state
This commit is contained in:
parent
2c30fd2235
commit
38b3a1e789
72 changed files with 2756 additions and 322 deletions
|
|
@ -283,6 +283,7 @@
|
||||||
"supports_pdf_input": true,
|
"supports_pdf_input": true,
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
|
"supports_minimal_reasoning_effort": true,
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
|
|
@ -480,6 +481,7 @@
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"supports_prompt_caching": false,
|
"supports_prompt_caching": false,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
|
"supports_minimal_reasoning_effort": true,
|
||||||
"supports_tool_choice": true
|
"supports_tool_choice": true
|
||||||
},
|
},
|
||||||
"global.anthropic.claude-opus-4-7": {
|
"global.anthropic.claude-opus-4-7": {
|
||||||
|
|
@ -627,6 +629,7 @@
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
|
"supports_max_reasoning_effort": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"tool_use_system_prompt_tokens": 346,
|
||||||
|
|
@ -656,6 +659,7 @@
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
|
"supports_max_reasoning_effort": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"tool_use_system_prompt_tokens": 346,
|
||||||
|
|
@ -685,6 +689,7 @@
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
|
"supports_max_reasoning_effort": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"tool_use_system_prompt_tokens": 346,
|
||||||
|
|
@ -713,6 +718,7 @@
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
|
"supports_max_reasoning_effort": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"tool_use_system_prompt_tokens": 346,
|
||||||
|
|
@ -741,6 +747,7 @@
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
|
"supports_max_reasoning_effort": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"tool_use_system_prompt_tokens": 346,
|
||||||
|
|
@ -1018,6 +1025,7 @@
|
||||||
"supports_pdf_input": true,
|
"supports_pdf_input": true,
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
|
"supports_minimal_reasoning_effort": true,
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true
|
"supports_vision": true
|
||||||
|
|
@ -1141,6 +1149,7 @@
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
|
"supports_max_reasoning_effort": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"tool_use_system_prompt_tokens": 346,
|
||||||
|
|
@ -1711,6 +1720,7 @@
|
||||||
"search_context_size_low": 0.01,
|
"search_context_size_low": 0.01,
|
||||||
"search_context_size_medium": 0.01
|
"search_context_size_medium": 0.01
|
||||||
},
|
},
|
||||||
|
"supports_adaptive_thinking": true,
|
||||||
"supports_assistant_prefill": true,
|
"supports_assistant_prefill": true,
|
||||||
"supports_computer_use": true,
|
"supports_computer_use": true,
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
|
|
@ -1718,6 +1728,7 @@
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
|
"supports_max_reasoning_effort": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"tool_use_system_prompt_tokens": 346,
|
||||||
|
|
@ -1853,6 +1864,7 @@
|
||||||
"supports_pdf_input": true,
|
"supports_pdf_input": true,
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
|
"supports_minimal_reasoning_effort": true,
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
|
|
@ -1880,6 +1892,7 @@
|
||||||
"supports_pdf_input": true,
|
"supports_pdf_input": true,
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
|
"supports_minimal_reasoning_effort": true,
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
|
|
@ -1901,6 +1914,7 @@
|
||||||
"search_context_size_low": 0.01,
|
"search_context_size_low": 0.01,
|
||||||
"search_context_size_medium": 0.01
|
"search_context_size_medium": 0.01
|
||||||
},
|
},
|
||||||
|
"supports_adaptive_thinking": true,
|
||||||
"supports_assistant_prefill": false,
|
"supports_assistant_prefill": false,
|
||||||
"supports_computer_use": true,
|
"supports_computer_use": true,
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
|
|
@ -1934,6 +1948,7 @@
|
||||||
"search_context_size_low": 0.01,
|
"search_context_size_low": 0.01,
|
||||||
"search_context_size_medium": 0.01
|
"search_context_size_medium": 0.01
|
||||||
},
|
},
|
||||||
|
"supports_adaptive_thinking": true,
|
||||||
"supports_assistant_prefill": false,
|
"supports_assistant_prefill": false,
|
||||||
"supports_computer_use": true,
|
"supports_computer_use": true,
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
|
|
@ -1967,6 +1982,7 @@
|
||||||
"search_context_size_low": 0.01,
|
"search_context_size_low": 0.01,
|
||||||
"search_context_size_medium": 0.01
|
"search_context_size_medium": 0.01
|
||||||
},
|
},
|
||||||
|
"supports_adaptive_thinking": true,
|
||||||
"supports_assistant_prefill": false,
|
"supports_assistant_prefill": false,
|
||||||
"supports_computer_use": true,
|
"supports_computer_use": true,
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
|
|
@ -2001,6 +2017,7 @@
|
||||||
"search_context_size_low": 0.01,
|
"search_context_size_low": 0.01,
|
||||||
"search_context_size_medium": 0.01
|
"search_context_size_medium": 0.01
|
||||||
},
|
},
|
||||||
|
"supports_adaptive_thinking": true,
|
||||||
"supports_assistant_prefill": false,
|
"supports_assistant_prefill": false,
|
||||||
"supports_computer_use": true,
|
"supports_computer_use": true,
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
|
|
@ -2144,6 +2161,7 @@
|
||||||
"supports_assistant_prefill": true,
|
"supports_assistant_prefill": true,
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
|
"supports_minimal_reasoning_effort": true,
|
||||||
"supports_tool_choice": true
|
"supports_tool_choice": true
|
||||||
},
|
},
|
||||||
"databricks/databricks-claude-sonnet-4": {
|
"databricks/databricks-claude-sonnet-4": {
|
||||||
|
|
@ -2655,7 +2673,8 @@
|
||||||
"mode": "chat",
|
"mode": "chat",
|
||||||
"output_cost_per_token": 0.000025,
|
"output_cost_per_token": 0.000025,
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
"supports_vision": true
|
"supports_vision": true,
|
||||||
|
"supports_minimal_reasoning_effort": true
|
||||||
},
|
},
|
||||||
"gmi/anthropic/claude-sonnet-4.5": {
|
"gmi/anthropic/claude-sonnet-4.5": {
|
||||||
"input_cost_per_token": 0.000003,
|
"input_cost_per_token": 0.000003,
|
||||||
|
|
@ -3304,6 +3323,7 @@
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
|
"supports_max_reasoning_effort": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 159,
|
"tool_use_system_prompt_tokens": 159,
|
||||||
|
|
@ -3322,6 +3342,7 @@
|
||||||
"supports_assistant_prefill": true,
|
"supports_assistant_prefill": true,
|
||||||
"supports_computer_use": true,
|
"supports_computer_use": true,
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
|
"supports_minimal_reasoning_effort": true,
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
|
|
@ -3343,6 +3364,7 @@
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
|
"supports_max_reasoning_effort": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"tool_use_system_prompt_tokens": 346,
|
||||||
|
|
@ -3408,6 +3430,7 @@
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
|
"supports_max_reasoning_effort": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"supports_xhigh_reasoning_effort": true,
|
"supports_xhigh_reasoning_effort": true,
|
||||||
|
|
@ -3786,6 +3809,7 @@
|
||||||
"supports_assistant_prefill": true,
|
"supports_assistant_prefill": true,
|
||||||
"supports_computer_use": true,
|
"supports_computer_use": true,
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
|
"supports_minimal_reasoning_effort": true,
|
||||||
"supports_pdf_input": true,
|
"supports_pdf_input": true,
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
|
|
@ -3814,6 +3838,7 @@
|
||||||
"supports_assistant_prefill": true,
|
"supports_assistant_prefill": true,
|
||||||
"supports_computer_use": true,
|
"supports_computer_use": true,
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
|
"supports_minimal_reasoning_effort": true,
|
||||||
"supports_pdf_input": true,
|
"supports_pdf_input": true,
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
|
|
@ -3841,6 +3866,7 @@
|
||||||
"supports_assistant_prefill": true,
|
"supports_assistant_prefill": true,
|
||||||
"supports_computer_use": true,
|
"supports_computer_use": true,
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
|
"supports_minimal_reasoning_effort": true,
|
||||||
"supports_pdf_input": true,
|
"supports_pdf_input": true,
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
|
|
@ -4107,6 +4133,7 @@
|
||||||
"output_cost_per_token": 0.000025,
|
"output_cost_per_token": 0.000025,
|
||||||
"supports_assistant_prefill": true,
|
"supports_assistant_prefill": true,
|
||||||
"supports_computer_use": true,
|
"supports_computer_use": true,
|
||||||
|
"supports_minimal_reasoning_effort": true,
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
|
|
@ -4446,6 +4473,7 @@
|
||||||
"supports_assistant_prefill": true,
|
"supports_assistant_prefill": true,
|
||||||
"supports_computer_use": true,
|
"supports_computer_use": true,
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
|
"supports_minimal_reasoning_effort": true,
|
||||||
"supports_pdf_input": true,
|
"supports_pdf_input": true,
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
|
|
@ -4472,6 +4500,7 @@
|
||||||
"supports_assistant_prefill": true,
|
"supports_assistant_prefill": true,
|
||||||
"supports_computer_use": true,
|
"supports_computer_use": true,
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
|
"supports_minimal_reasoning_effort": true,
|
||||||
"supports_pdf_input": true,
|
"supports_pdf_input": true,
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
|
|
@ -4638,6 +4667,7 @@
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
|
"supports_max_reasoning_effort": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"tool_use_system_prompt_tokens": 346,
|
||||||
|
|
@ -4778,6 +4808,7 @@
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
|
"supports_max_reasoning_effort": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"tool_use_system_prompt_tokens": 346,
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,27 @@
|
||||||
{
|
{
|
||||||
"version": "0.0.20",
|
"version": "0.0.21",
|
||||||
"sourceRef": "v0.0.20",
|
"sourceRef": "v0.0.21",
|
||||||
"sourceRepository": "777genius/agent_teams_orchestrator",
|
"sourceRepository": "777genius/agent_teams_orchestrator",
|
||||||
"releaseRepository": "777genius/claude_agent_teams_ui",
|
"releaseRepository": "777genius/claude_agent_teams_ui",
|
||||||
"releaseTag": "v1.2.0",
|
"releaseTag": "v1.2.0",
|
||||||
"assets": {
|
"assets": {
|
||||||
"darwin-arm64": {
|
"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",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"darwin-x64": {
|
"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",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"linux-x64": {
|
"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",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"win32-x64": {
|
"win32-x64": {
|
||||||
"file": "agent-teams-runtime-win32-x64-v0.0.20.zip",
|
"file": "agent-teams-runtime-win32-x64-v0.0.21.zip",
|
||||||
"archiveKind": "zip",
|
"archiveKind": "zip",
|
||||||
"binaryName": "claude-multimodel.exe"
|
"binaryName": "claude-multimodel.exe"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,34 @@
|
||||||
|
import {
|
||||||
|
getTeamTaskWorkflowColumn,
|
||||||
|
isTeamTaskDeleted,
|
||||||
|
isTeamTaskFinishedForDependency,
|
||||||
|
isTeamTaskNeedsFixActionable,
|
||||||
|
} from '@shared/utils/teamTaskState';
|
||||||
|
|
||||||
import type { KanbanColumnId, KanbanTaskState, TeamTask, TeamTaskWithKanban } from '@shared/types';
|
import type { KanbanColumnId, KanbanTaskState, TeamTask, TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
||||||
type TaskColumnInput = Pick<TeamTaskWithKanban, 'status' | 'reviewState' | 'kanbanColumn'>;
|
type TaskColumnInput = Pick<
|
||||||
|
TeamTaskWithKanban,
|
||||||
|
'status' | 'reviewState' | 'kanbanColumn' | 'deletedAt'
|
||||||
|
>;
|
||||||
type TaskReviewerInput = Pick<TeamTaskWithKanban, 'reviewer' | 'reviewState' | 'kanbanColumn'>;
|
type TaskReviewerInput = Pick<TeamTaskWithKanban, 'reviewer' | 'reviewState' | 'kanbanColumn'>;
|
||||||
type TaskBlockInput = Pick<TeamTask, 'blockedBy'>;
|
type TaskBlockInput = Pick<TeamTask, 'blockedBy'>;
|
||||||
type TaskBlockState = Pick<TeamTask, 'status'>;
|
type TaskBlockState = Pick<
|
||||||
|
TeamTaskWithKanban,
|
||||||
|
'status' | 'reviewState' | 'kanbanColumn' | 'deletedAt'
|
||||||
|
>;
|
||||||
|
|
||||||
export function resolveTaskGraphColumn(task: TaskColumnInput): KanbanColumnId {
|
export function resolveTaskGraphColumn(task: TaskColumnInput): KanbanColumnId {
|
||||||
if (task.reviewState === 'approved') return 'approved';
|
const workflowColumn = getTeamTaskWorkflowColumn(task);
|
||||||
if (task.reviewState === 'review' || task.reviewState === 'needsFix') return 'review';
|
if (workflowColumn) return workflowColumn;
|
||||||
if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') {
|
if (isTeamTaskNeedsFixActionable(task)) return 'review';
|
||||||
return task.kanbanColumn;
|
|
||||||
}
|
|
||||||
if (task.status === 'in_progress') return 'in_progress';
|
if (task.status === 'in_progress') return 'in_progress';
|
||||||
if (task.status === 'completed') return 'done';
|
if (task.status === 'completed') return 'done';
|
||||||
return 'todo';
|
return 'todo';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isTaskInReviewCycle(task: TaskColumnInput): boolean {
|
export function isTaskInReviewCycle(task: TaskColumnInput): boolean {
|
||||||
return (
|
return isTeamTaskNeedsFixActionable(task) || getTeamTaskWorkflowColumn(task) === 'review';
|
||||||
task.reviewState === 'review' ||
|
|
||||||
task.reviewState === 'needsFix' ||
|
|
||||||
task.kanbanColumn === 'review'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveTaskReviewer(
|
export function resolveTaskReviewer(
|
||||||
|
|
@ -43,6 +50,6 @@ export function isTaskBlocked(
|
||||||
|
|
||||||
return blockedBy.some((taskId) => {
|
return blockedBy.some((taskId) => {
|
||||||
const blocker = taskStateById.get(taskId);
|
const blocker = taskStateById.get(taskId);
|
||||||
return !blocker || (blocker.status !== 'completed' && blocker.status !== 'deleted');
|
return !blocker || (!isTeamTaskFinishedForDependency(blocker) && !isTeamTaskDeleted(blocker));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,10 @@ import {
|
||||||
} from '@shared/utils/idleNotificationSemantics';
|
} from '@shared/utils/idleNotificationSemantics';
|
||||||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
import {
|
||||||
|
isTeamTaskActivelyWorked,
|
||||||
|
isTeamTaskNeedsFixActionable,
|
||||||
|
} from '@shared/utils/teamTaskState';
|
||||||
import { buildOrderedVisibleTeamGraphOwnerIds } from '@shared/utils/teamGraphDefaultLayout';
|
import { buildOrderedVisibleTeamGraphOwnerIds } from '@shared/utils/teamGraphDefaultLayout';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -41,6 +45,7 @@ import {
|
||||||
import {
|
import {
|
||||||
isTaskBlocked,
|
isTaskBlocked,
|
||||||
isTaskInReviewCycle,
|
isTaskInReviewCycle,
|
||||||
|
resolveTaskGraphColumn,
|
||||||
resolveTaskReviewer,
|
resolveTaskReviewer,
|
||||||
} from '../../core/domain/taskGraphSemantics';
|
} from '../../core/domain/taskGraphSemantics';
|
||||||
|
|
||||||
|
|
@ -541,8 +546,17 @@ export class TeamGraphAdapter {
|
||||||
spawn,
|
spawn,
|
||||||
pendingApprovalAgents?.has(member.name) ?? false
|
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({
|
const launchPresentation = buildMemberLaunchPresentation({
|
||||||
member,
|
member: presentationMember,
|
||||||
spawnStatus: spawn?.status,
|
spawnStatus: spawn?.status,
|
||||||
spawnLaunchState: spawn?.launchState,
|
spawnLaunchState: spawn?.launchState,
|
||||||
spawnLivenessSource: spawn?.livenessSource,
|
spawnLivenessSource: spawn?.livenessSource,
|
||||||
|
|
@ -579,10 +593,8 @@ export class TeamGraphAdapter {
|
||||||
? (launchPresentation.launchStatusLabel ?? undefined)
|
? (launchPresentation.launchStatusLabel ?? undefined)
|
||||||
: undefined,
|
: undefined,
|
||||||
avatarUrl: resolveMemberAvatarUrl(member, avatarMap, 96),
|
avatarUrl: resolveMemberAvatarUrl(member, avatarMap, 96),
|
||||||
currentTaskId: member.currentTaskId ?? undefined,
|
currentTaskId: displayableCurrentTask?.id,
|
||||||
currentTaskSubject: member.currentTaskId
|
currentTaskSubject: displayableCurrentTask?.subject,
|
||||||
? data.tasks.find((t) => t.id === member.currentTaskId)?.subject
|
|
||||||
: undefined,
|
|
||||||
pendingApproval: pendingApprovalAgents?.has(member.name) ?? false,
|
pendingApproval: pendingApprovalAgents?.has(member.name) ?? false,
|
||||||
exceptionTone: exception?.exceptionTone,
|
exceptionTone: exception?.exceptionTone,
|
||||||
exceptionLabel: exception?.exceptionLabel,
|
exceptionLabel: exception?.exceptionLabel,
|
||||||
|
|
@ -632,12 +644,20 @@ export class TeamGraphAdapter {
|
||||||
leadName?: string,
|
leadName?: string,
|
||||||
activeTaskLogActivity?: Record<string, true>
|
activeTaskLogActivity?: Record<string, true>
|
||||||
): void {
|
): void {
|
||||||
const taskStateById = new Map<string, Pick<TeamGraphData['tasks'][number], 'status'>>();
|
const taskStateById = new Map<
|
||||||
|
string,
|
||||||
|
Pick<TeamGraphData['tasks'][number], 'status' | 'reviewState' | 'kanbanColumn' | 'deletedAt'>
|
||||||
|
>();
|
||||||
const taskDisplayIds = new Map<string, string>();
|
const taskDisplayIds = new Map<string, string>();
|
||||||
const memberColorByName = new Map<string, string>();
|
const memberColorByName = new Map<string, string>();
|
||||||
|
|
||||||
for (const t of data.tasks) {
|
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)}`);
|
taskDisplayIds.set(t.id, t.displayId ?? `#${t.id.slice(0, 6)}`);
|
||||||
}
|
}
|
||||||
for (const member of data.members) {
|
for (const member of data.members) {
|
||||||
|
|
@ -660,9 +680,19 @@ export class TeamGraphAdapter {
|
||||||
const kanbanTaskState = data.kanbanState.tasks[task.id];
|
const kanbanTaskState = data.kanbanState.tasks[task.id];
|
||||||
const reviewerName = resolveTaskReviewer(task, kanbanTaskState);
|
const reviewerName = resolveTaskReviewer(task, kanbanTaskState);
|
||||||
const isReviewCycle = isTaskInReviewCycle(task);
|
const isReviewCycle = isTaskInReviewCycle(task);
|
||||||
|
const graphColumn = resolveTaskGraphColumn(task);
|
||||||
const taskStatus = TeamGraphAdapter.#mapTaskStatusLiteral(task.status);
|
const taskStatus =
|
||||||
const reviewState = TeamGraphAdapter.#mapReviewState(task.reviewState);
|
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
|
const blockedByDisplayIds = task.blockedBy?.length
|
||||||
? task.blockedBy.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`)
|
? task.blockedBy.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`)
|
||||||
|
|
@ -686,7 +716,8 @@ export class TeamGraphAdapter {
|
||||||
kind: 'task',
|
kind: 'task',
|
||||||
label: task.displayId ?? `#${task.id.slice(0, 6)}`,
|
label: task.displayId ?? `#${task.id.slice(0, 6)}`,
|
||||||
sublabel: task.subject,
|
sublabel: task.subject,
|
||||||
state: TeamGraphAdapter.#mapTaskStatus(task.status),
|
state:
|
||||||
|
graphColumn === 'approved' ? 'complete' : TeamGraphAdapter.#mapTaskStatus(task.status),
|
||||||
taskStatus,
|
taskStatus,
|
||||||
reviewState,
|
reviewState,
|
||||||
reviewerName: isReviewCycle ? reviewerName : null,
|
reviewerName: isReviewCycle ? reviewerName : null,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
buildMemberAvatarMap,
|
buildMemberAvatarMap,
|
||||||
buildMemberLaunchPresentation,
|
buildMemberLaunchPresentation,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
|
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
||||||
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
|
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
|
||||||
import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react';
|
import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -309,6 +310,17 @@ const MemberPopoverContent = ({
|
||||||
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
|
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
|
||||||
const avatarSrc = node.avatarUrl ?? avatarMap.get(memberName) ?? agentAvatarUrl(memberName, 64);
|
const avatarSrc = node.avatarUrl ?? avatarMap.get(memberName) ?? agentAvatarUrl(memberName, 64);
|
||||||
const member = teamMembers.find((candidate) => candidate.name === memberName) ?? null;
|
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 =
|
const provisioningPresentation =
|
||||||
teamData && teamName
|
teamData && teamName
|
||||||
? buildTeamProvisioningPresentation({
|
? buildTeamProvisioningPresentation({
|
||||||
|
|
@ -320,7 +332,10 @@ const MemberPopoverContent = ({
|
||||||
: null;
|
: null;
|
||||||
const launchPresentation = member
|
const launchPresentation = member
|
||||||
? buildMemberLaunchPresentation({
|
? buildMemberLaunchPresentation({
|
||||||
member,
|
member:
|
||||||
|
member.currentTaskId && !displayableCurrentTask
|
||||||
|
? { ...member, currentTaskId: null }
|
||||||
|
: member,
|
||||||
spawnStatus: spawnEntry?.status,
|
spawnStatus: spawnEntry?.status,
|
||||||
spawnLaunchState: spawnEntry?.launchState,
|
spawnLaunchState: spawnEntry?.launchState,
|
||||||
spawnLivenessSource: spawnEntry?.livenessSource,
|
spawnLivenessSource: spawnEntry?.livenessSource,
|
||||||
|
|
@ -444,7 +459,7 @@ const MemberPopoverContent = ({
|
||||||
{/* Context usage stays hidden for now because lead context telemetry is still incomplete. */}
|
{/* Context usage stays hidden for now because lead context telemetry is still incomplete. */}
|
||||||
|
|
||||||
{/* Current task indicator — reuses same pattern as MemberCard */}
|
{/* Current task indicator — reuses same pattern as MemberCard */}
|
||||||
{node.currentTaskId && node.currentTaskSubject && (
|
{currentTaskIndicatorId && currentTaskIndicatorSubject && (
|
||||||
<div className="mt-2 flex items-center gap-1.5 text-[10px]">
|
<div className="mt-2 flex items-center gap-1.5 text-[10px]">
|
||||||
<Loader2
|
<Loader2
|
||||||
className="size-3 shrink-0 animate-spin"
|
className="size-3 shrink-0 animate-spin"
|
||||||
|
|
@ -457,13 +472,13 @@ const MemberPopoverContent = ({
|
||||||
style={{ border: `1px solid ${node.color ?? '#66ccff'}40` }}
|
style={{ border: `1px solid ${node.color ?? '#66ccff'}40` }}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onOpenTask?.(node.currentTaskId!);
|
onOpenTask?.(currentTaskIndicatorId);
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{node.currentTaskSubject.length > 30
|
{currentTaskIndicatorSubject.length > 30
|
||||||
? `${node.currentTaskSubject.slice(0, 30)}…`
|
? `${currentTaskIndicatorSubject.slice(0, 30)}…`
|
||||||
: node.currentTaskSubject}
|
: currentTaskIndicatorSubject}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { KanbanTaskCard } from '@renderer/components/team/kanban/KanbanTaskCard';
|
import { KanbanTaskCard } from '@renderer/components/team/kanban/KanbanTaskCard';
|
||||||
|
import { isTeamTaskNeedsFixActionable } from '@shared/utils/teamTaskState';
|
||||||
|
|
||||||
import { isTaskBlocked, resolveTaskGraphColumn } from '../../core/domain/taskGraphSemantics';
|
import { isTaskBlocked, resolveTaskGraphColumn } from '../../core/domain/taskGraphSemantics';
|
||||||
import { useGraphActivityContext } from '../hooks/useGraphActivityContext';
|
import { useGraphActivityContext } from '../hooks/useGraphActivityContext';
|
||||||
|
|
@ -49,7 +50,7 @@ function getGlowStyle(task: TeamTask, taskMap: ReadonlyMap<string, TeamTask>): R
|
||||||
boxShadow: '0 0 14px rgba(59, 130, 246, 0.4), inset 0 0 6px rgba(59, 130, 246, 0.08)',
|
boxShadow: '0 0 14px rgba(59, 130, 246, 0.4), inset 0 0 6px rgba(59, 130, 246, 0.08)',
|
||||||
};
|
};
|
||||||
case 'review':
|
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(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)' };
|
: { boxShadow: '0 0 14px rgba(245, 158, 11, 0.4), inset 0 0 6px rgba(245, 158, 11, 0.08)' };
|
||||||
case 'approved':
|
case 'approved':
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,10 @@
|
||||||
|
import {
|
||||||
|
getTeamTaskWorkflowColumn,
|
||||||
|
isTeamTaskFinishedForDependency,
|
||||||
|
isTeamTaskNeedsFixActionable,
|
||||||
|
isTeamTaskTerminalForActionableWork,
|
||||||
|
} from '@shared/utils/teamTaskState';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildAgendaFingerprintPayload,
|
buildAgendaFingerprintPayload,
|
||||||
canonicalizeAgendaFingerprintPayload,
|
canonicalizeAgendaFingerprintPayload,
|
||||||
|
|
@ -19,6 +26,7 @@ export interface MemberWorkSyncTaskLike {
|
||||||
status: string;
|
status: string;
|
||||||
owner?: string | null;
|
owner?: string | null;
|
||||||
reviewState?: string | null;
|
reviewState?: string | null;
|
||||||
|
kanbanColumn?: string | null;
|
||||||
needsClarification?: 'lead' | 'user' | null;
|
needsClarification?: 'lead' | 'user' | null;
|
||||||
blockedBy?: string[];
|
blockedBy?: string[];
|
||||||
blocks?: string[];
|
blocks?: string[];
|
||||||
|
|
@ -45,10 +53,6 @@ export interface BuildActionableWorkAgendaInput {
|
||||||
hash: (canonicalPayload: string) => string;
|
hash: (canonicalPayload: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCompletedOrDeleted(task: MemberWorkSyncTaskLike): boolean {
|
|
||||||
return task.status === 'completed' || task.status === 'deleted' || Boolean(task.deletedAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getActiveMemberNames(members: MemberWorkSyncMemberLike[]): Set<string> {
|
function getActiveMemberNames(members: MemberWorkSyncMemberLike[]): Set<string> {
|
||||||
return new Set(
|
return new Set(
|
||||||
members
|
members
|
||||||
|
|
@ -114,7 +118,9 @@ export function buildActionableWorkAgenda(
|
||||||
|
|
||||||
if (activeMemberNames.has(memberName)) {
|
if (activeMemberNames.has(memberName)) {
|
||||||
for (const task of input.tasks) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,7 +134,7 @@ export function buildActionableWorkAgenda(
|
||||||
const dependency = tasksByReference.get(dependencyId) ?? null;
|
const dependency = tasksByReference.get(dependencyId) ?? null;
|
||||||
if (!dependency || dependency.status === 'deleted' || dependency.deletedAt) {
|
if (!dependency || dependency.status === 'deleted' || dependency.deletedAt) {
|
||||||
brokenDependencyIds.push(dependencyId);
|
brokenDependencyIds.push(dependencyId);
|
||||||
} else if (dependency.status !== 'completed') {
|
} else if (!isTeamTaskFinishedForDependency(dependency)) {
|
||||||
waitingDependencyIds.push(dependencyId);
|
waitingDependencyIds.push(dependencyId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -174,11 +180,13 @@ export function buildActionableWorkAgenda(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reviewOwner = resolveCurrentReviewOwner({
|
const reviewOwner = isReviewWorkflow
|
||||||
reviewState: task.reviewState,
|
? resolveCurrentReviewOwner({
|
||||||
kanbanReviewer: input.kanbanReviewersByTaskId?.[task.id] ?? null,
|
reviewState: task.reviewState,
|
||||||
historyEvents: task.historyEvents,
|
kanbanReviewer: input.kanbanReviewersByTaskId?.[task.id] ?? null,
|
||||||
});
|
historyEvents: task.historyEvents,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
if (reviewOwner && sameMemberName(reviewOwner.reviewer, memberName)) {
|
if (reviewOwner && sameMemberName(reviewOwner.reviewer, memberName)) {
|
||||||
items.push({
|
items.push({
|
||||||
|
|
@ -199,6 +207,10 @@ export function buildActionableWorkAgenda(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isReviewWorkflow) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!sameMemberName(owner, memberName)) {
|
if (!sameMemberName(owner, memberName)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -214,18 +226,17 @@ export function buildActionableWorkAgenda(
|
||||||
if (
|
if (
|
||||||
task.status === 'pending' ||
|
task.status === 'pending' ||
|
||||||
task.status === 'in_progress' ||
|
task.status === 'in_progress' ||
|
||||||
task.reviewState === 'needsFix'
|
isTeamTaskNeedsFixActionable(task)
|
||||||
) {
|
) {
|
||||||
items.push({
|
items.push({
|
||||||
...base,
|
...base,
|
||||||
kind: 'work',
|
kind: 'work',
|
||||||
priority: 'normal',
|
priority: 'normal',
|
||||||
reason:
|
reason: isTeamTaskNeedsFixActionable(task)
|
||||||
task.reviewState === 'needsFix'
|
? 'review_changes_requested'
|
||||||
? 'review_changes_requested'
|
: task.status === 'pending'
|
||||||
: task.status === 'pending'
|
? 'owned_pending_task'
|
||||||
? 'owned_pending_task'
|
: 'owned_in_progress_task',
|
||||||
: 'owned_in_progress_task',
|
|
||||||
evidence: {
|
evidence: {
|
||||||
status: task.status,
|
status: task.status,
|
||||||
owner: memberName,
|
owner: memberName,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ export interface ReviewHistoryEventLike {
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
actor?: string;
|
actor?: string;
|
||||||
reviewer?: string;
|
reviewer?: string;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CurrentReviewOwner {
|
export interface CurrentReviewOwner {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
import {
|
||||||
|
getTeamTaskWorkflowColumn,
|
||||||
|
isTeamTaskTerminalForActionableWork,
|
||||||
|
} from '@shared/utils/teamTaskState';
|
||||||
|
|
||||||
import { normalizeMemberName, resolveCurrentReviewOwner } from '../../../core/domain';
|
import { normalizeMemberName, resolveCurrentReviewOwner } from '../../../core/domain';
|
||||||
|
|
||||||
|
|
@ -20,10 +24,6 @@ export interface MemberWorkSyncTaskImpactResolverResult {
|
||||||
diagnostics: string[];
|
diagnostics: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTerminalTask(task: Pick<TeamTask, 'status' | 'deletedAt'>): boolean {
|
|
||||||
return task.status === 'completed' || task.status === 'deleted' || Boolean(task.deletedAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDeletedTask(task: Pick<TeamTask, 'status' | 'deletedAt'>): boolean {
|
function isDeletedTask(task: Pick<TeamTask, 'status' | 'deletedAt'>): boolean {
|
||||||
return task.status === 'deleted' || Boolean(task.deletedAt);
|
return task.status === 'deleted' || Boolean(task.deletedAt);
|
||||||
}
|
}
|
||||||
|
|
@ -127,13 +127,6 @@ export class MemberWorkSyncTaskImpactResolver {
|
||||||
|
|
||||||
addMember(task.owner);
|
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)) {
|
if (!normalizeMemberName(task.owner)) {
|
||||||
addLead();
|
addLead();
|
||||||
addDiagnostic('task_owner_missing');
|
addDiagnostic('task_owner_missing');
|
||||||
|
|
@ -142,7 +135,23 @@ export class MemberWorkSyncTaskImpactResolver {
|
||||||
addDiagnostic('task_owner_inactive');
|
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();
|
addLead();
|
||||||
addDiagnostic('task_reviewer_missing');
|
addDiagnostic('task_reviewer_missing');
|
||||||
}
|
}
|
||||||
|
|
@ -166,7 +175,14 @@ export class MemberWorkSyncTaskImpactResolver {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const candidate of tasks) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,13 @@ export class TeamTaskAgendaSource implements MemberWorkSyncAgendaSourcePort {
|
||||||
teamName: input.teamName,
|
teamName: input.teamName,
|
||||||
memberName: input.memberName,
|
memberName: input.memberName,
|
||||||
generatedAt: this.deps.clock.now().toISOString(),
|
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),
|
members: members.map(toMemberLike),
|
||||||
kanbanReviewersByTaskId: Object.fromEntries(
|
kanbanReviewersByTaskId: Object.fromEntries(
|
||||||
Object.entries(kanban.tasks).map(([taskId, value]) => [taskId, value.reviewer ?? null])
|
Object.entries(kanban.tasks).map(([taskId, value]) => [taskId, value.reviewer ?? null])
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSema
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
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 { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||||
|
|
@ -62,6 +62,7 @@ import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificatio
|
||||||
import { TeamTaskReader } from './TeamTaskReader';
|
import { TeamTaskReader } from './TeamTaskReader';
|
||||||
import { TeamTaskWriter } from './TeamTaskWriter';
|
import { TeamTaskWriter } from './TeamTaskWriter';
|
||||||
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
|
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
|
||||||
|
import { getTeamTaskWorkflowColumn, selectCurrentActiveTeamTask } from './teamTaskActiveState';
|
||||||
|
|
||||||
import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes';
|
import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes';
|
||||||
import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository';
|
import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository';
|
||||||
|
|
@ -550,13 +551,7 @@ export class TeamDataService {
|
||||||
const launchIdentity = teamMeta?.launchIdentity;
|
const launchIdentity = teamMeta?.launchIdentity;
|
||||||
const leadName = 'team-lead';
|
const leadName = 'team-lead';
|
||||||
const ownedTasks = tasks.filter((task) => task.owner === leadName);
|
const ownedTasks = tasks.filter((task) => task.owner === leadName);
|
||||||
const currentTask =
|
const currentTask = selectCurrentActiveTeamTask(ownedTasks);
|
||||||
ownedTasks.find(
|
|
||||||
(task) =>
|
|
||||||
task.status === 'in_progress' &&
|
|
||||||
task.reviewState !== 'approved' &&
|
|
||||||
task.kanbanColumn !== 'approved'
|
|
||||||
) ?? null;
|
|
||||||
|
|
||||||
members.unshift({
|
members.unshift({
|
||||||
name: leadName,
|
name: leadName,
|
||||||
|
|
@ -600,12 +595,24 @@ export class TeamDataService {
|
||||||
task: Pick<TeamTask, 'reviewState' | 'historyEvents' | 'status'>,
|
task: Pick<TeamTask, 'reviewState' | 'historyEvents' | 'status'>,
|
||||||
kanbanTaskState?: KanbanState['tasks'][string]
|
kanbanTaskState?: KanbanState['tasks'][string]
|
||||||
): 'none' | 'review' | 'needsFix' | 'approved' {
|
): 'none' | 'review' | 'needsFix' | 'approved' {
|
||||||
return getReviewStateFromTask({
|
const kanbanColumn = kanbanTaskState?.column;
|
||||||
|
const reviewState = getReviewStateFromTask({
|
||||||
historyEvents: task.historyEvents,
|
historyEvents: task.historyEvents,
|
||||||
reviewState: task.reviewState,
|
reviewState: task.reviewState,
|
||||||
status: task.status,
|
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(
|
private attachKanbanCompatibility(
|
||||||
|
|
@ -614,14 +621,27 @@ export class TeamDataService {
|
||||||
): TeamTaskWithKanban {
|
): TeamTaskWithKanban {
|
||||||
const reviewState = this.resolveTaskReviewState(task, kanbanTaskState);
|
const reviewState = this.resolveTaskReviewState(task, kanbanTaskState);
|
||||||
const reviewer = this.resolveReviewerFromHistory(task, kanbanTaskState, reviewState) ?? null;
|
const reviewer = this.resolveReviewerFromHistory(task, kanbanTaskState, reviewState) ?? null;
|
||||||
|
const kanbanColumn = this.resolveTaskKanbanColumn(task, kanbanTaskState, reviewState);
|
||||||
return {
|
return {
|
||||||
...task,
|
...task,
|
||||||
reviewState,
|
reviewState,
|
||||||
kanbanColumn: getKanbanColumnFromReviewState(reviewState),
|
...(kanbanColumn ? { kanbanColumn } : {}),
|
||||||
reviewer,
|
reviewer,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveTaskKanbanColumn(
|
||||||
|
task: Pick<TeamTask, 'status'>,
|
||||||
|
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.
|
* Extract reviewer name from the current review cycle history.
|
||||||
* For legacy boards that stored reviewer only in kanban state, preserve that
|
* 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 info = teamInfoMap.get(task.teamName)!;
|
||||||
const kanbanTaskState = kanbanByTeam.get(task.teamName)?.tasks[task.id];
|
const kanbanTaskState = kanbanByTeam.get(task.teamName)?.tasks[task.id];
|
||||||
const reviewState = this.resolveTaskReviewState(task, kanbanTaskState);
|
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).
|
// 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.
|
// Return a "light" task object and defer heavy details to team/task detail views.
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import {
|
||||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
||||||
|
|
||||||
|
import { selectCurrentActiveTeamTask } from './teamTaskActiveState';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
PersistedTeamLaunchSnapshot,
|
PersistedTeamLaunchSnapshot,
|
||||||
TeamConfig,
|
TeamConfig,
|
||||||
|
|
@ -282,13 +284,7 @@ export class TeamMemberResolver {
|
||||||
const members: TeamMemberSnapshot[] = [];
|
const members: TeamMemberSnapshot[] = [];
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
const ownedTasks = tasks.filter((task) => task.owner === name);
|
const ownedTasks = tasks.filter((task) => task.owner === name);
|
||||||
const currentTask =
|
const currentTask = selectCurrentActiveTeamTask(ownedTasks);
|
||||||
ownedTasks.find(
|
|
||||||
(task) =>
|
|
||||||
task.status === 'in_progress' &&
|
|
||||||
task.reviewState !== 'approved' &&
|
|
||||||
task.kanbanColumn !== 'approved'
|
|
||||||
) ?? null;
|
|
||||||
const configMember = configMemberMap.get(name);
|
const configMember = configMemberMap.get(name);
|
||||||
const metaMember = metaMemberMap.get(name);
|
const metaMember = metaMemberMap.get(name);
|
||||||
const launchMember = launchMemberMap.get(name);
|
const launchMember = launchMemberMap.get(name);
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,8 @@ const QUOTA_EXHAUSTED_TOKENS = [
|
||||||
'quota exceeded',
|
'quota exceeded',
|
||||||
'quota exhausted',
|
'quota exhausted',
|
||||||
'insufficient credits',
|
'insufficient credits',
|
||||||
|
'key limit exceeded',
|
||||||
|
'total limit',
|
||||||
];
|
];
|
||||||
const RATE_LIMITED_TOKENS = [
|
const RATE_LIMITED_TOKENS = [
|
||||||
'rate limit',
|
'rate limit',
|
||||||
|
|
@ -83,6 +85,18 @@ const PROVIDER_OVERLOADED_TOKENS = [
|
||||||
'service unavailable',
|
'service unavailable',
|
||||||
'503',
|
'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');
|
const logger = createLogger('Service:TeamMemberRuntimeAdvisory');
|
||||||
|
|
||||||
interface CachedRuntimeAdvisory {
|
interface CachedRuntimeAdvisory {
|
||||||
|
|
@ -123,6 +137,9 @@ function classifyRetryReason(message: string | undefined): MemberRuntimeAdvisory
|
||||||
if (includesAnyToken(normalized, PROVIDER_OVERLOADED_TOKENS)) {
|
if (includesAnyToken(normalized, PROVIDER_OVERLOADED_TOKENS)) {
|
||||||
return 'provider_overloaded';
|
return 'provider_overloaded';
|
||||||
}
|
}
|
||||||
|
if (includesAnyToken(normalized, PROTOCOL_PROOF_MISSING_TOKENS)) {
|
||||||
|
return 'protocol_proof_missing';
|
||||||
|
}
|
||||||
return 'backend_error';
|
return 'backend_error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,8 +131,7 @@ function buildSyntheticBootstrapMessages(config: TeamConfig): InboxMessage[] {
|
||||||
member &&
|
member &&
|
||||||
member.name?.trim() &&
|
member.name?.trim() &&
|
||||||
member.name.trim().toLowerCase() !== normalizedLeadName &&
|
member.name.trim().toLowerCase() !== normalizedLeadName &&
|
||||||
member.removedAt == null &&
|
member.removedAt == null
|
||||||
(member as { isActive?: unknown }).isActive !== false
|
|
||||||
)
|
)
|
||||||
.map((member) => ({
|
.map((member) => ({
|
||||||
from: leadName,
|
from: leadName,
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,12 @@ import { createLogger } from '@shared/utils/logger';
|
||||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||||
import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection';
|
import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
|
import {
|
||||||
|
getTeamTaskWorkflowColumn,
|
||||||
|
isTeamTaskActivelyWorked,
|
||||||
|
isTeamTaskDeleted,
|
||||||
|
isTeamTaskNeedsFixActionable,
|
||||||
|
} from '@shared/utils/teamTaskState';
|
||||||
import {
|
import {
|
||||||
isTeamInternalControlMessageText,
|
isTeamInternalControlMessageText,
|
||||||
stripExactInternalControlEchoPrefix,
|
stripExactInternalControlEchoPrefix,
|
||||||
|
|
@ -163,7 +169,10 @@ import {
|
||||||
type OpenCodePromptDeliveryLedgerStore,
|
type OpenCodePromptDeliveryLedgerStore,
|
||||||
type OpenCodePromptDeliveryStatus,
|
type OpenCodePromptDeliveryStatus,
|
||||||
} from './opencode/delivery/OpenCodePromptDeliveryLedger';
|
} from './opencode/delivery/OpenCodePromptDeliveryLedger';
|
||||||
import { selectOpenCodeRuntimeDeliveryReason } from './opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics';
|
import {
|
||||||
|
isActionRequiredOpenCodeRuntimeDeliveryReason,
|
||||||
|
selectOpenCodeRuntimeDeliveryReason,
|
||||||
|
} from './opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics';
|
||||||
import {
|
import {
|
||||||
decideOpenCodePromptDeliveryRepair,
|
decideOpenCodePromptDeliveryRepair,
|
||||||
type OpenCodePromptDeliveryHardFailureKind,
|
type OpenCodePromptDeliveryHardFailureKind,
|
||||||
|
|
@ -4392,16 +4401,34 @@ function getAgentLanguageInstruction(): string {
|
||||||
return `IMPORTANT: Communicate in ${languageName}. All messages, summaries, and task descriptions MUST be in ${languageName}.`;
|
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. */
|
/** Build a full task board snapshot for the lead. */
|
||||||
function buildTaskBoardSnapshot(tasks: TeamTask[]): string {
|
function buildTaskBoardSnapshot(tasks: TeamTask[]): string {
|
||||||
const active = tasks.filter(
|
const active = tasks.filter(isTaskBoardSnapshotWorkCandidate);
|
||||||
(t) => (t.status === 'pending' || t.status === 'in_progress') && !t.id.startsWith('_internal')
|
|
||||||
);
|
|
||||||
if (active.length === 0) return '\nNo pending tasks on the board.\n';
|
if (active.length === 0) return '\nNo pending tasks on the board.\n';
|
||||||
|
|
||||||
const lines = active.map((t) => {
|
const lines = active.map((t) => {
|
||||||
const owner = t.owner ? ` (owner: ${t.owner})` : ' (unassigned)';
|
const owner = t.owner ? ` (owner: ${t.owner})` : ' (unassigned)';
|
||||||
const desc = t.description ? ` — ${t.description.slice(0, 120)}` : '';
|
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
|
const deps = t.blockedBy?.length
|
||||||
? ` [blocked by: ${t.blockedBy
|
? ` [blocked by: ${t.blockedBy
|
||||||
.map((id) => tasks.find((candidate) => candidate.id === id))
|
.map((id) => tasks.find((candidate) => candidate.id === id))
|
||||||
|
|
@ -4409,9 +4436,9 @@ function buildTaskBoardSnapshot(tasks: TeamTask[]): string {
|
||||||
.map((task) => formatTaskDisplayLabel(task))
|
.map((task) => formatTaskDisplayLabel(task))
|
||||||
.join(', ')}]`
|
.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(
|
function buildDeterministicLaunchHydrationPrompt(
|
||||||
|
|
@ -7523,16 +7550,56 @@ export class TeamProvisioningService {
|
||||||
...extra,
|
...extra,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
if (
|
const shouldNotifyTerminalFailure =
|
||||||
event === 'opencode_prompt_delivery_terminal_failure' &&
|
event === 'opencode_prompt_delivery_terminal_failure' && record.status === 'failed_terminal';
|
||||||
record.status === 'failed_terminal'
|
const shouldNotifyActionRequiredRetry =
|
||||||
) {
|
!shouldNotifyTerminalFailure &&
|
||||||
|
this.shouldNotifyOpenCodeRuntimeDeliveryBeforeTerminal(record);
|
||||||
|
if (shouldNotifyTerminalFailure || shouldNotifyActionRequiredRetry) {
|
||||||
void this.fireOpenCodeRuntimeDeliveryErrorNotification(record).catch((error) => {
|
void this.fireOpenCodeRuntimeDeliveryErrorNotification(record).catch((error) => {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[${record.teamName}] Failed to fire OpenCode runtime delivery error notification for ${record.memberName}: ${getErrorMessage(error)}`
|
`[${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(
|
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();
|
const now = Date.now();
|
||||||
this.pruneOpenCodeRuntimeDeliveryAdvisoryEventDedupe(now);
|
this.pruneOpenCodeRuntimeDeliveryAdvisoryEventDedupe(now);
|
||||||
if (this.openCodeRuntimeDeliveryAdvisoryEventSentAt.has(eventKey)) {
|
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: {
|
private async notifyLeadAboutOpenCodeRuntimeDeliveryError(input: {
|
||||||
record: OpenCodePromptDeliveryLedgerRecord;
|
record: OpenCodePromptDeliveryLedgerRecord;
|
||||||
reason: string;
|
reason: string;
|
||||||
|
|
@ -27536,18 +27618,14 @@ export class TeamProvisioningService {
|
||||||
try {
|
try {
|
||||||
const taskReader = new TeamTaskReader();
|
const taskReader = new TeamTaskReader();
|
||||||
const tasks = await taskReader.getTasks(run.teamName);
|
const tasks = await taskReader.getTasks(run.teamName);
|
||||||
const active = tasks.filter(
|
const active = tasks.filter(isTaskBoardSnapshotWorkCandidate);
|
||||||
(t) =>
|
|
||||||
(t.status === 'pending' || t.status === 'in_progress') &&
|
|
||||||
!t.id.startsWith('_internal')
|
|
||||||
);
|
|
||||||
if (active.length === 0) return;
|
if (active.length === 0) return;
|
||||||
|
|
||||||
const board = buildTaskBoardSnapshot(tasks);
|
const board = buildTaskBoardSnapshot(tasks);
|
||||||
const message = [
|
const message = [
|
||||||
`Reconnected and ready. Begin executing tasks now.`,
|
`Reconnected and ready. Begin executing tasks now.`,
|
||||||
`Execute tasks sequentially and keep the board + user updated:`,
|
`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.`,
|
`- If the task is unassigned, set yourself as owner.`,
|
||||||
`- BEFORE doing any work on a task: mark it started (in_progress).`,
|
`- BEFORE doing any work on a task: mark it started (in_progress).`,
|
||||||
`- Immediately SendMessage "user" that you started task #<id> (what you're doing + next step).`,
|
`- Immediately SendMessage "user" that you started task #<id> (what you're doing + next step).`,
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,25 @@ const GENERIC_DELIVERY_DIAGNOSTIC_TOKENS = [
|
||||||
'visible_reply_ack_only_still_requires_answer',
|
'visible_reply_ack_only_still_requires_answer',
|
||||||
'visible_reply_destination_not_found_yet',
|
'visible_reply_destination_not_found_yet',
|
||||||
'visible_reply_missing_relayofmessageid',
|
'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;
|
] as const;
|
||||||
|
|
||||||
export function normalizeOpenCodeRuntimeDeliveryDiagnostic(
|
export function normalizeOpenCodeRuntimeDeliveryDiagnostic(
|
||||||
|
|
@ -61,6 +80,16 @@ export function selectOpenCodeRuntimeDeliveryReason(
|
||||||
return normalized.length > 0 ? 'OpenCode runtime delivery did not complete.' : null;
|
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(
|
function getOpenCodeRuntimeDeliveryStateFallback(
|
||||||
record: OpenCodePromptDeliveryLedgerRecord
|
record: OpenCodePromptDeliveryLedgerRecord
|
||||||
): string | null {
|
): string | null {
|
||||||
|
|
@ -88,6 +117,9 @@ function getOpenCodeRuntimeDeliveryStateFallback(
|
||||||
) {
|
) {
|
||||||
return 'OpenCode created a reply without the required relayOfMessageId correlation.';
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ export class TeamTaskStallNotifier {
|
||||||
taskRefs: [args.alert.taskRef],
|
taskRefs: [args.alert.taskRef],
|
||||||
actionMode: 'do',
|
actionMode: 'do',
|
||||||
source: 'system_notification',
|
source: 'system_notification',
|
||||||
|
messageKind: 'task_stall_remediation',
|
||||||
};
|
};
|
||||||
await this.inboxWriter.sendMessage(args.teamName, request);
|
await this.inboxWriter.sendMessage(args.teamName, request);
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { isBoardTaskExactLogsReadEnabled } from '../taskLogs/exact/featureGates'
|
||||||
import { TeamKanbanManager } from '../TeamKanbanManager';
|
import { TeamKanbanManager } from '../TeamKanbanManager';
|
||||||
import { TeamMembersMetaStore } from '../TeamMembersMetaStore';
|
import { TeamMembersMetaStore } from '../TeamMembersMetaStore';
|
||||||
import { TeamTaskReader } from '../TeamTaskReader';
|
import { TeamTaskReader } from '../TeamTaskReader';
|
||||||
|
import { getTeamTaskWorkflowColumn, isTeamTaskActivelyWorked } from '../teamTaskActiveState';
|
||||||
|
|
||||||
import { BoardTaskActivityBatchIndexer } from './BoardTaskActivityBatchIndexer';
|
import { BoardTaskActivityBatchIndexer } from './BoardTaskActivityBatchIndexer';
|
||||||
import { OpenCodeTaskStallEvidenceSource } from './OpenCodeTaskStallEvidenceSource';
|
import { OpenCodeTaskStallEvidenceSource } from './OpenCodeTaskStallEvidenceSource';
|
||||||
|
|
@ -87,12 +88,47 @@ export class TeamTaskStallSnapshotSource {
|
||||||
this.kanbanManager.getState(teamName),
|
this.kanbanManager.getState(teamName),
|
||||||
this.membersMetaStore.getMembers(teamName).catch(() => []),
|
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 allTasksById = new Map(allTasks.map((task) => [task.id, task] as const));
|
||||||
const inProgressTasks = activeTasks.filter(
|
const inProgressTasks = workflowActiveTasks.filter((task) => {
|
||||||
(task) => task.status === 'in_progress' && task.reviewState !== 'review'
|
const kanbanColumn = kanbanState.tasks[task.id]?.column;
|
||||||
);
|
const workflowColumn = getTeamTaskWorkflowColumn({
|
||||||
const reviewOpenTasks = activeTasks.filter((task) => task.reviewState === 'review');
|
...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 resolvedReviewersByTaskId = buildResolvedReviewerIndex(activeTasks, kanbanState);
|
||||||
const activityReadsEnabled = isBoardTaskActivityReadEnabled();
|
const activityReadsEnabled = isBoardTaskActivityReadEnabled();
|
||||||
const exactReadsEnabled = isBoardTaskExactLogsReadEnabled();
|
const exactReadsEnabled = isBoardTaskExactLogsReadEnabled();
|
||||||
|
|
@ -157,7 +193,7 @@ export class TeamTaskStallSnapshotSource {
|
||||||
transcriptFiles: transcriptContext.transcriptFiles,
|
transcriptFiles: transcriptContext.transcriptFiles,
|
||||||
activityReadsEnabled,
|
activityReadsEnabled,
|
||||||
exactReadsEnabled,
|
exactReadsEnabled,
|
||||||
activeTasks,
|
activeTasks: workflowActiveTasks,
|
||||||
deletedTasks,
|
deletedTasks,
|
||||||
allTasksById,
|
allTasksById,
|
||||||
inProgressTasks,
|
inProgressTasks,
|
||||||
|
|
|
||||||
70
src/main/services/team/teamTaskActiveState.ts
Normal file
70
src/main/services/team/teamTaskActiveState.ts
Normal file
|
|
@ -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<T extends TeamTaskWithKanban>(
|
||||||
|
tasks: readonly T[]
|
||||||
|
): T | null {
|
||||||
|
const activeTasks = tasks.filter(isTeamTaskActivelyWorked);
|
||||||
|
if (activeTasks.length === 0) return null;
|
||||||
|
return [...activeTasks].sort(compareCurrentActiveTasks)[0] ?? null;
|
||||||
|
}
|
||||||
|
|
@ -1239,7 +1239,9 @@ function normalizeFallbackReviewState(value: unknown, status: string): string {
|
||||||
if (status === 'in_progress' || status === 'deleted') return 'none';
|
if (status === 'in_progress' || status === 'deleted') return 'none';
|
||||||
if (status === 'pending') return reviewState === 'needsFix' ? 'needsFix' : 'none';
|
if (status === 'pending') return reviewState === 'needsFix' ? 'needsFix' : 'none';
|
||||||
if (status === 'completed') {
|
if (status === 'completed') {
|
||||||
return reviewState === 'review' || reviewState === 'approved' ? reviewState : 'none';
|
return reviewState === 'review' || reviewState === 'approved' || reviewState === 'needsFix'
|
||||||
|
? reviewState
|
||||||
|
: 'none';
|
||||||
}
|
}
|
||||||
return reviewState;
|
return reviewState;
|
||||||
}
|
}
|
||||||
|
|
@ -1444,9 +1446,11 @@ async function readTasksDirForTeam(
|
||||||
parsed.status === 'deleted'
|
parsed.status === 'deleted'
|
||||||
? (parsed.status as string)
|
? (parsed.status as string)
|
||||||
: 'pending';
|
: 'pending';
|
||||||
|
const derivedReviewState = deriveReviewStateFromEvents(historyEvents);
|
||||||
const reviewState =
|
const reviewState =
|
||||||
deriveReviewStateFromEvents(historyEvents) ??
|
derivedReviewState !== null
|
||||||
normalizeFallbackReviewState(parsed.reviewState, status);
|
? normalizeFallbackReviewState(derivedReviewState, status)
|
||||||
|
: normalizeFallbackReviewState(parsed.reviewState, status);
|
||||||
|
|
||||||
const task = {
|
const task = {
|
||||||
id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,10 @@ import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/membe
|
||||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||||
import { projectColor } from '@renderer/utils/projectColor';
|
import { projectColor } from '@renderer/utils/projectColor';
|
||||||
import { projectLabelFromPath } from '@renderer/utils/taskGrouping';
|
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 { format, isThisYear, isToday, isYesterday } from 'date-fns';
|
||||||
import { CheckCircle2, Circle, Eye, Loader2, ShieldCheck, Trash2 } from 'lucide-react';
|
import { CheckCircle2, Circle, Eye, Loader2, ShieldCheck, Trash2 } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
@ -105,7 +108,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
|
||||||
}
|
}
|
||||||
}, [isRenaming, displaySubject]);
|
}, [isRenaming, displaySubject]);
|
||||||
|
|
||||||
const reviewColumn = getTaskKanbanColumn(task);
|
const reviewColumn = getTeamTaskWorkflowColumn(task);
|
||||||
const cfg =
|
const cfg =
|
||||||
reviewColumn === 'approved'
|
reviewColumn === 'approved'
|
||||||
? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const)
|
? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const)
|
||||||
|
|
@ -212,7 +215,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{displaySubject}
|
{displaySubject}
|
||||||
{task.reviewState === 'needsFix' && (
|
{isTeamTaskNeedsFixActionable(task) && (
|
||||||
<span
|
<span
|
||||||
className={`ml-1.5 inline-block rounded-full px-1.5 py-0.5 align-middle text-[10px] font-medium leading-none ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
|
className={`ml-1.5 inline-block rounded-full px-1.5 py-0.5 align-middle text-[10px] font-medium leading-none ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
import { useSyncExternalStore } from 'react';
|
import { useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
import { getSnapshot, getUnreadCount, subscribe } from '@renderer/services/commentReadStorage';
|
import { getSnapshot, getUnreadCount, subscribe } from '@renderer/services/commentReadStorage';
|
||||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
import {
|
||||||
|
getTeamTaskWorkflowColumn,
|
||||||
|
isTeamTaskNeedsFixActionable,
|
||||||
|
} from '@shared/utils/teamTaskState';
|
||||||
|
|
||||||
export type TaskStatusFilterId =
|
export type TaskStatusFilterId =
|
||||||
| 'todo'
|
| 'todo'
|
||||||
|
|
@ -50,10 +53,10 @@ export function taskMatchesStatus(
|
||||||
if (statusIds.size === 0) return false;
|
if (statusIds.size === 0) return false;
|
||||||
if (statusIds.size === STATUS_OPTIONS.length) return task.status !== 'deleted';
|
if (statusIds.size === STATUS_OPTIONS.length) return task.status !== 'deleted';
|
||||||
|
|
||||||
const kanbanColumn = getTaskKanbanColumn(task);
|
const kanbanColumn = getTeamTaskWorkflowColumn(task);
|
||||||
const inNeedsFix = task.reviewState === 'needsFix';
|
const inNeedsFix = isTeamTaskNeedsFixActionable(task);
|
||||||
const inTodo = task.status === 'pending' && !kanbanColumn && !inNeedsFix;
|
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 inDone = task.status === 'completed' && !kanbanColumn && !inNeedsFix;
|
||||||
const inReview = kanbanColumn === 'review';
|
const inReview = kanbanColumn === 'review';
|
||||||
const inApproved = kanbanColumn === 'approved';
|
const inApproved = kanbanColumn === 'approved';
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,10 @@ import { useStore } from '@renderer/store';
|
||||||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||||
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
||||||
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
|
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 { formatTaskDisplayLabel, taskMatchesRef } from '@shared/utils/taskIdentity';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
|
@ -29,7 +32,7 @@ const STATUS_COLORS: Record<string, { text: string; bg: string }> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function getEffectiveColumn(task: TeamTaskWithKanban): string {
|
function getEffectiveColumn(task: TeamTaskWithKanban): string {
|
||||||
const reviewColumn = getTaskKanbanColumn(task);
|
const reviewColumn = getTeamTaskWorkflowColumn(task);
|
||||||
if (reviewColumn) return reviewColumn;
|
if (reviewColumn) return reviewColumn;
|
||||||
if (task.status === 'pending') return 'todo';
|
if (task.status === 'pending') return 'todo';
|
||||||
if (task.status === 'completed') return 'done';
|
if (task.status === 'completed') return 'done';
|
||||||
|
|
@ -159,7 +162,7 @@ export const TaskTooltip = memo(function TaskTooltip({
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{task.reviewState === 'needsFix' ? (
|
{isTeamTaskNeedsFixActionable(task) ? (
|
||||||
<span
|
<span
|
||||||
className={`inline-block rounded px-1.5 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
|
className={`inline-block rounded px-1.5 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@ import {
|
||||||
buildMemberColorMap,
|
buildMemberColorMap,
|
||||||
displayMemberName,
|
displayMemberName,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
|
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
|
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
|
||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
@ -27,7 +29,7 @@ interface ActiveTasksBlockProps {
|
||||||
|
|
||||||
interface ActivityEntry {
|
interface ActivityEntry {
|
||||||
member: ResolvedTeamMember;
|
member: ResolvedTeamMember;
|
||||||
task: TeamTaskWithKanban | undefined;
|
task: TeamTaskWithKanban;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
kind: 'working' | 'reviewing';
|
kind: 'working' | 'reviewing';
|
||||||
}
|
}
|
||||||
|
|
@ -53,8 +55,8 @@ export const ActiveTasksBlock = memo(function ActiveTasksBlock({
|
||||||
for (const m of members) {
|
for (const m of members) {
|
||||||
if (!m.currentTaskId) continue;
|
if (!m.currentTaskId) continue;
|
||||||
const task = taskMap.get(m.currentTaskId);
|
const task = taskMap.get(m.currentTaskId);
|
||||||
// Defense-in-depth: hide banner for approved/completed tasks even if currentTaskId is stale
|
// Defense-in-depth: hide stale currentTaskId until backend refresh clears it.
|
||||||
if (task && (task.reviewState === 'approved' || task.status === 'completed')) continue;
|
if (!isDisplayableCurrentTask(task)) continue;
|
||||||
workingMemberNames.add(m.name);
|
workingMemberNames.add(m.name);
|
||||||
entries.push({ member: m, task, taskId: m.currentTaskId, kind: 'working' });
|
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) {
|
for (const m of members) {
|
||||||
if (workingMemberNames.has(m.name)) continue;
|
if (workingMemberNames.has(m.name)) continue;
|
||||||
const reviewTask = tasks.find(
|
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) {
|
if (reviewTask) {
|
||||||
entries.push({ member: m, task: reviewTask, taskId: reviewTask.id, kind: 'reviewing' });
|
entries.push({ member: m, task: reviewTask, taskId: reviewTask.id, kind: 'reviewing' });
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ import {
|
||||||
getKnownSlashCommand,
|
getKnownSlashCommand,
|
||||||
parseStandaloneSlashCommand,
|
parseStandaloneSlashCommand,
|
||||||
} from '@shared/utils/slashCommands';
|
} from '@shared/utils/slashCommands';
|
||||||
|
import { isTaskStallRemediationMessage } from '@shared/utils/teamAutomationMessages';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
|
@ -379,6 +380,68 @@ const PassiveIdlePeerSummaryRow = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TaskStallRemediationRow = ({
|
||||||
|
teamName,
|
||||||
|
recipientName,
|
||||||
|
recipientColor,
|
||||||
|
taskRef,
|
||||||
|
timestamp,
|
||||||
|
onMemberNameClick,
|
||||||
|
onTaskIdClick,
|
||||||
|
}: {
|
||||||
|
teamName: string;
|
||||||
|
recipientName: string;
|
||||||
|
recipientColor?: string;
|
||||||
|
taskRef?: NonNullable<InboxMessage['taskRefs']>[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 (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5" style={{ opacity: 0.82 }}>
|
||||||
|
<span className="bg-amber-500/12 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-300">
|
||||||
|
automation
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
|
||||||
|
stall nudge
|
||||||
|
</span>
|
||||||
|
<MoveRight size={10} style={{ color: CARD_ICON_MUTED }} className="shrink-0" />
|
||||||
|
<MemberBadge
|
||||||
|
name={recipientName}
|
||||||
|
color={recipientColor}
|
||||||
|
teamName={teamName}
|
||||||
|
hideAvatar
|
||||||
|
onClick={onMemberNameClick}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[11px]" style={{ color: CARD_TEXT_LIGHT }}>
|
||||||
|
Asked teammate to continue stalled task
|
||||||
|
{taskRef && taskLabel ? (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="font-medium text-blue-300 hover:text-blue-200"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onTaskIdClick?.(taskRef.taskId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{taskLabel}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||||
|
{timestamp}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const BootstrapSystemRow = ({
|
const BootstrapSystemRow = ({
|
||||||
teamName,
|
teamName,
|
||||||
eventKind,
|
eventKind,
|
||||||
|
|
@ -926,6 +989,20 @@ export const ActivityItem = memo(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isTaskStallRemediationMessage(message)) {
|
||||||
|
return (
|
||||||
|
<TaskStallRemediationRow
|
||||||
|
teamName={teamName}
|
||||||
|
recipientName={message.to ?? 'teammate'}
|
||||||
|
recipientColor={recipientColor}
|
||||||
|
taskRef={message.taskRefs?.[0]}
|
||||||
|
timestamp={timestamp}
|
||||||
|
onMemberNameClick={onMemberNameClick}
|
||||||
|
onTaskIdClick={onTaskIdClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (bootstrapDisplay) {
|
if (bootstrapDisplay) {
|
||||||
return (
|
return (
|
||||||
<BootstrapSystemRow
|
<BootstrapSystemRow
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import {
|
||||||
extractTaskRefsFromText,
|
extractTaskRefsFromText,
|
||||||
stripEncodedTaskReferenceMetadata,
|
stripEncodedTaskReferenceMetadata,
|
||||||
} from '@renderer/utils/taskReferenceUtils';
|
} from '@renderer/utils/taskReferenceUtils';
|
||||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
|
||||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import { AlertTriangle, ChevronDown, ChevronRight, Search } from 'lucide-react';
|
import { AlertTriangle, ChevronDown, ChevronRight, Search } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -153,7 +153,7 @@ export const CreateTaskDialog = ({
|
||||||
|
|
||||||
// Only show non-internal, non-deleted tasks as candidates for blocking
|
// Only show non-internal, non-deleted tasks as candidates for blocking
|
||||||
const availableTasks = tasks.filter(
|
const availableTasks = tasks.filter(
|
||||||
(t) => t.status !== 'deleted' && getTaskKanbanColumn(t) !== 'approved'
|
(t) => t.status !== 'deleted' && getTeamTaskWorkflowColumn(t) !== 'approved'
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleBlockedBy = (taskId: string): void => {
|
const toggleBlockedBy = (taskId: string): void => {
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,11 @@ import {
|
||||||
} from '@renderer/utils/taskChangeRequest';
|
} from '@renderer/utils/taskChangeRequest';
|
||||||
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
import {
|
||||||
|
getTeamTaskWorkflowColumn,
|
||||||
|
isTeamTaskFinishedForDependency,
|
||||||
|
isTeamTaskNeedsFixActionable,
|
||||||
|
} from '@shared/utils/teamTaskState';
|
||||||
import {
|
import {
|
||||||
deriveTaskDisplayId,
|
deriveTaskDisplayId,
|
||||||
formatTaskDisplayLabel,
|
formatTaskDisplayLabel,
|
||||||
|
|
@ -598,12 +602,10 @@ export const TaskDetailDialog = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const kanbanColumn =
|
const kanbanColumn = getTeamTaskWorkflowColumn({
|
||||||
kanbanTaskState?.column ??
|
...currentTask,
|
||||||
getTaskKanbanColumn({
|
...(kanbanTaskState?.column ? { kanbanColumn: kanbanTaskState.column } : {}),
|
||||||
reviewState: currentTask.reviewState,
|
});
|
||||||
kanbanColumn: currentTask.kanbanColumn,
|
|
||||||
});
|
|
||||||
const status = currentTask.status;
|
const status = currentTask.status;
|
||||||
const statusStyle =
|
const statusStyle =
|
||||||
kanbanColumn && KANBAN_COLUMN_DISPLAY[kanbanColumn]
|
kanbanColumn && KANBAN_COLUMN_DISPLAY[kanbanColumn]
|
||||||
|
|
@ -659,13 +661,13 @@ export const TaskDetailDialog = ({
|
||||||
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal">
|
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal">
|
||||||
{formatTaskDisplayLabel(currentTask)}
|
{formatTaskDisplayLabel(currentTask)}
|
||||||
</Badge>
|
</Badge>
|
||||||
{(currentTask.reviewState === 'approved' || currentTask.reviewState === 'review') &&
|
{(kanbanColumn === 'approved' || kanbanColumn === 'review') &&
|
||||||
currentTask.reviewer &&
|
currentTask.reviewer &&
|
||||||
currentTask.reviewer !== 'user' ? (
|
currentTask.reviewer !== 'user' ? (
|
||||||
(() => {
|
(() => {
|
||||||
const reviewerColor = colorMap.get(currentTask.reviewer);
|
const reviewerColor = colorMap.get(currentTask.reviewer);
|
||||||
const colors =
|
const colors =
|
||||||
currentTask.reviewState === 'review'
|
kanbanColumn === 'review'
|
||||||
? getTeamColorSet('blue')
|
? getTeamColorSet('blue')
|
||||||
: getTeamColorSet(reviewerColor ?? '');
|
: getTeamColorSet(reviewerColor ?? '');
|
||||||
const reviewerBadgeStyle = {
|
const reviewerBadgeStyle = {
|
||||||
|
|
@ -677,7 +679,7 @@ export const TaskDetailDialog = ({
|
||||||
};
|
};
|
||||||
const lastReviewEvent = currentTask.historyEvents
|
const lastReviewEvent = currentTask.historyEvents
|
||||||
?.filter((e) =>
|
?.filter((e) =>
|
||||||
currentTask.reviewState === 'approved'
|
kanbanColumn === 'approved'
|
||||||
? e.type === 'review_approved'
|
? e.type === 'review_approved'
|
||||||
: e.type === 'review_requested' || e.type === 'review_started'
|
: e.type === 'review_requested' || e.type === 'review_started'
|
||||||
)
|
)
|
||||||
|
|
@ -731,7 +733,7 @@ export const TaskDetailDialog = ({
|
||||||
{statusLabel}
|
{statusLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{currentTask.reviewState === 'needsFix' ? (
|
{isTeamTaskNeedsFixActionable(currentTask) ? (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
|
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
|
||||||
>
|
>
|
||||||
|
|
@ -941,7 +943,9 @@ export const TaskDetailDialog = ({
|
||||||
</span>
|
</span>
|
||||||
{blockedByIds.map((id) => {
|
{blockedByIds.map((id) => {
|
||||||
const depTask = taskMap.get(id);
|
const depTask = taskMap.get(id);
|
||||||
const isCompleted = depTask?.status === 'completed';
|
const isCompleted = depTask
|
||||||
|
? isTeamTaskFinishedForDependency(depTask)
|
||||||
|
: false;
|
||||||
const label = depTask
|
const label = depTask
|
||||||
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
|
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
|
||||||
: `#${deriveTaskDisplayId(id)}`;
|
: `#${deriveTaskDisplayId(id)}`;
|
||||||
|
|
@ -977,7 +981,9 @@ export const TaskDetailDialog = ({
|
||||||
</span>
|
</span>
|
||||||
{blocksIds.map((id) => {
|
{blocksIds.map((id) => {
|
||||||
const depTask = taskMap.get(id);
|
const depTask = taskMap.get(id);
|
||||||
const isCompleted = depTask?.status === 'completed';
|
const isCompleted = depTask
|
||||||
|
? isTeamTaskFinishedForDependency(depTask)
|
||||||
|
: false;
|
||||||
const label = depTask
|
const label = depTask
|
||||||
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
|
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
|
||||||
: `#${deriveTaskDisplayId(id)}`;
|
: `#${deriveTaskDisplayId(id)}`;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
|
||||||
import { useResizableColumns } from '@renderer/hooks/useResizableColumns';
|
import { useResizableColumns } from '@renderer/hooks/useResizableColumns';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
import { isTeamTaskNeedsFixActionable } from '@shared/utils/teamTaskState';
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
|
@ -161,7 +162,7 @@ function estimateGridSkeletonCardHeight(
|
||||||
if (task.subject.length > 54) height += 10;
|
if (task.subject.length > 54) height += 10;
|
||||||
if (task.subject.length > 92) height += 8;
|
if (task.subject.length > 92) height += 8;
|
||||||
if (task.needsClarification) height += 16;
|
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.blockedBy?.length ?? 0) > 0) height += 18;
|
||||||
if ((task.blocks?.length ?? 0) > 0) height += 18;
|
if ((task.blocks?.length ?? 0) > 0) height += 18;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ import {
|
||||||
buildTaskChangeRequestOptions,
|
buildTaskChangeRequestOptions,
|
||||||
canDisplayTaskChangesForOptions,
|
canDisplayTaskChangesForOptions,
|
||||||
} from '@renderer/utils/taskChangeRequest';
|
} from '@renderer/utils/taskChangeRequest';
|
||||||
|
import {
|
||||||
|
isTeamTaskFinishedForDependency,
|
||||||
|
isTeamTaskNeedsFixActionable,
|
||||||
|
} from '@shared/utils/teamTaskState';
|
||||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import {
|
import {
|
||||||
ArrowLeftFromLine,
|
ArrowLeftFromLine,
|
||||||
|
|
@ -65,7 +69,7 @@ const DependencyBadge = ({
|
||||||
onScrollToTask,
|
onScrollToTask,
|
||||||
}: DependencyBadgeProps): React.JSX.Element => {
|
}: DependencyBadgeProps): React.JSX.Element => {
|
||||||
const depTask = taskMap.get(taskId);
|
const depTask = taskMap.get(taskId);
|
||||||
const isCompleted = depTask?.status === 'completed';
|
const isCompleted = depTask ? isTeamTaskFinishedForDependency(depTask) : false;
|
||||||
const label = depTask
|
const label = depTask
|
||||||
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
|
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
|
||||||
: `#${deriveTaskDisplayId(taskId)}`;
|
: `#${deriveTaskDisplayId(taskId)}`;
|
||||||
|
|
@ -334,7 +338,7 @@ export const KanbanTaskCard = memo(
|
||||||
{task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'}
|
{task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{task.reviewState === 'needsFix' ? (
|
{isTeamTaskNeedsFixActionable(task) ? (
|
||||||
<span
|
<span
|
||||||
className={`mt-1 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
|
className={`mt-1 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -163,8 +163,15 @@ export const MemberCard = memo(function MemberCard({
|
||||||
selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : []
|
selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : []
|
||||||
);
|
);
|
||||||
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
|
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
|
||||||
|
const presentationMember =
|
||||||
|
member.currentTaskId && !currentTask
|
||||||
|
? {
|
||||||
|
...member,
|
||||||
|
currentTaskId: null,
|
||||||
|
}
|
||||||
|
: member;
|
||||||
const launchPresentation = buildMemberLaunchPresentation({
|
const launchPresentation = buildMemberLaunchPresentation({
|
||||||
member,
|
member: presentationMember,
|
||||||
spawnStatus,
|
spawnStatus,
|
||||||
spawnLaunchState,
|
spawnLaunchState,
|
||||||
spawnLivenessSource,
|
spawnLivenessSource,
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,9 @@ import {
|
||||||
getRuntimeMemorySourceLabel,
|
getRuntimeMemorySourceLabel,
|
||||||
resolveMemberRuntimeSummary,
|
resolveMemberRuntimeSummary,
|
||||||
} from '@renderer/utils/memberRuntimeSummary';
|
} from '@renderer/utils/memberRuntimeSummary';
|
||||||
|
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
import { isTeamTaskFinishedForDependency } from '@shared/utils/teamTaskState';
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
FileText,
|
FileText,
|
||||||
|
|
@ -155,12 +157,22 @@ export const MemberDetailDialog = ({
|
||||||
}, [member, memberMessages, members, tasks, teamName]);
|
}, [member, memberMessages, members, tasks, teamName]);
|
||||||
|
|
||||||
const inProgressTasks = useMemo(
|
const inProgressTasks = useMemo(
|
||||||
() => memberTasks.filter((t) => t.status === 'in_progress').length,
|
() => memberTasks.filter(isDisplayableCurrentTask).length,
|
||||||
[memberTasks]
|
[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(
|
const completedTasks = useMemo(
|
||||||
() => memberTasks.filter((t) => t.status === 'completed').length,
|
() => memberTasks.filter(isTeamTaskFinishedForDependency).length,
|
||||||
[memberTasks]
|
[memberTasks]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -255,7 +267,11 @@ export const MemberDetailDialog = ({
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<DialogHeader className="shrink-0">
|
<DialogHeader className="shrink-0">
|
||||||
<MemberDetailHeader
|
<MemberDetailHeader
|
||||||
member={member}
|
member={
|
||||||
|
member.currentTaskId && !displayableCurrentTask
|
||||||
|
? { ...member, currentTaskId: null }
|
||||||
|
: member
|
||||||
|
}
|
||||||
runtimeSummary={runtimeSummary}
|
runtimeSummary={runtimeSummary}
|
||||||
isTeamAlive={isTeamAlive}
|
isTeamAlive={isTeamAlive}
|
||||||
isTeamProvisioning={isTeamProvisioning}
|
isTeamProvisioning={isTeamProvisioning}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
buildMemberLaunchPresentation,
|
buildMemberLaunchPresentation,
|
||||||
displayMemberName,
|
displayMemberName,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
|
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
||||||
import {
|
import {
|
||||||
buildMemberLaunchDiagnosticsPayload,
|
buildMemberLaunchDiagnosticsPayload,
|
||||||
getMemberLaunchDiagnosticsErrorMessage,
|
getMemberLaunchDiagnosticsErrorMessage,
|
||||||
|
|
@ -31,6 +32,7 @@ import {
|
||||||
hasMemberLaunchDiagnosticsError,
|
hasMemberLaunchDiagnosticsError,
|
||||||
} from '@renderer/utils/memberLaunchDiagnostics';
|
} from '@renderer/utils/memberLaunchDiagnostics';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
|
||||||
import { ExternalLink } from 'lucide-react';
|
import { ExternalLink } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
|
@ -126,8 +128,19 @@ export const MemberHoverCard = memo(function MemberHoverCard({
|
||||||
progress?.state === 'ready' && getLaunchJoinState(launchJoinMilestones).hasMembersStillJoining;
|
progress?.state === 'ready' && getLaunchJoinState(launchJoinMilestones).hasMembersStillJoining;
|
||||||
const colors = getTeamColorSet(color ?? member.color ?? '');
|
const colors = getTeamColorSet(color ?? member.color ?? '');
|
||||||
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
||||||
|
const currentTaskCandidate: TeamTaskWithKanban | null = member.currentTaskId
|
||||||
|
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
|
||||||
|
: null;
|
||||||
|
const currentTask = isDisplayableCurrentTask(currentTaskCandidate) ? currentTaskCandidate : null;
|
||||||
|
const presentationMember =
|
||||||
|
member.currentTaskId && !currentTask
|
||||||
|
? {
|
||||||
|
...member,
|
||||||
|
currentTaskId: null,
|
||||||
|
}
|
||||||
|
: member;
|
||||||
const launchPresentation = buildMemberLaunchPresentation({
|
const launchPresentation = buildMemberLaunchPresentation({
|
||||||
member,
|
member: presentationMember,
|
||||||
spawnStatus: spawnEntry?.status,
|
spawnStatus: spawnEntry?.status,
|
||||||
spawnLaunchState: spawnEntry?.launchState,
|
spawnLaunchState: spawnEntry?.launchState,
|
||||||
spawnLivenessSource: spawnEntry?.livenessSource,
|
spawnLivenessSource: spawnEntry?.livenessSource,
|
||||||
|
|
@ -171,15 +184,12 @@ export const MemberHoverCard = memo(function MemberHoverCard({
|
||||||
const showCopyDiagnostics =
|
const showCopyDiagnostics =
|
||||||
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
|
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
|
||||||
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
|
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
|
||||||
const currentTask: TeamTaskWithKanban | null = member.currentTaskId
|
|
||||||
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
|
|
||||||
: null;
|
|
||||||
const reviewTask: TeamTaskWithKanban | null = tasks
|
const reviewTask: TeamTaskWithKanban | null = tasks
|
||||||
? (tasks.find(
|
? (tasks.find(
|
||||||
(task) =>
|
(task) =>
|
||||||
task.reviewer === member.name &&
|
task.reviewer === member.name &&
|
||||||
task.id !== member.currentTaskId &&
|
task.id !== currentTask?.id &&
|
||||||
(task.reviewState === 'review' || task.kanbanColumn === 'review')
|
getTeamTaskWorkflowColumn(task) === 'review'
|
||||||
) ?? null)
|
) ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary';
|
import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary';
|
||||||
|
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
|
||||||
|
|
||||||
import { MemberCard } from './MemberCard';
|
import { MemberCard } from './MemberCard';
|
||||||
|
|
||||||
|
|
@ -425,7 +427,7 @@ export const MemberList = memo(function MemberList({
|
||||||
const result = new Map<string, TeamTaskWithKanban>();
|
const result = new Map<string, TeamTaskWithKanban>();
|
||||||
if (!taskMap) return result;
|
if (!taskMap) return result;
|
||||||
for (const task of taskMap.values()) {
|
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);
|
result.set(task.reviewer, task);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -455,11 +457,14 @@ export const MemberList = memo(function MemberList({
|
||||||
<div ref={containerRef} className="flex flex-col gap-1">
|
<div ref={containerRef} className="flex flex-col gap-1">
|
||||||
<div className={gridClass}>
|
<div className={gridClass}>
|
||||||
{activeMembers.map((member) => {
|
{activeMembers.map((member) => {
|
||||||
const currentTask =
|
const currentTaskCandidate =
|
||||||
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
|
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
|
||||||
|
const currentTask = isDisplayableCurrentTask(currentTaskCandidate)
|
||||||
|
? currentTaskCandidate
|
||||||
|
: null;
|
||||||
const reviewCandidate = reviewTaskByMember.get(member.name) ?? null;
|
const reviewCandidate = reviewTaskByMember.get(member.name) ?? null;
|
||||||
const reviewTask =
|
const reviewTask =
|
||||||
reviewCandidate && reviewCandidate.id !== member.currentTaskId ? reviewCandidate : null;
|
reviewCandidate && reviewCandidate.id !== currentTask?.id ? reviewCandidate : null;
|
||||||
const spawnEntry = memberSpawnStatuses?.get(member.name);
|
const spawnEntry = memberSpawnStatuses?.get(member.name);
|
||||||
const runtimeEntry = memberRuntimeEntries?.get(member.name);
|
const runtimeEntry = memberRuntimeEntries?.get(member.name);
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,10 @@ import {
|
||||||
TASK_STATUS_LABELS,
|
TASK_STATUS_LABELS,
|
||||||
TASK_STATUS_STYLES,
|
TASK_STATUS_STYLES,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
import {
|
||||||
|
getTeamTaskWorkflowColumn,
|
||||||
|
isTeamTaskNeedsFixActionable,
|
||||||
|
} from '@shared/utils/teamTaskState';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
|
|
||||||
import type { TeamTaskWithKanban } from '@shared/types';
|
import type { TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
@ -44,7 +47,7 @@ export const MemberTasksTab = ({ tasks, onTaskClick }: MemberTasksTabProps): Rea
|
||||||
<div className="max-h-[320px] overflow-y-auto">
|
<div className="max-h-[320px] overflow-y-auto">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{visibleTasks.map((task) => {
|
{visibleTasks.map((task) => {
|
||||||
const col = getTaskKanbanColumn(task);
|
const col = getTeamTaskWorkflowColumn(task);
|
||||||
const style =
|
const style =
|
||||||
col && KANBAN_COLUMN_DISPLAY[col]
|
col && KANBAN_COLUMN_DISPLAY[col]
|
||||||
? { bg: KANBAN_COLUMN_DISPLAY[col].bg, text: KANBAN_COLUMN_DISPLAY[col].text }
|
? { bg: KANBAN_COLUMN_DISPLAY[col].bg, text: KANBAN_COLUMN_DISPLAY[col].text }
|
||||||
|
|
@ -71,7 +74,7 @@ export const MemberTasksTab = ({ tasks, onTaskClick }: MemberTasksTabProps): Rea
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{task.reviewState === 'needsFix' ? (
|
{isTeamTaskNeedsFixActionable(task) ? (
|
||||||
<span
|
<span
|
||||||
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
|
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
||||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics';
|
import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
import { isTaskStallRemediationMessage } from '@shared/utils/teamAutomationMessages';
|
||||||
import {
|
import {
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
ChevronsDownUp,
|
ChevronsDownUp,
|
||||||
|
|
@ -587,6 +588,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
|
|
||||||
const activityTimelineMessages = useMemo(() => {
|
const activityTimelineMessages = useMemo(() => {
|
||||||
return filterTeamMessages(effectiveMessages, {
|
return filterTeamMessages(effectiveMessages, {
|
||||||
|
includeAutomationEvents: true,
|
||||||
includePassiveIdlePeerSummariesWhenNoiseHidden: true,
|
includePassiveIdlePeerSummariesWhenNoiseHidden: true,
|
||||||
leadNames,
|
leadNames,
|
||||||
timeWindow,
|
timeWindow,
|
||||||
|
|
@ -600,6 +602,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
effectiveMessages.filter(
|
effectiveMessages.filter(
|
||||||
(m) =>
|
(m) =>
|
||||||
m.messageKind !== 'task_comment_notification' &&
|
m.messageKind !== 'task_comment_notification' &&
|
||||||
|
!isTaskStallRemediationMessage(m) &&
|
||||||
!shouldExcludeInboxTextFromReplyCandidates(typeof m.text === 'string' ? m.text : '')
|
!shouldExcludeInboxTextFromReplyCandidates(typeof m.text === 'string' ? m.text : '')
|
||||||
),
|
),
|
||||||
[effectiveMessages]
|
[effectiveMessages]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { computePendingCrossTeamReplies } from '@renderer/utils/crossTeamPendingReplies';
|
import { computePendingCrossTeamReplies } from '@renderer/utils/crossTeamPendingReplies';
|
||||||
|
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
import { ActiveTasksBlock } from '../activity/ActiveTasksBlock';
|
import { ActiveTasksBlock } from '../activity/ActiveTasksBlock';
|
||||||
|
|
@ -55,8 +56,7 @@ export const StatusBlock = ({
|
||||||
return members.some((m) => {
|
return members.some((m) => {
|
||||||
if (!m.currentTaskId) return false;
|
if (!m.currentTaskId) return false;
|
||||||
const task = tMap.get(m.currentTaskId);
|
const task = tMap.get(m.currentTaskId);
|
||||||
if (task && (task.reviewState === 'approved' || task.status === 'completed')) return false;
|
return isDisplayableCurrentTask(task);
|
||||||
return true;
|
|
||||||
});
|
});
|
||||||
}, [members, tasks]);
|
}, [members, tasks]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
||||||
import { isTaskLogActivityChangeEvent } from '@renderer/utils/teamChangeEvents';
|
import { isTaskLogActivityChangeEvent } from '@renderer/utils/teamChangeEvents';
|
||||||
|
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
||||||
|
|
||||||
import { ExecutionSessionsSection } from './ExecutionSessionsSection';
|
import { ExecutionSessionsSection } from './ExecutionSessionsSection';
|
||||||
import { isBoardTaskActivityUiEnabled, isBoardTaskExactLogsUiEnabled } from './featureGates';
|
import { isBoardTaskActivityUiEnabled, isBoardTaskExactLogsUiEnabled } from './featureGates';
|
||||||
|
|
@ -66,7 +67,8 @@ export const TaskLogsPanel = ({
|
||||||
const countReloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const countReloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const countRequestSeqRef = useRef(0);
|
const countRequestSeqRef = useRef(0);
|
||||||
const hasTaskLogStream = availableTabs.includes('stream');
|
const hasTaskLogStream = availableTabs.includes('stream');
|
||||||
const taskLogActivityTrackingEnabled = task.status === 'in_progress' && hasTaskLogStream;
|
const taskIsActivelyWorked = isDisplayableCurrentTask(task);
|
||||||
|
const taskLogActivityTrackingEnabled = taskIsActivelyWorked && hasTaskLogStream;
|
||||||
const taskLogSummaryEnabled = hasOpenedContent && hasTaskLogStream;
|
const taskLogSummaryEnabled = hasOpenedContent && hasTaskLogStream;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -261,7 +263,7 @@ export const TaskLogsPanel = ({
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
taskId={task.id}
|
taskId={task.id}
|
||||||
taskStatus={task.status}
|
taskStatus={task.status}
|
||||||
liveEnabled={isOpen && task.status === 'in_progress'}
|
liveEnabled={isOpen && taskIsActivelyWorked}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,10 @@ import {
|
||||||
REVIEW_STATE_DISPLAY,
|
REVIEW_STATE_DISPLAY,
|
||||||
TASK_STATUS_LABELS,
|
TASK_STATUS_LABELS,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} 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 { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
|
|
||||||
import type { TeamTaskWithKanban } from '@shared/types';
|
import type { TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
@ -17,7 +20,7 @@ interface TaskRowProps {
|
||||||
export const TaskRow = memo(function TaskRow({ task }: TaskRowProps): React.JSX.Element {
|
export const TaskRow = memo(function TaskRow({ task }: TaskRowProps): React.JSX.Element {
|
||||||
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
|
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
|
||||||
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
|
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
|
||||||
const kanbanColumn = getTaskKanbanColumn(task);
|
const kanbanColumn = getTeamTaskWorkflowColumn(task);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="border-t border-[var(--color-border)]">
|
<tr className="border-t border-[var(--color-border)]">
|
||||||
|
|
@ -35,7 +38,7 @@ export const TaskRow = memo(function TaskRow({ task }: TaskRowProps): React.JSX.
|
||||||
? KANBAN_COLUMN_DISPLAY[kanbanColumn].label
|
? KANBAN_COLUMN_DISPLAY[kanbanColumn].label
|
||||||
: (TASK_STATUS_LABELS[task.status] ?? task.status)}
|
: (TASK_STATUS_LABELS[task.status] ?? task.status)}
|
||||||
</span>
|
</span>
|
||||||
{task.reviewState === 'needsFix' ? (
|
{isTeamTaskNeedsFixActionable(task) ? (
|
||||||
<span
|
<span
|
||||||
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
|
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
canDisplayTaskChangesForOptions,
|
canDisplayTaskChangesForOptions,
|
||||||
} from '@renderer/utils/taskChangeRequest';
|
} from '@renderer/utils/taskChangeRequest';
|
||||||
import { isTaskLogActivityChangeEvent } from '@renderer/utils/teamChangeEvents';
|
import { isTaskLogActivityChangeEvent } from '@renderer/utils/teamChangeEvents';
|
||||||
|
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { isVersionOlder, normalizeVersion } from '@shared/utils/version';
|
import { isVersionOlder, normalizeVersion } from '@shared/utils/version';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
@ -736,7 +737,7 @@ export function initializeNotificationListeners(): () => void {
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidateTasks = teamData.tasks.filter((task) => {
|
const candidateTasks = teamData.tasks.filter((task) => {
|
||||||
if (task.status !== 'in_progress') {
|
if (!isDisplayableCurrentTask(task)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return canDisplayTaskChangesForOptions(buildTaskChangeRequestOptions(task));
|
return canDisplayTaskChangesForOptions(buildTaskChangeRequestOptions(task));
|
||||||
|
|
@ -770,7 +771,7 @@ export function initializeNotificationListeners(): () => void {
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentTask = currentTeamData.tasks.find((task) => task.id === nextTask.id);
|
const currentTask = currentTeamData.tasks.find((task) => task.id === nextTask.id);
|
||||||
if (currentTask?.status !== 'in_progress') {
|
if (!isDisplayableCurrentTask(currentTask)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,15 @@ import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext
|
||||||
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
|
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
|
||||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||||
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
|
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
|
||||||
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
||||||
|
import {
|
||||||
|
getTeamTaskWorkflowColumn,
|
||||||
|
isTeamTaskFinalForCompletionNotification,
|
||||||
|
isTeamTaskNeedsFixActionable,
|
||||||
|
} from '@shared/utils/teamTaskState';
|
||||||
|
|
||||||
import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics';
|
import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics';
|
||||||
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
|
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
|
||||||
|
|
@ -1387,11 +1390,12 @@ function detectStatusChangeNotifications(
|
||||||
if (!oldTask) continue;
|
if (!oldTask) continue;
|
||||||
|
|
||||||
// Detect kanbanColumn change to 'approved' (status stays 'completed', column changes)
|
// Detect kanbanColumn change to 'approved' (status stays 'completed', column changes)
|
||||||
const taskKanbanColumn = getTaskKanbanColumn(task);
|
const taskKanbanColumn = getTeamTaskWorkflowColumn(task);
|
||||||
const oldTaskKanbanColumn = getTaskKanbanColumn(oldTask);
|
const oldTaskKanbanColumn = getTeamTaskWorkflowColumn(oldTask);
|
||||||
const becameApproved = taskKanbanColumn === 'approved' && oldTaskKanbanColumn !== 'approved';
|
const becameApproved = taskKanbanColumn === 'approved' && oldTaskKanbanColumn !== 'approved';
|
||||||
const becameReview = taskKanbanColumn === 'review' && oldTaskKanbanColumn !== 'review';
|
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;
|
const statusChanged = oldTask.status !== task.status;
|
||||||
if (!statusChanged && !becameApproved && !becameReview && !becameNeedsFix) continue;
|
if (!statusChanged && !becameApproved && !becameReview && !becameNeedsFix) continue;
|
||||||
|
|
@ -1686,7 +1690,7 @@ function detectAllTasksCompletedNotification(
|
||||||
|
|
||||||
for (const [teamName, tasks] of teamTasks) {
|
for (const [teamName, tasks] of teamTasks) {
|
||||||
if (tasks.length === 0) continue;
|
if (tasks.length === 0) continue;
|
||||||
const allCompleted = tasks.every((t) => t.status === 'completed' || t.status === 'deleted');
|
const allCompleted = tasks.every(isTeamTaskFinalForCompletionNotification);
|
||||||
if (!allCompleted) {
|
if (!allCompleted) {
|
||||||
// Reset so we can notify again if tasks become all-completed later
|
// Reset so we can notify again if tasks become all-completed later
|
||||||
notifiedAllCompletedTeams.delete(teamName);
|
notifiedAllCompletedTeams.delete(teamName);
|
||||||
|
|
@ -1697,8 +1701,7 @@ function detectAllTasksCompletedNotification(
|
||||||
// Check that at least one task was NOT completed before (real transition)
|
// Check that at least one task was NOT completed before (real transition)
|
||||||
const oldTeamTasks = oldTasks.filter((t) => t.teamName === teamName);
|
const oldTeamTasks = oldTasks.filter((t) => t.teamName === teamName);
|
||||||
const wasAlreadyAllCompleted =
|
const wasAlreadyAllCompleted =
|
||||||
oldTeamTasks.length > 0 &&
|
oldTeamTasks.length > 0 && oldTeamTasks.every(isTeamTaskFinalForCompletionNotification);
|
||||||
oldTeamTasks.every((t) => t.status === 'completed' || t.status === 'deleted');
|
|
||||||
if (wasAlreadyAllCompleted) {
|
if (wasAlreadyAllCompleted) {
|
||||||
notifiedAllCompletedTeams.add(teamName);
|
notifiedAllCompletedTeams.add(teamName);
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -3045,13 +3048,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:${task.status}`);
|
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:${task.status}`);
|
||||||
if (task.reviewState === 'needsFix') {
|
if (isTeamTaskNeedsFixActionable(task)) {
|
||||||
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:needsFix`);
|
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:needsFix`);
|
||||||
}
|
}
|
||||||
if (getTaskKanbanColumn(task) === 'approved') {
|
if (getTeamTaskWorkflowColumn(task) === 'approved') {
|
||||||
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:approved`);
|
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:approved`);
|
||||||
}
|
}
|
||||||
if (getTaskKanbanColumn(task) === 'review') {
|
if (getTeamTaskWorkflowColumn(task) === 'review') {
|
||||||
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:review`);
|
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:review`);
|
||||||
}
|
}
|
||||||
// Seed comment keys to prevent false notifications
|
// Seed comment keys to prevent false notifications
|
||||||
|
|
@ -3069,7 +3072,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
teamTasksMap.set(task.teamName, list);
|
teamTasksMap.set(task.teamName, list);
|
||||||
}
|
}
|
||||||
for (const [teamName, teamTasks] of teamTasksMap) {
|
for (const [teamName, teamTasks] of teamTasksMap) {
|
||||||
if (teamTasks.every((t) => t.status === 'completed' || t.status === 'deleted')) {
|
if (teamTasks.every(isTeamTaskFinalForCompletionNotification)) {
|
||||||
notifiedAllCompletedTeams.add(teamName);
|
notifiedAllCompletedTeams.add(teamName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -332,7 +332,8 @@ function isOpenCodeRuntimeDeliveryAdvisoryMessage(message: string | undefined):
|
||||||
displayMessage.startsWith('OpenCode returned an empty assistant turn') ||
|
displayMessage.startsWith('OpenCode returned an empty assistant turn') ||
|
||||||
displayMessage.startsWith('OpenCode accepted the prompt') ||
|
displayMessage.startsWith('OpenCode accepted the prompt') ||
|
||||||
displayMessage.startsWith('OpenCode responded, but did not create') ||
|
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.';
|
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 (
|
if (
|
||||||
trimmed.startsWith(
|
trimmed.startsWith(
|
||||||
'OpenCode bootstrap MCP did not complete required tools before assistant response:'
|
'OpenCode bootstrap MCP did not complete required tools before assistant response:'
|
||||||
|
|
@ -389,6 +393,8 @@ function formatRuntimeAdvisoryBaseLabel(
|
||||||
return 'Network error';
|
return 'Network error';
|
||||||
case 'provider_overloaded':
|
case 'provider_overloaded':
|
||||||
return providerLabel ? `${providerLabel} overload` : 'Provider overload';
|
return providerLabel ? `${providerLabel} overload` : 'Provider overload';
|
||||||
|
case 'protocol_proof_missing':
|
||||||
|
return providerId === 'opencode' ? 'OpenCode proof missing' : 'Protocol proof missing';
|
||||||
case 'backend_error':
|
case 'backend_error':
|
||||||
case 'unknown':
|
case 'unknown':
|
||||||
if (
|
if (
|
||||||
|
|
@ -416,6 +422,8 @@ function formatRuntimeAdvisoryBaseLabel(
|
||||||
return 'Network retry';
|
return 'Network retry';
|
||||||
case 'provider_overloaded':
|
case 'provider_overloaded':
|
||||||
return providerLabel ? `${providerLabel} overload retry` : 'Provider overload retry';
|
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 'backend_error':
|
||||||
case 'unknown':
|
case 'unknown':
|
||||||
return 'Provider retry';
|
return 'Provider retry';
|
||||||
|
|
@ -458,6 +466,13 @@ function formatRuntimeAdvisoryTitle(
|
||||||
'Provider is temporarily overloaded.',
|
'Provider is temporarily overloaded.',
|
||||||
advisory.message
|
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 'backend_error':
|
||||||
case 'unknown':
|
case 'unknown':
|
||||||
if (
|
if (
|
||||||
|
|
@ -509,6 +524,13 @@ function formatRuntimeAdvisoryTitle(
|
||||||
'Provider is temporarily overloaded. SDK is retrying automatically.',
|
'Provider is temporarily overloaded. SDK is retrying automatically.',
|
||||||
advisory.message
|
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 'backend_error':
|
||||||
case 'unknown':
|
case 'unknown':
|
||||||
return appendRuntimeAdvisoryRawMessage(
|
return appendRuntimeAdvisoryRawMessage(
|
||||||
|
|
@ -565,6 +587,9 @@ export function getMemberRuntimeAdvisoryTone(
|
||||||
if (!advisory) {
|
if (!advisory) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (advisory.reasonCode === 'protocol_proof_missing') {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
return advisory.kind === 'api_error' ? 'error' : 'warning';
|
return advisory.kind === 'api_error' ? 'error' : 'warning';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,9 @@ function formatOpenCodeRuntimeDeliveryFailureReason(reason: string | null | unde
|
||||||
) {
|
) {
|
||||||
return 'OpenCode created a reply without the required relayOfMessageId correlation.';
|
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 '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,10 @@
|
||||||
|
import {
|
||||||
|
getTeamTaskWorkflowColumn,
|
||||||
|
isTeamTaskDeleted,
|
||||||
|
isTeamTaskFinishedForDependency,
|
||||||
|
isTeamTaskNeedsFixActionable,
|
||||||
|
} from '@shared/utils/teamTaskState';
|
||||||
|
|
||||||
import type { GlobalTask } from '@shared/types';
|
import type { GlobalTask } from '@shared/types';
|
||||||
|
|
||||||
export function normalizePath(p: string): string {
|
export function normalizePath(p: string): string {
|
||||||
|
|
@ -15,10 +22,29 @@ export interface TaskStatusCounts {
|
||||||
completed: number;
|
completed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function incrementStatus(counts: TaskStatusCounts, status: string): TaskStatusCounts {
|
function incrementTaskStatus(
|
||||||
if (status === 'pending') return { ...counts, pending: counts.pending + 1 };
|
counts: TaskStatusCounts,
|
||||||
if (status === 'in_progress') return { ...counts, inProgress: counts.inProgress + 1 };
|
task: {
|
||||||
if (status === 'completed') return { ...counts, completed: counts.completed + 1 };
|
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;
|
return counts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,7 +55,7 @@ export function buildTaskCountsByProject(tasks: GlobalTask[]): Map<string, TaskS
|
||||||
if (!task.projectPath) continue;
|
if (!task.projectPath) continue;
|
||||||
const key = normalizePath(task.projectPath);
|
const key = normalizePath(task.projectPath);
|
||||||
const counts = map.get(key) ?? { pending: 0, inProgress: 0, completed: 0 };
|
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;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
@ -40,22 +66,28 @@ export function buildTaskCountsByTeam(tasks: GlobalTask[]): Map<string, TaskStat
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
const key = task.teamName;
|
const key = task.teamName;
|
||||||
const counts = map.get(key) ?? { pending: 0, inProgress: 0, completed: 0 };
|
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;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a map of owner name (lowercase) -> task status counts (ignores deleted). */
|
/** Build a map of owner name (lowercase) -> task status counts (ignores deleted). */
|
||||||
export function buildTaskCountsByOwner(
|
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<string, TaskStatusCounts> {
|
): Map<string, TaskStatusCounts> {
|
||||||
const map = new Map<string, TaskStatusCounts>();
|
const map = new Map<string, TaskStatusCounts>();
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
const owner = task.owner?.trim();
|
const owner = task.owner?.trim();
|
||||||
if (!owner || task.status === 'deleted') continue;
|
if (!owner || isTeamTaskDeleted(task)) continue;
|
||||||
const key = owner.toLowerCase();
|
const key = owner.toLowerCase();
|
||||||
const counts = map.get(key) ?? { pending: 0, inProgress: 0, completed: 0 };
|
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;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
} from '@renderer/utils/bootstrapPromptSanitizer';
|
} from '@renderer/utils/bootstrapPromptSanitizer';
|
||||||
import { shouldKeepIdleMessageInActivityWhenNoiseHidden } from '@renderer/utils/idleNotificationSemantics';
|
import { shouldKeepIdleMessageInActivityWhenNoiseHidden } from '@renderer/utils/idleNotificationSemantics';
|
||||||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||||
|
import { isTaskStallRemediationMessage } from '@shared/utils/teamAutomationMessages';
|
||||||
import { isTeamInternalControlMessageEnvelope } from '@shared/utils/teamInternalControlMessages';
|
import { isTeamInternalControlMessageEnvelope } from '@shared/utils/teamInternalControlMessages';
|
||||||
|
|
||||||
import type { InboxMessage } from '@shared/types';
|
import type { InboxMessage } from '@shared/types';
|
||||||
|
|
@ -111,6 +112,7 @@ export function filterTeamMessages(
|
||||||
messages: InboxMessage[],
|
messages: InboxMessage[],
|
||||||
options: {
|
options: {
|
||||||
includePassiveIdlePeerSummariesWhenNoiseHidden?: boolean;
|
includePassiveIdlePeerSummariesWhenNoiseHidden?: boolean;
|
||||||
|
includeAutomationEvents?: boolean;
|
||||||
leadNames?: Iterable<string>;
|
leadNames?: Iterable<string>;
|
||||||
timeWindow?: { start: number; end: number } | null;
|
timeWindow?: { start: number; end: number } | null;
|
||||||
filter: TeamMessagesFilter;
|
filter: TeamMessagesFilter;
|
||||||
|
|
@ -119,6 +121,7 @@ export function filterTeamMessages(
|
||||||
): InboxMessage[] {
|
): InboxMessage[] {
|
||||||
const {
|
const {
|
||||||
includePassiveIdlePeerSummariesWhenNoiseHidden = false,
|
includePassiveIdlePeerSummariesWhenNoiseHidden = false,
|
||||||
|
includeAutomationEvents = false,
|
||||||
leadNames: rawLeadNames,
|
leadNames: rawLeadNames,
|
||||||
timeWindow,
|
timeWindow,
|
||||||
filter,
|
filter,
|
||||||
|
|
@ -127,7 +130,10 @@ export function filterTeamMessages(
|
||||||
const leadNames = normalizeLeadNames(rawLeadNames);
|
const leadNames = normalizeLeadNames(rawLeadNames);
|
||||||
|
|
||||||
let list = messages.filter(
|
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) {
|
if (timeWindow) {
|
||||||
list = list.filter((m) => {
|
list = list.filter((m) => {
|
||||||
|
|
|
||||||
9
src/renderer/utils/teamTaskDisplayState.ts
Normal file
9
src/renderer/utils/teamTaskDisplayState.ts
Normal file
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
@ -434,6 +434,7 @@ export type InboxMessageKind =
|
||||||
| 'slash_command'
|
| 'slash_command'
|
||||||
| 'slash_command_result'
|
| 'slash_command_result'
|
||||||
| 'task_comment_notification'
|
| 'task_comment_notification'
|
||||||
|
| 'task_stall_remediation'
|
||||||
| 'member_work_sync_nudge'
|
| 'member_work_sync_nudge'
|
||||||
| 'agent_error';
|
| 'agent_error';
|
||||||
|
|
||||||
|
|
@ -828,6 +829,7 @@ export interface MemberRuntimeAdvisory {
|
||||||
| 'codex_native_timeout'
|
| 'codex_native_timeout'
|
||||||
| 'network_error'
|
| 'network_error'
|
||||||
| 'provider_overloaded'
|
| 'provider_overloaded'
|
||||||
|
| 'protocol_proof_missing'
|
||||||
| 'backend_error'
|
| 'backend_error'
|
||||||
| 'unknown';
|
| 'unknown';
|
||||||
message?: string;
|
message?: string;
|
||||||
|
|
|
||||||
|
|
@ -14,18 +14,8 @@ export function normalizeReviewState(value: unknown): TeamReviewState {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getReviewStateFromTask(task: ReviewStateLike): 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 fallbackStatus = typeof task.status === 'string' ? task.status : null;
|
||||||
const normalizeFallback = (value: unknown): TeamReviewState | null => {
|
const normalizeForStatus = (value: unknown): TeamReviewState | null => {
|
||||||
const explicit = normalizeReviewState(value);
|
const explicit = normalizeReviewState(value);
|
||||||
if (explicit === 'none') return null;
|
if (explicit === 'none') return null;
|
||||||
|
|
||||||
|
|
@ -36,16 +26,28 @@ export function getReviewStateFromTask(task: ReviewStateLike): TeamReviewState {
|
||||||
return explicit === 'needsFix' ? 'needsFix' : 'none';
|
return explicit === 'needsFix' ? 'needsFix' : 'none';
|
||||||
}
|
}
|
||||||
if (fallbackStatus === 'completed') {
|
if (fallbackStatus === 'completed') {
|
||||||
return explicit === 'review' || explicit === 'approved' ? explicit : 'none';
|
return explicit === 'review' || explicit === 'approved' || explicit === 'needsFix'
|
||||||
|
? explicit
|
||||||
|
: 'none';
|
||||||
}
|
}
|
||||||
return explicit;
|
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 (explicit) return explicit;
|
||||||
|
|
||||||
if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') {
|
if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') {
|
||||||
return normalizeFallback(task.kanbanColumn) ?? 'none';
|
return normalizeForStatus(task.kanbanColumn) ?? 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'none';
|
return 'none';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { getReviewStateFromTask } from './reviewState';
|
import { getReviewStateFromTask } from './reviewState';
|
||||||
|
import { getTeamTaskWorkflowColumn } from './teamTaskState';
|
||||||
|
|
||||||
import type { TeamReviewState } from '@shared/types';
|
import type { TeamReviewState } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -9,6 +10,7 @@ interface TaskChangeStateLike {
|
||||||
reviewState?: TeamReviewState | null;
|
reviewState?: TeamReviewState | null;
|
||||||
historyEvents?: unknown[];
|
historyEvents?: unknown[];
|
||||||
kanbanColumn?: 'review' | 'approved' | null;
|
kanbanColumn?: 'review' | 'approved' | null;
|
||||||
|
deletedAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEffectiveReviewState(task: TaskChangeStateLike): TeamReviewState {
|
function getEffectiveReviewState(task: TaskChangeStateLike): TeamReviewState {
|
||||||
|
|
@ -17,8 +19,15 @@ function getEffectiveReviewState(task: TaskChangeStateLike): TeamReviewState {
|
||||||
|
|
||||||
export function getTaskChangeStateBucket(task: TaskChangeStateLike): TaskChangeStateBucket {
|
export function getTaskChangeStateBucket(task: TaskChangeStateLike): TaskChangeStateBucket {
|
||||||
const reviewState = getEffectiveReviewState(task);
|
const reviewState = getEffectiveReviewState(task);
|
||||||
if (reviewState === 'approved') return 'approved';
|
const workflowColumn = getTeamTaskWorkflowColumn({
|
||||||
if (reviewState === 'review') return 'review';
|
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';
|
return task.status === 'completed' ? 'completed' : 'active';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
16
src/shared/utils/teamAutomationMessages.ts
Normal file
16
src/shared/utils/teamAutomationMessages.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import type { InboxMessage } from '@shared/types';
|
||||||
|
|
||||||
|
type AutomationMessageLike = Pick<InboxMessage, 'from' | 'messageId' | 'messageKind' | 'source'>;
|
||||||
|
|
||||||
|
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:')
|
||||||
|
);
|
||||||
|
}
|
||||||
115
src/shared/utils/teamTaskState.ts
Normal file
115
src/shared/utils/teamTaskState.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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', () => {
|
it('assigns active review work to the current-cycle reviewer only', () => {
|
||||||
const agenda = buildActionableWorkAgenda({
|
const agenda = buildActionableWorkAgenda({
|
||||||
teamName: 'team-a',
|
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', () => {
|
it('does not resurrect a stale reviewer after review was approved', () => {
|
||||||
const agenda = buildActionableWorkAgenda({
|
const agenda = buildActionableWorkAgenda({
|
||||||
teamName: 'team-a',
|
teamName: 'team-a',
|
||||||
|
|
@ -257,12 +520,20 @@ describe('buildActionableWorkAgenda', () => {
|
||||||
owner: 'bob',
|
owner: 'bob',
|
||||||
reviewState: 'needsFix',
|
reviewState: 'needsFix',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'task-2',
|
||||||
|
subject: 'Fix completed review',
|
||||||
|
status: 'completed',
|
||||||
|
owner: 'bob',
|
||||||
|
reviewState: 'needsFix',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
hash,
|
hash,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(agenda.items.map((item) => [item.taskId, item.kind, item.reason])).toEqual([
|
expect(agenda.items.map((item) => [item.taskId, item.kind, item.reason])).toEqual([
|
||||||
['task-1', 'work', 'review_changes_requested'],
|
['task-1', 'work', 'review_changes_requested'],
|
||||||
|
['task-2', 'work', 'review_changes_requested'],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -129,4 +129,90 @@ describe('MemberWorkSyncTaskImpactResolver', () => {
|
||||||
diagnostics: [],
|
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: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,117 +1,47 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { selectOpenCodeRuntimeDeliveryReason } from '../../../../src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics';
|
import {
|
||||||
|
isActionRequiredOpenCodeRuntimeDeliveryReason,
|
||||||
import type { OpenCodePromptDeliveryLedgerRecord } from '../../../../src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger';
|
selectOpenCodeRuntimeDeliveryReason,
|
||||||
|
} from '../../../../src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics';
|
||||||
function record(
|
|
||||||
input: Partial<OpenCodePromptDeliveryLedgerRecord>
|
|
||||||
): 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('OpenCodeRuntimeDeliveryDiagnostics', () => {
|
describe('OpenCodeRuntimeDeliveryDiagnostics', () => {
|
||||||
it('skips internal bootstrap MCP diagnostics when a provider error is available', () => {
|
it('treats OpenRouter key limit errors as action-required delivery failures', () => {
|
||||||
const reason = selectOpenCodeRuntimeDeliveryReason(
|
const reason =
|
||||||
record({
|
'Key limit exceeded (total limit). Manage it using https://openrouter.ai/settings/keys';
|
||||||
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',
|
|
||||||
],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
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', () => {
|
it('does not treat protocol proof repair reasons as action-required provider failures', () => {
|
||||||
const reason = selectOpenCodeRuntimeDeliveryReason(
|
expect(isActionRequiredOpenCodeRuntimeDeliveryReason('visible_reply_still_required')).toBe(
|
||||||
record({
|
false
|
||||||
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',
|
|
||||||
],
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(reason).toBe('OpenCode returned an empty assistant turn.');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps missing visible reply proof to a readable protocol error', () => {
|
it('selects a concrete OpenCode runtime delivery diagnostic before generic fallback text', () => {
|
||||||
const reason = selectOpenCodeRuntimeDeliveryReason(
|
const record = {
|
||||||
record({
|
diagnostics: [
|
||||||
responseState: 'responded_non_visible_tool',
|
'Latest assistant message for opencode session abc failed with APIError - Key limit exceeded (total limit). Manage it using https://openrouter.ai/settings/keys',
|
||||||
lastReason: 'visible_reply_still_required',
|
],
|
||||||
diagnostics: [
|
lastReason: 'OpenCode runtime delivery failed',
|
||||||
'OpenCode bootstrap MCP did not complete required tools before assistant response: runtime_bootstrap_checkin, member_briefing',
|
responseState: 'session_error',
|
||||||
'visible_reply_still_required',
|
status: 'accepted',
|
||||||
],
|
} as Parameters<typeof selectOpenCodeRuntimeDeliveryReason>[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', () => {
|
it('formats non-visible tool progress failures without exposing the internal reason code', () => {
|
||||||
const reason = selectOpenCodeRuntimeDeliveryReason(
|
const record = {
|
||||||
record({
|
diagnostics: ['non_visible_tool_without_task_progress'],
|
||||||
diagnostics: [
|
lastReason: 'non_visible_tool_without_task_progress',
|
||||||
'OpenCode app MCP was reattached before message delivery.',
|
responseState: 'responded_non_visible_tool',
|
||||||
'OpenCode bootstrap MCP did not complete required tools before assistant response: runtime_bootstrap_checkin, member_briefing',
|
status: 'failed_terminal',
|
||||||
],
|
} as Parameters<typeof selectOpenCodeRuntimeDeliveryReason>[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.'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
it('applies kanban overlay review state in global task projections', async () => {
|
||||||
const service = new TeamDataService(
|
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 () => {
|
it('propagates leadSessionId for kanban-driven review transitions', async () => {
|
||||||
const requestReviewMock = vi.fn();
|
const requestReviewMock = vi.fn();
|
||||||
const approveReviewMock = vi.fn();
|
const approveReviewMock = vi.fn();
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,86 @@ describe('TeamMemberResolver', () => {
|
||||||
expect(lead?.agentType).toBe('team-lead');
|
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', () => {
|
it('filters out "user" pseudo-member even when present in config, meta, or inboxNames', () => {
|
||||||
const resolver = new TeamMemberResolver();
|
const resolver = new TeamMemberResolver();
|
||||||
const config: TeamConfig = {
|
const config: TeamConfig = {
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,10 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
|
||||||
'All credentials for model claude-opus-4-6 are cooling down via provider claude.',
|
'All credentials for model claude-opus-4-6 are cooling down via provider claude.',
|
||||||
],
|
],
|
||||||
['auth_error', 'Authentication failed due to invalid API key.'],
|
['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.'],
|
['codex_native_timeout', 'Codex native exec timed out after 120000ms.'],
|
||||||
['network_error', 'Fetch failed because the network connection timed out.'],
|
['network_error', 'Fetch failed because the network connection timed out.'],
|
||||||
['provider_overloaded', 'Service unavailable: provider temporarily unavailable (503).'],
|
['provider_overloaded', 'Service unavailable: provider temporarily unavailable (503).'],
|
||||||
|
|
@ -357,6 +361,99 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
|
||||||
expect(advisory?.message).not.toContain('Latest assistant message');
|
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 () => {
|
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-'));
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-'));
|
||||||
setClaudeBasePathOverride(tmpDir);
|
setClaudeBasePathOverride(tmpDir);
|
||||||
|
|
|
||||||
|
|
@ -252,7 +252,7 @@ Messages:
|
||||||
expect(getInboxMessages).toHaveBeenCalledTimes(2);
|
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 = {
|
const opencodeConfig: TeamConfig = {
|
||||||
name: 'relay-works-14',
|
name: 'relay-works-14',
|
||||||
description: 'relay-works-14 team for provisioning flow',
|
description: 'relay-works-14 team for provisioning flow',
|
||||||
|
|
@ -281,7 +281,7 @@ Messages:
|
||||||
from: 'team-lead',
|
from: 'team-lead',
|
||||||
to: 'bob',
|
to: 'bob',
|
||||||
source: 'system_notification',
|
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',
|
timestamp: '2026-04-30T17:42:26.947Z',
|
||||||
});
|
});
|
||||||
expect(feed.messages[0]?.text).toContain('Provider override for this teammate: opencode.');
|
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.'
|
'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<TeamConfig['members']>[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',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ describe('TeamTaskStallNotifier', () => {
|
||||||
taskRefs: [alert.taskRef],
|
taskRefs: [alert.taskRef],
|
||||||
actionMode: 'do',
|
actionMode: 'do',
|
||||||
source: 'system_notification',
|
source: 'system_notification',
|
||||||
|
messageKind: 'task_stall_remediation',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(teamProvisioningService.relayOpenCodeMemberInboxMessages).toHaveBeenCalledWith(
|
expect(teamProvisioningService.relayOpenCodeMemberInboxMessages).toHaveBeenCalledWith(
|
||||||
|
|
|
||||||
|
|
@ -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 deletedTasks = [{ id: 'task-deleted', subject: 'D', status: 'deleted' }];
|
||||||
const transcriptContext = {
|
const transcriptContext = {
|
||||||
|
|
@ -98,6 +100,10 @@ describe('TeamTaskStallSnapshotSource', () => {
|
||||||
movedAt: '2026-04-19T12:00:00.000Z',
|
movedAt: '2026-04-19T12:00:00.000Z',
|
||||||
reviewer: 'alice',
|
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 snapshot = await source.getSnapshot('demo');
|
||||||
|
const expectedWorkflowActiveTasks = [
|
||||||
|
activeTasks[0],
|
||||||
|
activeTasks[1],
|
||||||
|
{ ...activeTasks[2], reviewState: 'approved' },
|
||||||
|
{ ...activeTasks[3], reviewState: 'none' },
|
||||||
|
];
|
||||||
|
|
||||||
expect(snapshot).not.toBeNull();
|
expect(snapshot).not.toBeNull();
|
||||||
expect(batchIndexer.buildIndex).toHaveBeenCalledWith({
|
expect(batchIndexer.buildIndex).toHaveBeenCalledWith({
|
||||||
teamName: 'demo',
|
teamName: 'demo',
|
||||||
tasks: [...activeTasks, ...deletedTasks],
|
tasks: [...expectedWorkflowActiveTasks, ...deletedTasks],
|
||||||
messages: rawMessages,
|
messages: rawMessages,
|
||||||
});
|
});
|
||||||
expect(freshnessReader.readSignals).toHaveBeenCalledWith('/tmp/project', ['task-a', 'task-b']);
|
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(exactRowReader.parseFiles).toHaveBeenCalledWith(['/tmp/project/session-a.jsonl', '/tmp/project/session-b.jsonl']);
|
||||||
expect(openCodeEvidenceSource.readEvidence).toHaveBeenCalledWith({
|
expect(openCodeEvidenceSource.readEvidence).toHaveBeenCalledWith({
|
||||||
teamName: 'demo',
|
teamName: 'demo',
|
||||||
tasks: [activeTasks[0], activeTasks[1]],
|
tasks: [expectedWorkflowActiveTasks[0], expectedWorkflowActiveTasks[1]],
|
||||||
providerByMemberName: new Map([
|
providerByMemberName: new Map([
|
||||||
['team-lead', 'codex'],
|
['team-lead', 'codex'],
|
||||||
['alice', 'opencode'],
|
['alice', 'opencode'],
|
||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
|
expect(snapshot?.activeTasks).toEqual(expectedWorkflowActiveTasks);
|
||||||
expect(snapshot?.inProgressTasks.map((task) => task.id)).toEqual(['task-a']);
|
expect(snapshot?.inProgressTasks.map((task) => task.id)).toEqual(['task-a']);
|
||||||
expect(snapshot?.reviewOpenTasks.map((task) => task.id)).toEqual(['task-b']);
|
expect(snapshot?.reviewOpenTasks.map((task) => task.id)).toEqual(['task-b']);
|
||||||
expect(snapshot?.leadName).toBe('team-lead');
|
expect(snapshot?.leadName).toBe('team-lead');
|
||||||
|
|
|
||||||
329
test/main/services/team/teamTaskActiveState.test.ts
Normal file
329
test/main/services/team/teamTaskActiveState.test.ts
Normal file
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -6,12 +6,44 @@ describe('taskFiltersState', () => {
|
||||||
it('treats needsFix as distinct from normal todo/done buckets', () => {
|
it('treats needsFix as distinct from normal todo/done buckets', () => {
|
||||||
const pendingNeedsFixTask = { status: 'pending', reviewState: 'needsFix' as const };
|
const pendingNeedsFixTask = { status: 'pending', reviewState: 'needsFix' as const };
|
||||||
const completedNeedsFixTask = { status: 'completed', 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 };
|
const normalPendingTask = { status: 'pending', reviewState: 'none' as const };
|
||||||
|
|
||||||
expect(taskMatchesStatus(pendingNeedsFixTask, new Set(['needs_fix']))).toBe(true);
|
expect(taskMatchesStatus(pendingNeedsFixTask, new Set(['needs_fix']))).toBe(true);
|
||||||
expect(taskMatchesStatus(completedNeedsFixTask, 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(pendingNeedsFixTask, new Set(['todo']))).toBe(false);
|
||||||
expect(taskMatchesStatus(completedNeedsFixTask, new Set(['done']))).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);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -541,4 +541,41 @@ describe('ActivityItem legacy system message fallback', () => {
|
||||||
await Promise.resolve();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import React, { act } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
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', () => ({
|
vi.mock('@renderer/components/team/members/MemberCard', () => ({
|
||||||
MemberCard: ({
|
MemberCard: ({
|
||||||
|
|
@ -10,6 +10,8 @@ vi.mock('@renderer/components/team/members/MemberCard', () => ({
|
||||||
spawnError,
|
spawnError,
|
||||||
spawnStatus,
|
spawnStatus,
|
||||||
spawnLaunchState,
|
spawnLaunchState,
|
||||||
|
currentTask,
|
||||||
|
reviewTask,
|
||||||
onRestartMember,
|
onRestartMember,
|
||||||
onSkipMemberForLaunch,
|
onSkipMemberForLaunch,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -17,6 +19,8 @@ vi.mock('@renderer/components/team/members/MemberCard', () => ({
|
||||||
spawnError?: string;
|
spawnError?: string;
|
||||||
spawnStatus?: string;
|
spawnStatus?: string;
|
||||||
spawnLaunchState?: string;
|
spawnLaunchState?: string;
|
||||||
|
currentTask?: TeamTaskWithKanban | null;
|
||||||
|
reviewTask?: TeamTaskWithKanban | null;
|
||||||
onRestartMember?: (memberName: string) => void;
|
onRestartMember?: (memberName: string) => void;
|
||||||
onSkipMemberForLaunch?: (memberName: string) => void;
|
onSkipMemberForLaunch?: (memberName: string) => void;
|
||||||
}) =>
|
}) =>
|
||||||
|
|
@ -24,6 +28,12 @@ vi.mock('@renderer/components/team/members/MemberCard', () => ({
|
||||||
'div',
|
'div',
|
||||||
{ 'data-testid': `member-${member.name}` },
|
{ 'data-testid': `member-${member.name}` },
|
||||||
spawnError ?? '',
|
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')
|
onRestartMember && (spawnStatus === 'error' || spawnLaunchState === 'failed_to_start')
|
||||||
? React.createElement(
|
? React.createElement(
|
||||||
'button',
|
'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 () => {
|
it('passes skip callbacks to failed member cards and rerenders when the callback changes', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
|
|
|
||||||
|
|
@ -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<TeamTaskWithKanban, 'status' | 'reviewState' | 'kanbanColumn' | 'deletedAt'>
|
||||||
|
>([
|
||||||
|
['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<TeamTaskWithKanban, 'status' | 'reviewState' | 'kanbanColumn' | 'deletedAt'>
|
||||||
|
>([
|
||||||
|
[
|
||||||
|
'completed-review',
|
||||||
|
{
|
||||||
|
status: 'completed',
|
||||||
|
reviewState: 'review',
|
||||||
|
kanbanColumn: 'review',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(isTaskBlocked({ blockedBy: ['completed-review'] }, taskStateById)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
getSpawnCardClass,
|
getSpawnCardClass,
|
||||||
getMemberRuntimeAdvisoryLabel,
|
getMemberRuntimeAdvisoryLabel,
|
||||||
getMemberRuntimeAdvisoryTitle,
|
getMemberRuntimeAdvisoryTitle,
|
||||||
|
getMemberRuntimeAdvisoryTone,
|
||||||
isOpenCodeRelaunchActionable,
|
isOpenCodeRelaunchActionable,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
|
|
||||||
|
|
@ -703,15 +704,18 @@ describe('memberHelpers spawn-aware presence', () => {
|
||||||
const advisory = {
|
const advisory = {
|
||||||
kind: 'api_error' as const,
|
kind: 'api_error' as const,
|
||||||
observedAt: '2026-04-07T09:00:00.000Z',
|
observedAt: '2026-04-07T09:00:00.000Z',
|
||||||
reasonCode: 'backend_error' as const,
|
reasonCode: 'protocol_proof_missing' as const,
|
||||||
message: 'visible_reply_still_required',
|
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');
|
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).toContain('OpenCode responded, but did not create a visible message_send reply.');
|
||||||
expect(title).not.toContain('visible_reply_still_required');
|
expect(title).not.toContain('visible_reply_still_required');
|
||||||
});
|
});
|
||||||
|
|
@ -732,6 +736,42 @@ describe('memberHelpers spawn-aware presence', () => {
|
||||||
expect(title).not.toContain('runtime_bootstrap_checkin');
|
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', () => {
|
it('renders Codex native timeout separately from network errors', () => {
|
||||||
const advisory = {
|
const advisory = {
|
||||||
kind: 'api_error' as const,
|
kind: 'api_error' as const,
|
||||||
|
|
|
||||||
|
|
@ -77,4 +77,25 @@ describe('openCodeRuntimeDeliveryDiagnostics', () => {
|
||||||
reason: 'visible_reply_still_required',
|
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.'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
105
test/renderer/utils/pathNormalize.test.ts
Normal file
105
test/renderer/utils/pathNormalize.test.ts
Normal file
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -534,4 +534,54 @@ Messages:
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0].messageId).toBe('msg-2');
|
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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,39 @@ describe('reviewState utils', () => {
|
||||||
).toBe('none');
|
).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', () => {
|
it('falls back to persisted legacy reviewState when history has no review signal', () => {
|
||||||
expect(
|
expect(
|
||||||
getReviewStateFromTask({
|
getReviewStateFromTask({
|
||||||
|
|
@ -139,4 +172,25 @@ describe('reviewState utils', () => {
|
||||||
'needsFix'
|
'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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -67,4 +67,63 @@ describe('taskChangeState utils', () => {
|
||||||
})
|
})
|
||||||
).toBe('active');
|
).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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue