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