feat(team): update task automation state

This commit is contained in:
777genius 2026-05-07 01:22:16 +03:00
parent 2c30fd2235
commit 38b3a1e789
72 changed files with 2756 additions and 322 deletions

View file

@ -283,6 +283,7 @@
"supports_pdf_input": true, "supports_pdf_input": true,
"supports_prompt_caching": true, "supports_prompt_caching": true,
"supports_reasoning": true, "supports_reasoning": true,
"supports_minimal_reasoning_effort": true,
"supports_response_schema": true, "supports_response_schema": true,
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
@ -480,6 +481,7 @@
"supports_vision": true, "supports_vision": true,
"supports_prompt_caching": false, "supports_prompt_caching": false,
"supports_reasoning": true, "supports_reasoning": true,
"supports_minimal_reasoning_effort": true,
"supports_tool_choice": true "supports_tool_choice": true
}, },
"global.anthropic.claude-opus-4-7": { "global.anthropic.claude-opus-4-7": {
@ -627,6 +629,7 @@
"supports_prompt_caching": true, "supports_prompt_caching": true,
"supports_reasoning": true, "supports_reasoning": true,
"supports_response_schema": true, "supports_response_schema": true,
"supports_max_reasoning_effort": true,
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
@ -656,6 +659,7 @@
"supports_prompt_caching": true, "supports_prompt_caching": true,
"supports_reasoning": true, "supports_reasoning": true,
"supports_response_schema": true, "supports_response_schema": true,
"supports_max_reasoning_effort": true,
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
@ -685,6 +689,7 @@
"supports_prompt_caching": true, "supports_prompt_caching": true,
"supports_reasoning": true, "supports_reasoning": true,
"supports_response_schema": true, "supports_response_schema": true,
"supports_max_reasoning_effort": true,
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
@ -713,6 +718,7 @@
"supports_prompt_caching": true, "supports_prompt_caching": true,
"supports_reasoning": true, "supports_reasoning": true,
"supports_response_schema": true, "supports_response_schema": true,
"supports_max_reasoning_effort": true,
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
@ -741,6 +747,7 @@
"supports_prompt_caching": true, "supports_prompt_caching": true,
"supports_reasoning": true, "supports_reasoning": true,
"supports_response_schema": true, "supports_response_schema": true,
"supports_max_reasoning_effort": true,
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
@ -1018,6 +1025,7 @@
"supports_pdf_input": true, "supports_pdf_input": true,
"supports_prompt_caching": true, "supports_prompt_caching": true,
"supports_reasoning": true, "supports_reasoning": true,
"supports_minimal_reasoning_effort": true,
"supports_response_schema": true, "supports_response_schema": true,
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true "supports_vision": true
@ -1141,6 +1149,7 @@
"supports_prompt_caching": true, "supports_prompt_caching": true,
"supports_reasoning": true, "supports_reasoning": true,
"supports_response_schema": true, "supports_response_schema": true,
"supports_max_reasoning_effort": true,
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
@ -1711,6 +1720,7 @@
"search_context_size_low": 0.01, "search_context_size_low": 0.01,
"search_context_size_medium": 0.01 "search_context_size_medium": 0.01
}, },
"supports_adaptive_thinking": true,
"supports_assistant_prefill": true, "supports_assistant_prefill": true,
"supports_computer_use": true, "supports_computer_use": true,
"supports_function_calling": true, "supports_function_calling": true,
@ -1718,6 +1728,7 @@
"supports_prompt_caching": true, "supports_prompt_caching": true,
"supports_reasoning": true, "supports_reasoning": true,
"supports_response_schema": true, "supports_response_schema": true,
"supports_max_reasoning_effort": true,
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
@ -1853,6 +1864,7 @@
"supports_pdf_input": true, "supports_pdf_input": true,
"supports_prompt_caching": true, "supports_prompt_caching": true,
"supports_reasoning": true, "supports_reasoning": true,
"supports_minimal_reasoning_effort": true,
"supports_response_schema": true, "supports_response_schema": true,
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
@ -1880,6 +1892,7 @@
"supports_pdf_input": true, "supports_pdf_input": true,
"supports_prompt_caching": true, "supports_prompt_caching": true,
"supports_reasoning": true, "supports_reasoning": true,
"supports_minimal_reasoning_effort": true,
"supports_response_schema": true, "supports_response_schema": true,
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
@ -1901,6 +1914,7 @@
"search_context_size_low": 0.01, "search_context_size_low": 0.01,
"search_context_size_medium": 0.01 "search_context_size_medium": 0.01
}, },
"supports_adaptive_thinking": true,
"supports_assistant_prefill": false, "supports_assistant_prefill": false,
"supports_computer_use": true, "supports_computer_use": true,
"supports_function_calling": true, "supports_function_calling": true,
@ -1934,6 +1948,7 @@
"search_context_size_low": 0.01, "search_context_size_low": 0.01,
"search_context_size_medium": 0.01 "search_context_size_medium": 0.01
}, },
"supports_adaptive_thinking": true,
"supports_assistant_prefill": false, "supports_assistant_prefill": false,
"supports_computer_use": true, "supports_computer_use": true,
"supports_function_calling": true, "supports_function_calling": true,
@ -1967,6 +1982,7 @@
"search_context_size_low": 0.01, "search_context_size_low": 0.01,
"search_context_size_medium": 0.01 "search_context_size_medium": 0.01
}, },
"supports_adaptive_thinking": true,
"supports_assistant_prefill": false, "supports_assistant_prefill": false,
"supports_computer_use": true, "supports_computer_use": true,
"supports_function_calling": true, "supports_function_calling": true,
@ -2001,6 +2017,7 @@
"search_context_size_low": 0.01, "search_context_size_low": 0.01,
"search_context_size_medium": 0.01 "search_context_size_medium": 0.01
}, },
"supports_adaptive_thinking": true,
"supports_assistant_prefill": false, "supports_assistant_prefill": false,
"supports_computer_use": true, "supports_computer_use": true,
"supports_function_calling": true, "supports_function_calling": true,
@ -2144,6 +2161,7 @@
"supports_assistant_prefill": true, "supports_assistant_prefill": true,
"supports_function_calling": true, "supports_function_calling": true,
"supports_reasoning": true, "supports_reasoning": true,
"supports_minimal_reasoning_effort": true,
"supports_tool_choice": true "supports_tool_choice": true
}, },
"databricks/databricks-claude-sonnet-4": { "databricks/databricks-claude-sonnet-4": {
@ -2655,7 +2673,8 @@
"mode": "chat", "mode": "chat",
"output_cost_per_token": 0.000025, "output_cost_per_token": 0.000025,
"supports_function_calling": true, "supports_function_calling": true,
"supports_vision": true "supports_vision": true,
"supports_minimal_reasoning_effort": true
}, },
"gmi/anthropic/claude-sonnet-4.5": { "gmi/anthropic/claude-sonnet-4.5": {
"input_cost_per_token": 0.000003, "input_cost_per_token": 0.000003,
@ -3304,6 +3323,7 @@
"supports_function_calling": true, "supports_function_calling": true,
"supports_prompt_caching": true, "supports_prompt_caching": true,
"supports_reasoning": true, "supports_reasoning": true,
"supports_max_reasoning_effort": true,
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 159, "tool_use_system_prompt_tokens": 159,
@ -3322,6 +3342,7 @@
"supports_assistant_prefill": true, "supports_assistant_prefill": true,
"supports_computer_use": true, "supports_computer_use": true,
"supports_function_calling": true, "supports_function_calling": true,
"supports_minimal_reasoning_effort": true,
"supports_prompt_caching": true, "supports_prompt_caching": true,
"supports_reasoning": true, "supports_reasoning": true,
"supports_tool_choice": true, "supports_tool_choice": true,
@ -3343,6 +3364,7 @@
"supports_function_calling": true, "supports_function_calling": true,
"supports_prompt_caching": true, "supports_prompt_caching": true,
"supports_reasoning": true, "supports_reasoning": true,
"supports_max_reasoning_effort": true,
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
@ -3408,6 +3430,7 @@
"supports_prompt_caching": true, "supports_prompt_caching": true,
"supports_reasoning": true, "supports_reasoning": true,
"supports_response_schema": true, "supports_response_schema": true,
"supports_max_reasoning_effort": true,
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"supports_xhigh_reasoning_effort": true, "supports_xhigh_reasoning_effort": true,
@ -3786,6 +3809,7 @@
"supports_assistant_prefill": true, "supports_assistant_prefill": true,
"supports_computer_use": true, "supports_computer_use": true,
"supports_function_calling": true, "supports_function_calling": true,
"supports_minimal_reasoning_effort": true,
"supports_pdf_input": true, "supports_pdf_input": true,
"supports_prompt_caching": true, "supports_prompt_caching": true,
"supports_reasoning": true, "supports_reasoning": true,
@ -3814,6 +3838,7 @@
"supports_assistant_prefill": true, "supports_assistant_prefill": true,
"supports_computer_use": true, "supports_computer_use": true,
"supports_function_calling": true, "supports_function_calling": true,
"supports_minimal_reasoning_effort": true,
"supports_pdf_input": true, "supports_pdf_input": true,
"supports_prompt_caching": true, "supports_prompt_caching": true,
"supports_reasoning": true, "supports_reasoning": true,
@ -3841,6 +3866,7 @@
"supports_assistant_prefill": true, "supports_assistant_prefill": true,
"supports_computer_use": true, "supports_computer_use": true,
"supports_function_calling": true, "supports_function_calling": true,
"supports_minimal_reasoning_effort": true,
"supports_pdf_input": true, "supports_pdf_input": true,
"supports_prompt_caching": true, "supports_prompt_caching": true,
"supports_reasoning": true, "supports_reasoning": true,
@ -4107,6 +4133,7 @@
"output_cost_per_token": 0.000025, "output_cost_per_token": 0.000025,
"supports_assistant_prefill": true, "supports_assistant_prefill": true,
"supports_computer_use": true, "supports_computer_use": true,
"supports_minimal_reasoning_effort": true,
"supports_function_calling": true, "supports_function_calling": true,
"supports_prompt_caching": true, "supports_prompt_caching": true,
"supports_reasoning": true, "supports_reasoning": true,
@ -4446,6 +4473,7 @@
"supports_assistant_prefill": true, "supports_assistant_prefill": true,
"supports_computer_use": true, "supports_computer_use": true,
"supports_function_calling": true, "supports_function_calling": true,
"supports_minimal_reasoning_effort": true,
"supports_pdf_input": true, "supports_pdf_input": true,
"supports_prompt_caching": true, "supports_prompt_caching": true,
"supports_reasoning": true, "supports_reasoning": true,
@ -4472,6 +4500,7 @@
"supports_assistant_prefill": true, "supports_assistant_prefill": true,
"supports_computer_use": true, "supports_computer_use": true,
"supports_function_calling": true, "supports_function_calling": true,
"supports_minimal_reasoning_effort": true,
"supports_pdf_input": true, "supports_pdf_input": true,
"supports_prompt_caching": true, "supports_prompt_caching": true,
"supports_reasoning": true, "supports_reasoning": true,
@ -4638,6 +4667,7 @@
"supports_prompt_caching": true, "supports_prompt_caching": true,
"supports_reasoning": true, "supports_reasoning": true,
"supports_response_schema": true, "supports_response_schema": true,
"supports_max_reasoning_effort": true,
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
@ -4778,6 +4808,7 @@
"supports_prompt_caching": true, "supports_prompt_caching": true,
"supports_reasoning": true, "supports_reasoning": true,
"supports_response_schema": true, "supports_response_schema": true,
"supports_max_reasoning_effort": true,
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,

View file

@ -1,27 +1,27 @@
{ {
"version": "0.0.20", "version": "0.0.21",
"sourceRef": "v0.0.20", "sourceRef": "v0.0.21",
"sourceRepository": "777genius/agent_teams_orchestrator", "sourceRepository": "777genius/agent_teams_orchestrator",
"releaseRepository": "777genius/claude_agent_teams_ui", "releaseRepository": "777genius/claude_agent_teams_ui",
"releaseTag": "v1.2.0", "releaseTag": "v1.2.0",
"assets": { "assets": {
"darwin-arm64": { "darwin-arm64": {
"file": "agent-teams-runtime-darwin-arm64-v0.0.20.tar.gz", "file": "agent-teams-runtime-darwin-arm64-v0.0.21.tar.gz",
"archiveKind": "tar.gz", "archiveKind": "tar.gz",
"binaryName": "claude-multimodel" "binaryName": "claude-multimodel"
}, },
"darwin-x64": { "darwin-x64": {
"file": "agent-teams-runtime-darwin-x64-v0.0.20.tar.gz", "file": "agent-teams-runtime-darwin-x64-v0.0.21.tar.gz",
"archiveKind": "tar.gz", "archiveKind": "tar.gz",
"binaryName": "claude-multimodel" "binaryName": "claude-multimodel"
}, },
"linux-x64": { "linux-x64": {
"file": "agent-teams-runtime-linux-x64-v0.0.20.tar.gz", "file": "agent-teams-runtime-linux-x64-v0.0.21.tar.gz",
"archiveKind": "tar.gz", "archiveKind": "tar.gz",
"binaryName": "claude-multimodel" "binaryName": "claude-multimodel"
}, },
"win32-x64": { "win32-x64": {
"file": "agent-teams-runtime-win32-x64-v0.0.20.zip", "file": "agent-teams-runtime-win32-x64-v0.0.21.zip",
"archiveKind": "zip", "archiveKind": "zip",
"binaryName": "claude-multimodel.exe" "binaryName": "claude-multimodel.exe"
} }

View file

@ -1,27 +1,34 @@
import {
getTeamTaskWorkflowColumn,
isTeamTaskDeleted,
isTeamTaskFinishedForDependency,
isTeamTaskNeedsFixActionable,
} from '@shared/utils/teamTaskState';
import type { KanbanColumnId, KanbanTaskState, TeamTask, TeamTaskWithKanban } from '@shared/types'; import type { KanbanColumnId, KanbanTaskState, TeamTask, TeamTaskWithKanban } from '@shared/types';
type TaskColumnInput = Pick<TeamTaskWithKanban, 'status' | 'reviewState' | 'kanbanColumn'>; type TaskColumnInput = Pick<
TeamTaskWithKanban,
'status' | 'reviewState' | 'kanbanColumn' | 'deletedAt'
>;
type TaskReviewerInput = Pick<TeamTaskWithKanban, 'reviewer' | 'reviewState' | 'kanbanColumn'>; type TaskReviewerInput = Pick<TeamTaskWithKanban, 'reviewer' | 'reviewState' | 'kanbanColumn'>;
type TaskBlockInput = Pick<TeamTask, 'blockedBy'>; type TaskBlockInput = Pick<TeamTask, 'blockedBy'>;
type TaskBlockState = Pick<TeamTask, 'status'>; type TaskBlockState = Pick<
TeamTaskWithKanban,
'status' | 'reviewState' | 'kanbanColumn' | 'deletedAt'
>;
export function resolveTaskGraphColumn(task: TaskColumnInput): KanbanColumnId { export function resolveTaskGraphColumn(task: TaskColumnInput): KanbanColumnId {
if (task.reviewState === 'approved') return 'approved'; const workflowColumn = getTeamTaskWorkflowColumn(task);
if (task.reviewState === 'review' || task.reviewState === 'needsFix') return 'review'; if (workflowColumn) return workflowColumn;
if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') { if (isTeamTaskNeedsFixActionable(task)) return 'review';
return task.kanbanColumn;
}
if (task.status === 'in_progress') return 'in_progress'; if (task.status === 'in_progress') return 'in_progress';
if (task.status === 'completed') return 'done'; if (task.status === 'completed') return 'done';
return 'todo'; return 'todo';
} }
export function isTaskInReviewCycle(task: TaskColumnInput): boolean { export function isTaskInReviewCycle(task: TaskColumnInput): boolean {
return ( return isTeamTaskNeedsFixActionable(task) || getTeamTaskWorkflowColumn(task) === 'review';
task.reviewState === 'review' ||
task.reviewState === 'needsFix' ||
task.kanbanColumn === 'review'
);
} }
export function resolveTaskReviewer( export function resolveTaskReviewer(
@ -43,6 +50,6 @@ export function isTaskBlocked(
return blockedBy.some((taskId) => { return blockedBy.some((taskId) => {
const blocker = taskStateById.get(taskId); const blocker = taskStateById.get(taskId);
return !blocker || (blocker.status !== 'completed' && blocker.status !== 'deleted'); return !blocker || (!isTeamTaskFinishedForDependency(blocker) && !isTeamTaskDeleted(blocker));
}); });
} }

View file

@ -25,6 +25,10 @@ import {
} from '@shared/utils/idleNotificationSemantics'; } from '@shared/utils/idleNotificationSemantics';
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
import { isLeadMember } from '@shared/utils/leadDetection'; import { isLeadMember } from '@shared/utils/leadDetection';
import {
isTeamTaskActivelyWorked,
isTeamTaskNeedsFixActionable,
} from '@shared/utils/teamTaskState';
import { buildOrderedVisibleTeamGraphOwnerIds } from '@shared/utils/teamGraphDefaultLayout'; import { buildOrderedVisibleTeamGraphOwnerIds } from '@shared/utils/teamGraphDefaultLayout';
import { import {
@ -41,6 +45,7 @@ import {
import { import {
isTaskBlocked, isTaskBlocked,
isTaskInReviewCycle, isTaskInReviewCycle,
resolveTaskGraphColumn,
resolveTaskReviewer, resolveTaskReviewer,
} from '../../core/domain/taskGraphSemantics'; } from '../../core/domain/taskGraphSemantics';
@ -541,8 +546,17 @@ export class TeamGraphAdapter {
spawn, spawn,
pendingApprovalAgents?.has(member.name) ?? false pendingApprovalAgents?.has(member.name) ?? false
); );
const currentTask = member.currentTaskId
? data.tasks.find((task) => task.id === member.currentTaskId)
: undefined;
const displayableCurrentTask =
currentTask && isTeamTaskActivelyWorked(currentTask) ? currentTask : undefined;
const presentationMember =
member.currentTaskId && !displayableCurrentTask
? { ...member, currentTaskId: null }
: member;
const launchPresentation = buildMemberLaunchPresentation({ const launchPresentation = buildMemberLaunchPresentation({
member, member: presentationMember,
spawnStatus: spawn?.status, spawnStatus: spawn?.status,
spawnLaunchState: spawn?.launchState, spawnLaunchState: spawn?.launchState,
spawnLivenessSource: spawn?.livenessSource, spawnLivenessSource: spawn?.livenessSource,
@ -579,10 +593,8 @@ export class TeamGraphAdapter {
? (launchPresentation.launchStatusLabel ?? undefined) ? (launchPresentation.launchStatusLabel ?? undefined)
: undefined, : undefined,
avatarUrl: resolveMemberAvatarUrl(member, avatarMap, 96), avatarUrl: resolveMemberAvatarUrl(member, avatarMap, 96),
currentTaskId: member.currentTaskId ?? undefined, currentTaskId: displayableCurrentTask?.id,
currentTaskSubject: member.currentTaskId currentTaskSubject: displayableCurrentTask?.subject,
? data.tasks.find((t) => t.id === member.currentTaskId)?.subject
: undefined,
pendingApproval: pendingApprovalAgents?.has(member.name) ?? false, pendingApproval: pendingApprovalAgents?.has(member.name) ?? false,
exceptionTone: exception?.exceptionTone, exceptionTone: exception?.exceptionTone,
exceptionLabel: exception?.exceptionLabel, exceptionLabel: exception?.exceptionLabel,
@ -632,12 +644,20 @@ export class TeamGraphAdapter {
leadName?: string, leadName?: string,
activeTaskLogActivity?: Record<string, true> activeTaskLogActivity?: Record<string, true>
): void { ): void {
const taskStateById = new Map<string, Pick<TeamGraphData['tasks'][number], 'status'>>(); const taskStateById = new Map<
string,
Pick<TeamGraphData['tasks'][number], 'status' | 'reviewState' | 'kanbanColumn' | 'deletedAt'>
>();
const taskDisplayIds = new Map<string, string>(); const taskDisplayIds = new Map<string, string>();
const memberColorByName = new Map<string, string>(); const memberColorByName = new Map<string, string>();
for (const t of data.tasks) { for (const t of data.tasks) {
taskStateById.set(t.id, { status: t.status }); taskStateById.set(t.id, {
status: t.status,
...(t.reviewState ? { reviewState: t.reviewState } : {}),
...(t.kanbanColumn ? { kanbanColumn: t.kanbanColumn } : {}),
...(t.deletedAt ? { deletedAt: t.deletedAt } : {}),
});
taskDisplayIds.set(t.id, t.displayId ?? `#${t.id.slice(0, 6)}`); taskDisplayIds.set(t.id, t.displayId ?? `#${t.id.slice(0, 6)}`);
} }
for (const member of data.members) { for (const member of data.members) {
@ -660,9 +680,19 @@ export class TeamGraphAdapter {
const kanbanTaskState = data.kanbanState.tasks[task.id]; const kanbanTaskState = data.kanbanState.tasks[task.id];
const reviewerName = resolveTaskReviewer(task, kanbanTaskState); const reviewerName = resolveTaskReviewer(task, kanbanTaskState);
const isReviewCycle = isTaskInReviewCycle(task); const isReviewCycle = isTaskInReviewCycle(task);
const graphColumn = resolveTaskGraphColumn(task);
const taskStatus = TeamGraphAdapter.#mapTaskStatusLiteral(task.status); const taskStatus =
const reviewState = TeamGraphAdapter.#mapReviewState(task.reviewState); graphColumn === 'approved'
? 'completed'
: TeamGraphAdapter.#mapTaskStatusLiteral(task.status);
const reviewState =
graphColumn === 'approved'
? 'approved'
: graphColumn === 'review'
? isTeamTaskNeedsFixActionable(task)
? 'needsFix'
: 'review'
: TeamGraphAdapter.#mapReviewState(task.reviewState);
const blockedByDisplayIds = task.blockedBy?.length const blockedByDisplayIds = task.blockedBy?.length
? task.blockedBy.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`) ? task.blockedBy.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`)
@ -686,7 +716,8 @@ export class TeamGraphAdapter {
kind: 'task', kind: 'task',
label: task.displayId ?? `#${task.id.slice(0, 6)}`, label: task.displayId ?? `#${task.id.slice(0, 6)}`,
sublabel: task.subject, sublabel: task.subject,
state: TeamGraphAdapter.#mapTaskStatus(task.status), state:
graphColumn === 'approved' ? 'complete' : TeamGraphAdapter.#mapTaskStatus(task.status),
taskStatus, taskStatus,
reviewState, reviewState,
reviewerName: isReviewCycle ? reviewerName : null, reviewerName: isReviewCycle ? reviewerName : null,

View file

@ -13,6 +13,7 @@ import {
buildMemberAvatarMap, buildMemberAvatarMap,
buildMemberLaunchPresentation, buildMemberLaunchPresentation,
} from '@renderer/utils/memberHelpers'; } from '@renderer/utils/memberHelpers';
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation'; import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react'; import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react';
@ -309,6 +310,17 @@ const MemberPopoverContent = ({
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]); const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
const avatarSrc = node.avatarUrl ?? avatarMap.get(memberName) ?? agentAvatarUrl(memberName, 64); const avatarSrc = node.avatarUrl ?? avatarMap.get(memberName) ?? agentAvatarUrl(memberName, 64);
const member = teamMembers.find((candidate) => candidate.name === memberName) ?? null; const member = teamMembers.find((candidate) => candidate.name === memberName) ?? null;
const currentTaskCandidate =
member?.currentTaskId && teamData
? (teamData.tasks.find((task) => task.id === member.currentTaskId) ?? null)
: null;
const displayableCurrentTask = isDisplayableCurrentTask(currentTaskCandidate)
? currentTaskCandidate
: null;
const currentTaskIndicatorId =
displayableCurrentTask?.id ?? (!teamData ? node.currentTaskId : undefined);
const currentTaskIndicatorSubject =
displayableCurrentTask?.subject ?? (!teamData ? node.currentTaskSubject : undefined);
const provisioningPresentation = const provisioningPresentation =
teamData && teamName teamData && teamName
? buildTeamProvisioningPresentation({ ? buildTeamProvisioningPresentation({
@ -320,7 +332,10 @@ const MemberPopoverContent = ({
: null; : null;
const launchPresentation = member const launchPresentation = member
? buildMemberLaunchPresentation({ ? buildMemberLaunchPresentation({
member, member:
member.currentTaskId && !displayableCurrentTask
? { ...member, currentTaskId: null }
: member,
spawnStatus: spawnEntry?.status, spawnStatus: spawnEntry?.status,
spawnLaunchState: spawnEntry?.launchState, spawnLaunchState: spawnEntry?.launchState,
spawnLivenessSource: spawnEntry?.livenessSource, spawnLivenessSource: spawnEntry?.livenessSource,
@ -444,7 +459,7 @@ const MemberPopoverContent = ({
{/* Context usage stays hidden for now because lead context telemetry is still incomplete. */} {/* Context usage stays hidden for now because lead context telemetry is still incomplete. */}
{/* Current task indicator — reuses same pattern as MemberCard */} {/* Current task indicator — reuses same pattern as MemberCard */}
{node.currentTaskId && node.currentTaskSubject && ( {currentTaskIndicatorId && currentTaskIndicatorSubject && (
<div className="mt-2 flex items-center gap-1.5 text-[10px]"> <div className="mt-2 flex items-center gap-1.5 text-[10px]">
<Loader2 <Loader2
className="size-3 shrink-0 animate-spin" className="size-3 shrink-0 animate-spin"
@ -457,13 +472,13 @@ const MemberPopoverContent = ({
style={{ border: `1px solid ${node.color ?? '#66ccff'}40` }} style={{ border: `1px solid ${node.color ?? '#66ccff'}40` }}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onOpenTask?.(node.currentTaskId!); onOpenTask?.(currentTaskIndicatorId);
onClose(); onClose();
}} }}
> >
{node.currentTaskSubject.length > 30 {currentTaskIndicatorSubject.length > 30
? `${node.currentTaskSubject.slice(0, 30)}` ? `${currentTaskIndicatorSubject.slice(0, 30)}`
: node.currentTaskSubject} : currentTaskIndicatorSubject}
</button> </button>
</div> </div>
)} )}

View file

@ -7,6 +7,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { KanbanTaskCard } from '@renderer/components/team/kanban/KanbanTaskCard'; import { KanbanTaskCard } from '@renderer/components/team/kanban/KanbanTaskCard';
import { isTeamTaskNeedsFixActionable } from '@shared/utils/teamTaskState';
import { isTaskBlocked, resolveTaskGraphColumn } from '../../core/domain/taskGraphSemantics'; import { isTaskBlocked, resolveTaskGraphColumn } from '../../core/domain/taskGraphSemantics';
import { useGraphActivityContext } from '../hooks/useGraphActivityContext'; import { useGraphActivityContext } from '../hooks/useGraphActivityContext';
@ -49,7 +50,7 @@ function getGlowStyle(task: TeamTask, taskMap: ReadonlyMap<string, TeamTask>): R
boxShadow: '0 0 14px rgba(59, 130, 246, 0.4), inset 0 0 6px rgba(59, 130, 246, 0.08)', boxShadow: '0 0 14px rgba(59, 130, 246, 0.4), inset 0 0 6px rgba(59, 130, 246, 0.08)',
}; };
case 'review': case 'review':
return task.reviewState === 'needsFix' return isTeamTaskNeedsFixActionable(task)
? { boxShadow: '0 0 14px rgba(239, 68, 68, 0.4), inset 0 0 6px rgba(239, 68, 68, 0.08)' } ? { boxShadow: '0 0 14px rgba(239, 68, 68, 0.4), inset 0 0 6px rgba(239, 68, 68, 0.08)' }
: { boxShadow: '0 0 14px rgba(245, 158, 11, 0.4), inset 0 0 6px rgba(245, 158, 11, 0.08)' }; : { boxShadow: '0 0 14px rgba(245, 158, 11, 0.4), inset 0 0 6px rgba(245, 158, 11, 0.08)' };
case 'approved': case 'approved':

View file

@ -1,3 +1,10 @@
import {
getTeamTaskWorkflowColumn,
isTeamTaskFinishedForDependency,
isTeamTaskNeedsFixActionable,
isTeamTaskTerminalForActionableWork,
} from '@shared/utils/teamTaskState';
import { import {
buildAgendaFingerprintPayload, buildAgendaFingerprintPayload,
canonicalizeAgendaFingerprintPayload, canonicalizeAgendaFingerprintPayload,
@ -19,6 +26,7 @@ export interface MemberWorkSyncTaskLike {
status: string; status: string;
owner?: string | null; owner?: string | null;
reviewState?: string | null; reviewState?: string | null;
kanbanColumn?: string | null;
needsClarification?: 'lead' | 'user' | null; needsClarification?: 'lead' | 'user' | null;
blockedBy?: string[]; blockedBy?: string[];
blocks?: string[]; blocks?: string[];
@ -45,10 +53,6 @@ export interface BuildActionableWorkAgendaInput {
hash: (canonicalPayload: string) => string; hash: (canonicalPayload: string) => string;
} }
function isCompletedOrDeleted(task: MemberWorkSyncTaskLike): boolean {
return task.status === 'completed' || task.status === 'deleted' || Boolean(task.deletedAt);
}
function getActiveMemberNames(members: MemberWorkSyncMemberLike[]): Set<string> { function getActiveMemberNames(members: MemberWorkSyncMemberLike[]): Set<string> {
return new Set( return new Set(
members members
@ -114,7 +118,9 @@ export function buildActionableWorkAgenda(
if (activeMemberNames.has(memberName)) { if (activeMemberNames.has(memberName)) {
for (const task of input.tasks) { for (const task of input.tasks) {
if (!task.id || isCompletedOrDeleted(task)) { const workflowColumn = getTeamTaskWorkflowColumn(task);
const isReviewWorkflow = workflowColumn === 'review';
if (!task.id || (isTeamTaskTerminalForActionableWork(task) && !isReviewWorkflow)) {
continue; continue;
} }
@ -128,7 +134,7 @@ export function buildActionableWorkAgenda(
const dependency = tasksByReference.get(dependencyId) ?? null; const dependency = tasksByReference.get(dependencyId) ?? null;
if (!dependency || dependency.status === 'deleted' || dependency.deletedAt) { if (!dependency || dependency.status === 'deleted' || dependency.deletedAt) {
brokenDependencyIds.push(dependencyId); brokenDependencyIds.push(dependencyId);
} else if (dependency.status !== 'completed') { } else if (!isTeamTaskFinishedForDependency(dependency)) {
waitingDependencyIds.push(dependencyId); waitingDependencyIds.push(dependencyId);
} }
} }
@ -174,11 +180,13 @@ export function buildActionableWorkAgenda(
continue; continue;
} }
const reviewOwner = resolveCurrentReviewOwner({ const reviewOwner = isReviewWorkflow
reviewState: task.reviewState, ? resolveCurrentReviewOwner({
kanbanReviewer: input.kanbanReviewersByTaskId?.[task.id] ?? null, reviewState: task.reviewState,
historyEvents: task.historyEvents, kanbanReviewer: input.kanbanReviewersByTaskId?.[task.id] ?? null,
}); historyEvents: task.historyEvents,
})
: null;
if (reviewOwner && sameMemberName(reviewOwner.reviewer, memberName)) { if (reviewOwner && sameMemberName(reviewOwner.reviewer, memberName)) {
items.push({ items.push({
@ -199,6 +207,10 @@ export function buildActionableWorkAgenda(
continue; continue;
} }
if (isReviewWorkflow) {
continue;
}
if (!sameMemberName(owner, memberName)) { if (!sameMemberName(owner, memberName)) {
continue; continue;
} }
@ -214,18 +226,17 @@ export function buildActionableWorkAgenda(
if ( if (
task.status === 'pending' || task.status === 'pending' ||
task.status === 'in_progress' || task.status === 'in_progress' ||
task.reviewState === 'needsFix' isTeamTaskNeedsFixActionable(task)
) { ) {
items.push({ items.push({
...base, ...base,
kind: 'work', kind: 'work',
priority: 'normal', priority: 'normal',
reason: reason: isTeamTaskNeedsFixActionable(task)
task.reviewState === 'needsFix' ? 'review_changes_requested'
? 'review_changes_requested' : task.status === 'pending'
: task.status === 'pending' ? 'owned_pending_task'
? 'owned_pending_task' : 'owned_in_progress_task',
: 'owned_in_progress_task',
evidence: { evidence: {
status: task.status, status: task.status,
owner: memberName, owner: memberName,

View file

@ -6,6 +6,8 @@ export interface ReviewHistoryEventLike {
timestamp?: string; timestamp?: string;
actor?: string; actor?: string;
reviewer?: string; reviewer?: string;
from?: string;
to?: string;
} }
export interface CurrentReviewOwner { export interface CurrentReviewOwner {

View file

@ -1,4 +1,8 @@
import { isLeadMember } from '@shared/utils/leadDetection'; import { isLeadMember } from '@shared/utils/leadDetection';
import {
getTeamTaskWorkflowColumn,
isTeamTaskTerminalForActionableWork,
} from '@shared/utils/teamTaskState';
import { normalizeMemberName, resolveCurrentReviewOwner } from '../../../core/domain'; import { normalizeMemberName, resolveCurrentReviewOwner } from '../../../core/domain';
@ -20,10 +24,6 @@ export interface MemberWorkSyncTaskImpactResolverResult {
diagnostics: string[]; diagnostics: string[];
} }
function isTerminalTask(task: Pick<TeamTask, 'status' | 'deletedAt'>): boolean {
return task.status === 'completed' || task.status === 'deleted' || Boolean(task.deletedAt);
}
function isDeletedTask(task: Pick<TeamTask, 'status' | 'deletedAt'>): boolean { function isDeletedTask(task: Pick<TeamTask, 'status' | 'deletedAt'>): boolean {
return task.status === 'deleted' || Boolean(task.deletedAt); return task.status === 'deleted' || Boolean(task.deletedAt);
} }
@ -127,13 +127,6 @@ export class MemberWorkSyncTaskImpactResolver {
addMember(task.owner); addMember(task.owner);
const reviewOwner = resolveCurrentReviewOwner({
reviewState: task.reviewState,
kanbanReviewer: kanban.tasks[task.id]?.reviewer ?? null,
historyEvents: task.historyEvents,
});
addMember(reviewOwner?.reviewer);
if (!normalizeMemberName(task.owner)) { if (!normalizeMemberName(task.owner)) {
addLead(); addLead();
addDiagnostic('task_owner_missing'); addDiagnostic('task_owner_missing');
@ -142,7 +135,23 @@ export class MemberWorkSyncTaskImpactResolver {
addDiagnostic('task_owner_inactive'); addDiagnostic('task_owner_inactive');
} }
if (task.reviewState === 'review' && !reviewOwner?.reviewer) { const taskKanbanColumn = kanban.tasks[task.id]?.column;
const taskWorkflowColumn = getTeamTaskWorkflowColumn({
...task,
...(taskKanbanColumn ? { kanbanColumn: taskKanbanColumn } : {}),
});
const reviewOwner =
taskWorkflowColumn === 'review'
? resolveCurrentReviewOwner({
reviewState: task.reviewState,
kanbanReviewer: kanban.tasks[task.id]?.reviewer ?? null,
historyEvents: task.historyEvents,
})
: null;
addMember(reviewOwner?.reviewer);
if (taskWorkflowColumn === 'review' && !reviewOwner?.reviewer) {
addLead(); addLead();
addDiagnostic('task_reviewer_missing'); addDiagnostic('task_reviewer_missing');
} }
@ -166,7 +175,14 @@ export class MemberWorkSyncTaskImpactResolver {
} }
for (const candidate of tasks) { for (const candidate of tasks) {
if (candidate.id === task.id || isTerminalTask(candidate)) { const kanbanColumn = kanban.tasks[candidate.id]?.column;
if (
candidate.id === task.id ||
isTeamTaskTerminalForActionableWork({
...candidate,
...(kanbanColumn ? { kanbanColumn } : {}),
})
) {
continue; continue;
} }
if ( if (

View file

@ -122,7 +122,13 @@ export class TeamTaskAgendaSource implements MemberWorkSyncAgendaSourcePort {
teamName: input.teamName, teamName: input.teamName,
memberName: input.memberName, memberName: input.memberName,
generatedAt: this.deps.clock.now().toISOString(), generatedAt: this.deps.clock.now().toISOString(),
tasks, tasks: tasks.map((task) => {
const kanbanColumn = kanban.tasks[task.id]?.column;
return {
...task,
...(kanbanColumn ? { kanbanColumn } : {}),
};
}),
members: members.map(toMemberLike), members: members.map(toMemberLike),
kanbanReviewersByTaskId: Object.fromEntries( kanbanReviewersByTaskId: Object.fromEntries(
Object.entries(kanban.tasks).map(([taskId, value]) => [taskId, value.reviewer ?? null]) Object.entries(kanban.tasks).map(([taskId, value]) => [taskId, value.reviewer ?? null])

View file

@ -14,7 +14,7 @@ import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSema
import { isLeadMember } from '@shared/utils/leadDetection'; import { isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { migrateProviderBackendId } from '@shared/utils/providerBackend'; import { migrateProviderBackendId } from '@shared/utils/providerBackend';
import { getKanbanColumnFromReviewState, getReviewStateFromTask } from '@shared/utils/reviewState'; import { getReviewStateFromTask } from '@shared/utils/reviewState';
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands'; import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors'; import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
@ -62,6 +62,7 @@ import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificatio
import { TeamTaskReader } from './TeamTaskReader'; import { TeamTaskReader } from './TeamTaskReader';
import { TeamTaskWriter } from './TeamTaskWriter'; import { TeamTaskWriter } from './TeamTaskWriter';
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver'; import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
import { getTeamTaskWorkflowColumn, selectCurrentActiveTeamTask } from './teamTaskActiveState';
import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes'; import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes';
import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository'; import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository';
@ -550,13 +551,7 @@ export class TeamDataService {
const launchIdentity = teamMeta?.launchIdentity; const launchIdentity = teamMeta?.launchIdentity;
const leadName = 'team-lead'; const leadName = 'team-lead';
const ownedTasks = tasks.filter((task) => task.owner === leadName); const ownedTasks = tasks.filter((task) => task.owner === leadName);
const currentTask = const currentTask = selectCurrentActiveTeamTask(ownedTasks);
ownedTasks.find(
(task) =>
task.status === 'in_progress' &&
task.reviewState !== 'approved' &&
task.kanbanColumn !== 'approved'
) ?? null;
members.unshift({ members.unshift({
name: leadName, name: leadName,
@ -600,12 +595,24 @@ export class TeamDataService {
task: Pick<TeamTask, 'reviewState' | 'historyEvents' | 'status'>, task: Pick<TeamTask, 'reviewState' | 'historyEvents' | 'status'>,
kanbanTaskState?: KanbanState['tasks'][string] kanbanTaskState?: KanbanState['tasks'][string]
): 'none' | 'review' | 'needsFix' | 'approved' { ): 'none' | 'review' | 'needsFix' | 'approved' {
return getReviewStateFromTask({ const kanbanColumn = kanbanTaskState?.column;
const reviewState = getReviewStateFromTask({
historyEvents: task.historyEvents, historyEvents: task.historyEvents,
reviewState: task.reviewState, reviewState: task.reviewState,
status: task.status, status: task.status,
kanbanColumn: kanbanTaskState?.column, ...(kanbanColumn ? { kanbanColumn } : {}),
}); });
const workflowColumn = getTeamTaskWorkflowColumn({
status: task.status,
reviewState,
...(kanbanColumn ? { kanbanColumn } : {}),
});
if (workflowColumn) {
return workflowColumn;
}
return reviewState;
} }
private attachKanbanCompatibility( private attachKanbanCompatibility(
@ -614,14 +621,27 @@ export class TeamDataService {
): TeamTaskWithKanban { ): TeamTaskWithKanban {
const reviewState = this.resolveTaskReviewState(task, kanbanTaskState); const reviewState = this.resolveTaskReviewState(task, kanbanTaskState);
const reviewer = this.resolveReviewerFromHistory(task, kanbanTaskState, reviewState) ?? null; const reviewer = this.resolveReviewerFromHistory(task, kanbanTaskState, reviewState) ?? null;
const kanbanColumn = this.resolveTaskKanbanColumn(task, kanbanTaskState, reviewState);
return { return {
...task, ...task,
reviewState, reviewState,
kanbanColumn: getKanbanColumnFromReviewState(reviewState), ...(kanbanColumn ? { kanbanColumn } : {}),
reviewer, reviewer,
}; };
} }
private resolveTaskKanbanColumn(
task: Pick<TeamTask, 'status'>,
kanbanTaskState?: KanbanState['tasks'][string],
reviewState: 'none' | 'review' | 'needsFix' | 'approved' = 'none'
): 'review' | 'approved' | undefined {
return getTeamTaskWorkflowColumn({
status: task.status,
reviewState,
...(kanbanTaskState?.column ? { kanbanColumn: kanbanTaskState.column } : {}),
});
}
/** /**
* Extract reviewer name from the current review cycle history. * Extract reviewer name from the current review cycle history.
* For legacy boards that stored reviewer only in kanban state, preserve that * For legacy boards that stored reviewer only in kanban state, preserve that
@ -1023,7 +1043,7 @@ export class TeamDataService {
const info = teamInfoMap.get(task.teamName)!; const info = teamInfoMap.get(task.teamName)!;
const kanbanTaskState = kanbanByTeam.get(task.teamName)?.tasks[task.id]; const kanbanTaskState = kanbanByTeam.get(task.teamName)?.tasks[task.id];
const reviewState = this.resolveTaskReviewState(task, kanbanTaskState); const reviewState = this.resolveTaskReviewState(task, kanbanTaskState);
const kanbanColumn = getKanbanColumnFromReviewState(reviewState); const kanbanColumn = this.resolveTaskKanbanColumn(task, kanbanTaskState, reviewState);
// IPC payload safety: GlobalTask lists can be enormous (especially comments and large nested fields). // IPC payload safety: GlobalTask lists can be enormous (especially comments and large nested fields).
// Return a "light" task object and defer heavy details to team/task detail views. // Return a "light" task object and defer heavy details to team/task detail views.

View file

@ -9,6 +9,8 @@ import {
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId'; import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
import { selectCurrentActiveTeamTask } from './teamTaskActiveState';
import type { import type {
PersistedTeamLaunchSnapshot, PersistedTeamLaunchSnapshot,
TeamConfig, TeamConfig,
@ -282,13 +284,7 @@ export class TeamMemberResolver {
const members: TeamMemberSnapshot[] = []; const members: TeamMemberSnapshot[] = [];
for (const name of names) { for (const name of names) {
const ownedTasks = tasks.filter((task) => task.owner === name); const ownedTasks = tasks.filter((task) => task.owner === name);
const currentTask = const currentTask = selectCurrentActiveTeamTask(ownedTasks);
ownedTasks.find(
(task) =>
task.status === 'in_progress' &&
task.reviewState !== 'approved' &&
task.kanbanColumn !== 'approved'
) ?? null;
const configMember = configMemberMap.get(name); const configMember = configMemberMap.get(name);
const metaMember = metaMemberMap.get(name); const metaMember = metaMemberMap.get(name);
const launchMember = launchMemberMap.get(name); const launchMember = launchMemberMap.get(name);

View file

@ -47,6 +47,8 @@ const QUOTA_EXHAUSTED_TOKENS = [
'quota exceeded', 'quota exceeded',
'quota exhausted', 'quota exhausted',
'insufficient credits', 'insufficient credits',
'key limit exceeded',
'total limit',
]; ];
const RATE_LIMITED_TOKENS = [ const RATE_LIMITED_TOKENS = [
'rate limit', 'rate limit',
@ -83,6 +85,18 @@ const PROVIDER_OVERLOADED_TOKENS = [
'service unavailable', 'service unavailable',
'503', '503',
]; ];
const PROTOCOL_PROOF_MISSING_TOKENS = [
'non_visible_tool_without_task_progress',
'visible_reply_still_required',
'visible_reply_ack_only_still_requires_answer',
'plain_text_ack_only_still_requires_answer',
'visible_reply_destination_not_found_yet',
'visible_reply_missing_relayofmessageid',
'did not create a visible reply',
'did not create a visible message_send reply',
'did not create a visible reply or task progress proof',
'without the required relayofmessageid correlation',
];
const logger = createLogger('Service:TeamMemberRuntimeAdvisory'); const logger = createLogger('Service:TeamMemberRuntimeAdvisory');
interface CachedRuntimeAdvisory { interface CachedRuntimeAdvisory {
@ -123,6 +137,9 @@ function classifyRetryReason(message: string | undefined): MemberRuntimeAdvisory
if (includesAnyToken(normalized, PROVIDER_OVERLOADED_TOKENS)) { if (includesAnyToken(normalized, PROVIDER_OVERLOADED_TOKENS)) {
return 'provider_overloaded'; return 'provider_overloaded';
} }
if (includesAnyToken(normalized, PROTOCOL_PROOF_MISSING_TOKENS)) {
return 'protocol_proof_missing';
}
return 'backend_error'; return 'backend_error';
} }

View file

@ -131,8 +131,7 @@ function buildSyntheticBootstrapMessages(config: TeamConfig): InboxMessage[] {
member && member &&
member.name?.trim() && member.name?.trim() &&
member.name.trim().toLowerCase() !== normalizedLeadName && member.name.trim().toLowerCase() !== normalizedLeadName &&
member.removedAt == null && member.removedAt == null
(member as { isActive?: unknown }).isActive !== false
) )
.map((member) => ({ .map((member) => ({
from: leadName, from: leadName,

View file

@ -84,6 +84,12 @@ import { createLogger } from '@shared/utils/logger';
import { migrateProviderBackendId } from '@shared/utils/providerBackend'; import { migrateProviderBackendId } from '@shared/utils/providerBackend';
import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection'; import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import {
getTeamTaskWorkflowColumn,
isTeamTaskActivelyWorked,
isTeamTaskDeleted,
isTeamTaskNeedsFixActionable,
} from '@shared/utils/teamTaskState';
import { import {
isTeamInternalControlMessageText, isTeamInternalControlMessageText,
stripExactInternalControlEchoPrefix, stripExactInternalControlEchoPrefix,
@ -163,7 +169,10 @@ import {
type OpenCodePromptDeliveryLedgerStore, type OpenCodePromptDeliveryLedgerStore,
type OpenCodePromptDeliveryStatus, type OpenCodePromptDeliveryStatus,
} from './opencode/delivery/OpenCodePromptDeliveryLedger'; } from './opencode/delivery/OpenCodePromptDeliveryLedger';
import { selectOpenCodeRuntimeDeliveryReason } from './opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics'; import {
isActionRequiredOpenCodeRuntimeDeliveryReason,
selectOpenCodeRuntimeDeliveryReason,
} from './opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics';
import { import {
decideOpenCodePromptDeliveryRepair, decideOpenCodePromptDeliveryRepair,
type OpenCodePromptDeliveryHardFailureKind, type OpenCodePromptDeliveryHardFailureKind,
@ -4392,16 +4401,34 @@ function getAgentLanguageInstruction(): string {
return `IMPORTANT: Communicate in ${languageName}. All messages, summaries, and task descriptions MUST be in ${languageName}.`; return `IMPORTANT: Communicate in ${languageName}. All messages, summaries, and task descriptions MUST be in ${languageName}.`;
} }
function isTaskBoardSnapshotWorkCandidate(task: TeamTask): boolean {
if (!task.id || task.id.startsWith('_internal') || isTeamTaskDeleted(task)) {
return false;
}
const workflowColumn = getTeamTaskWorkflowColumn(task);
if (workflowColumn === 'review' || workflowColumn === 'approved') {
return false;
}
return (
task.status === 'pending' ||
isTeamTaskNeedsFixActionable(task) ||
isTeamTaskActivelyWorked(task)
);
}
/** Build a full task board snapshot for the lead. */ /** Build a full task board snapshot for the lead. */
function buildTaskBoardSnapshot(tasks: TeamTask[]): string { function buildTaskBoardSnapshot(tasks: TeamTask[]): string {
const active = tasks.filter( const active = tasks.filter(isTaskBoardSnapshotWorkCandidate);
(t) => (t.status === 'pending' || t.status === 'in_progress') && !t.id.startsWith('_internal')
);
if (active.length === 0) return '\nNo pending tasks on the board.\n'; if (active.length === 0) return '\nNo pending tasks on the board.\n';
const lines = active.map((t) => { const lines = active.map((t) => {
const owner = t.owner ? ` (owner: ${t.owner})` : ' (unassigned)'; const owner = t.owner ? ` (owner: ${t.owner})` : ' (unassigned)';
const desc = t.description ? `${t.description.slice(0, 120)}` : ''; const desc = t.description ? `${t.description.slice(0, 120)}` : '';
const stateLabel = [t.status, isTeamTaskNeedsFixActionable(t) ? 'needsFix' : null]
.filter(Boolean)
.join(', ');
const deps = t.blockedBy?.length const deps = t.blockedBy?.length
? ` [blocked by: ${t.blockedBy ? ` [blocked by: ${t.blockedBy
.map((id) => tasks.find((candidate) => candidate.id === id)) .map((id) => tasks.find((candidate) => candidate.id === id))
@ -4409,9 +4436,9 @@ function buildTaskBoardSnapshot(tasks: TeamTask[]): string {
.map((task) => formatTaskDisplayLabel(task)) .map((task) => formatTaskDisplayLabel(task))
.join(', ')}]` .join(', ')}]`
: ''; : '';
return ` - ${formatTaskDisplayLabel(t)} (taskId: ${t.id}) [${t.status}]${owner} ${t.subject}${deps}${desc}`; return ` - ${formatTaskDisplayLabel(t)} (taskId: ${t.id}) [${stateLabel}]${owner} ${t.subject}${deps}${desc}`;
}); });
return `\nCurrent task board (in_progress/pending):\n${lines.join('\n')}\n`; return `\nCurrent actionable task board (pending/in_progress/needsFix):\n${lines.join('\n')}\n`;
} }
function buildDeterministicLaunchHydrationPrompt( function buildDeterministicLaunchHydrationPrompt(
@ -7523,16 +7550,56 @@ export class TeamProvisioningService {
...extra, ...extra,
}) })
); );
if ( const shouldNotifyTerminalFailure =
event === 'opencode_prompt_delivery_terminal_failure' && event === 'opencode_prompt_delivery_terminal_failure' && record.status === 'failed_terminal';
record.status === 'failed_terminal' const shouldNotifyActionRequiredRetry =
) { !shouldNotifyTerminalFailure &&
this.shouldNotifyOpenCodeRuntimeDeliveryBeforeTerminal(record);
if (shouldNotifyTerminalFailure || shouldNotifyActionRequiredRetry) {
void this.fireOpenCodeRuntimeDeliveryErrorNotification(record).catch((error) => { void this.fireOpenCodeRuntimeDeliveryErrorNotification(record).catch((error) => {
logger.warn( logger.warn(
`[${record.teamName}] Failed to fire OpenCode runtime delivery error notification for ${record.memberName}: ${getErrorMessage(error)}` `[${record.teamName}] Failed to fire OpenCode runtime delivery error notification for ${record.memberName}: ${getErrorMessage(error)}`
); );
}); });
return;
} }
if (this.shouldSurfaceOpenCodeRuntimeDeliveryAdvisory(record)) {
this.emitOpenCodeRuntimeDeliveryAdvisoryEvent(record);
}
}
private shouldSurfaceOpenCodeRuntimeDeliveryAdvisory(
record: OpenCodePromptDeliveryLedgerRecord
): boolean {
if (!selectOpenCodeRuntimeDeliveryReason(record)) {
return false;
}
if (record.status === 'failed_terminal') {
return true;
}
if (record.status === 'responded') {
return false;
}
return (
record.responseState === 'session_error' ||
record.responseState === 'tool_error' ||
record.responseState === 'permission_blocked' ||
record.responseState === 'reconcile_failed'
);
}
private shouldNotifyOpenCodeRuntimeDeliveryBeforeTerminal(
record: OpenCodePromptDeliveryLedgerRecord
): boolean {
if (!this.shouldSurfaceOpenCodeRuntimeDeliveryAdvisory(record)) {
return false;
}
if (record.status === 'failed_terminal') {
return false;
}
return isActionRequiredOpenCodeRuntimeDeliveryReason(
selectOpenCodeRuntimeDeliveryReason(record)
);
} }
private async fireOpenCodeRuntimeDeliveryErrorNotification( private async fireOpenCodeRuntimeDeliveryErrorNotification(
@ -7596,7 +7663,8 @@ export class TeamProvisioningService {
); );
} }
const eventKey = `opencode_runtime_delivery_error:${record.teamName}:${record.memberName}:${record.id}`; const reasonKey = this.getOpenCodeRuntimeDeliveryAdvisoryReasonKey(record);
const eventKey = `opencode_runtime_delivery_error:${record.teamName}:${record.memberName}:${record.id}:${reasonKey}`;
const now = Date.now(); const now = Date.now();
this.pruneOpenCodeRuntimeDeliveryAdvisoryEventDedupe(now); this.pruneOpenCodeRuntimeDeliveryAdvisoryEventDedupe(now);
if (this.openCodeRuntimeDeliveryAdvisoryEventSentAt.has(eventKey)) { if (this.openCodeRuntimeDeliveryAdvisoryEventSentAt.has(eventKey)) {
@ -7626,6 +7694,20 @@ export class TeamProvisioningService {
} }
} }
private getOpenCodeRuntimeDeliveryAdvisoryReasonKey(
record: OpenCodePromptDeliveryLedgerRecord
): string {
const reason =
selectOpenCodeRuntimeDeliveryReason(record) ?? record.responseState ?? record.status;
const normalized = reason
.toLowerCase()
.replace(/https?:\/\/\S+/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 96);
return normalized || 'unknown';
}
private async notifyLeadAboutOpenCodeRuntimeDeliveryError(input: { private async notifyLeadAboutOpenCodeRuntimeDeliveryError(input: {
record: OpenCodePromptDeliveryLedgerRecord; record: OpenCodePromptDeliveryLedgerRecord;
reason: string; reason: string;
@ -27536,18 +27618,14 @@ export class TeamProvisioningService {
try { try {
const taskReader = new TeamTaskReader(); const taskReader = new TeamTaskReader();
const tasks = await taskReader.getTasks(run.teamName); const tasks = await taskReader.getTasks(run.teamName);
const active = tasks.filter( const active = tasks.filter(isTaskBoardSnapshotWorkCandidate);
(t) =>
(t.status === 'pending' || t.status === 'in_progress') &&
!t.id.startsWith('_internal')
);
if (active.length === 0) return; if (active.length === 0) return;
const board = buildTaskBoardSnapshot(tasks); const board = buildTaskBoardSnapshot(tasks);
const message = [ const message = [
`Reconnected and ready. Begin executing tasks now.`, `Reconnected and ready. Begin executing tasks now.`,
`Execute tasks sequentially and keep the board + user updated:`, `Execute tasks sequentially and keep the board + user updated:`,
`- Identify the next READY task (pending, not blocked by incomplete dependencies).`, `- Identify the next READY task (pending or needsFix, not blocked by incomplete dependencies).`,
`- If the task is unassigned, set yourself as owner.`, `- If the task is unassigned, set yourself as owner.`,
`- BEFORE doing any work on a task: mark it started (in_progress).`, `- BEFORE doing any work on a task: mark it started (in_progress).`,
`- Immediately SendMessage "user" that you started task #<id> (what you're doing + next step).`, `- Immediately SendMessage "user" that you started task #<id> (what you're doing + next step).`,

View file

@ -19,6 +19,25 @@ const GENERIC_DELIVERY_DIAGNOSTIC_TOKENS = [
'visible_reply_ack_only_still_requires_answer', 'visible_reply_ack_only_still_requires_answer',
'visible_reply_destination_not_found_yet', 'visible_reply_destination_not_found_yet',
'visible_reply_missing_relayofmessageid', 'visible_reply_missing_relayofmessageid',
'non_visible_tool_without_task_progress',
] as const;
const ACTION_REQUIRED_DELIVERY_ERROR_TOKENS = [
'auth_unavailable',
'no auth available',
'authentication_failed',
'unauthorized',
'forbidden',
'invalid api key',
'api key',
'does not have access',
'please run /login',
'insufficient credits',
'quota exceeded',
'quota exhausted',
'capacity exceeded',
'key limit exceeded',
'total limit',
] as const; ] as const;
export function normalizeOpenCodeRuntimeDeliveryDiagnostic( export function normalizeOpenCodeRuntimeDeliveryDiagnostic(
@ -61,6 +80,16 @@ export function selectOpenCodeRuntimeDeliveryReason(
return normalized.length > 0 ? 'OpenCode runtime delivery did not complete.' : null; return normalized.length > 0 ? 'OpenCode runtime delivery did not complete.' : null;
} }
export function isActionRequiredOpenCodeRuntimeDeliveryReason(
message: string | null | undefined
): boolean {
const normalized = normalizeOpenCodeRuntimeDeliveryDiagnostic(message)?.toLowerCase();
if (!normalized) {
return false;
}
return ACTION_REQUIRED_DELIVERY_ERROR_TOKENS.some((token) => normalized.includes(token));
}
function getOpenCodeRuntimeDeliveryStateFallback( function getOpenCodeRuntimeDeliveryStateFallback(
record: OpenCodePromptDeliveryLedgerRecord record: OpenCodePromptDeliveryLedgerRecord
): string | null { ): string | null {
@ -88,6 +117,9 @@ function getOpenCodeRuntimeDeliveryStateFallback(
) { ) {
return 'OpenCode created a reply without the required relayOfMessageId correlation.'; return 'OpenCode created a reply without the required relayOfMessageId correlation.';
} }
if (reason === 'non_visible_tool_without_task_progress') {
return 'OpenCode used tools, but did not create a visible reply or task progress proof.';
}
return null; return null;
} }

View file

@ -107,6 +107,7 @@ export class TeamTaskStallNotifier {
taskRefs: [args.alert.taskRef], taskRefs: [args.alert.taskRef],
actionMode: 'do', actionMode: 'do',
source: 'system_notification', source: 'system_notification',
messageKind: 'task_stall_remediation',
}; };
await this.inboxWriter.sendMessage(args.teamName, request); await this.inboxWriter.sendMessage(args.teamName, request);
return true; return true;

View file

@ -10,6 +10,7 @@ import { isBoardTaskExactLogsReadEnabled } from '../taskLogs/exact/featureGates'
import { TeamKanbanManager } from '../TeamKanbanManager'; import { TeamKanbanManager } from '../TeamKanbanManager';
import { TeamMembersMetaStore } from '../TeamMembersMetaStore'; import { TeamMembersMetaStore } from '../TeamMembersMetaStore';
import { TeamTaskReader } from '../TeamTaskReader'; import { TeamTaskReader } from '../TeamTaskReader';
import { getTeamTaskWorkflowColumn, isTeamTaskActivelyWorked } from '../teamTaskActiveState';
import { BoardTaskActivityBatchIndexer } from './BoardTaskActivityBatchIndexer'; import { BoardTaskActivityBatchIndexer } from './BoardTaskActivityBatchIndexer';
import { OpenCodeTaskStallEvidenceSource } from './OpenCodeTaskStallEvidenceSource'; import { OpenCodeTaskStallEvidenceSource } from './OpenCodeTaskStallEvidenceSource';
@ -87,12 +88,47 @@ export class TeamTaskStallSnapshotSource {
this.kanbanManager.getState(teamName), this.kanbanManager.getState(teamName),
this.membersMetaStore.getMembers(teamName).catch(() => []), this.membersMetaStore.getMembers(teamName).catch(() => []),
]); ]);
const allTasks = [...activeTasks, ...deletedTasks]; const withWorkflowOverlay = (task: TeamTask): TeamTask => {
const kanbanColumn = kanbanState.tasks[task.id]?.column;
const workflowColumn = getTeamTaskWorkflowColumn({
...task,
...(kanbanColumn ? { kanbanColumn } : {}),
});
if (workflowColumn) {
return task.reviewState !== workflowColumn
? { ...task, reviewState: workflowColumn }
: task;
}
return task.reviewState === 'review' || task.reviewState === 'approved'
? { ...task, reviewState: 'none' }
: task;
};
const workflowActiveTasks = activeTasks.map(withWorkflowOverlay);
const allTasks = [...workflowActiveTasks, ...deletedTasks];
const allTasksById = new Map(allTasks.map((task) => [task.id, task] as const)); const allTasksById = new Map(allTasks.map((task) => [task.id, task] as const));
const inProgressTasks = activeTasks.filter( const inProgressTasks = workflowActiveTasks.filter((task) => {
(task) => task.status === 'in_progress' && task.reviewState !== 'review' const kanbanColumn = kanbanState.tasks[task.id]?.column;
); const workflowColumn = getTeamTaskWorkflowColumn({
const reviewOpenTasks = activeTasks.filter((task) => task.reviewState === 'review'); ...task,
...(kanbanColumn ? { kanbanColumn } : {}),
});
return (
workflowColumn !== 'review' &&
isTeamTaskActivelyWorked({
...task,
...(kanbanColumn ? { kanbanColumn } : {}),
})
);
});
const reviewOpenTasks = workflowActiveTasks.filter((task) => {
const kanbanColumn = kanbanState.tasks[task.id]?.column;
return (
getTeamTaskWorkflowColumn({
...task,
...(kanbanColumn ? { kanbanColumn } : {}),
}) === 'review'
);
});
const resolvedReviewersByTaskId = buildResolvedReviewerIndex(activeTasks, kanbanState); const resolvedReviewersByTaskId = buildResolvedReviewerIndex(activeTasks, kanbanState);
const activityReadsEnabled = isBoardTaskActivityReadEnabled(); const activityReadsEnabled = isBoardTaskActivityReadEnabled();
const exactReadsEnabled = isBoardTaskExactLogsReadEnabled(); const exactReadsEnabled = isBoardTaskExactLogsReadEnabled();
@ -157,7 +193,7 @@ export class TeamTaskStallSnapshotSource {
transcriptFiles: transcriptContext.transcriptFiles, transcriptFiles: transcriptContext.transcriptFiles,
activityReadsEnabled, activityReadsEnabled,
exactReadsEnabled, exactReadsEnabled,
activeTasks, activeTasks: workflowActiveTasks,
deletedTasks, deletedTasks,
allTasksById, allTasksById,
inProgressTasks, inProgressTasks,

View 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;
}

View file

@ -1239,7 +1239,9 @@ function normalizeFallbackReviewState(value: unknown, status: string): string {
if (status === 'in_progress' || status === 'deleted') return 'none'; if (status === 'in_progress' || status === 'deleted') return 'none';
if (status === 'pending') return reviewState === 'needsFix' ? 'needsFix' : 'none'; if (status === 'pending') return reviewState === 'needsFix' ? 'needsFix' : 'none';
if (status === 'completed') { if (status === 'completed') {
return reviewState === 'review' || reviewState === 'approved' ? reviewState : 'none'; return reviewState === 'review' || reviewState === 'approved' || reviewState === 'needsFix'
? reviewState
: 'none';
} }
return reviewState; return reviewState;
} }
@ -1444,9 +1446,11 @@ async function readTasksDirForTeam(
parsed.status === 'deleted' parsed.status === 'deleted'
? (parsed.status as string) ? (parsed.status as string)
: 'pending'; : 'pending';
const derivedReviewState = deriveReviewStateFromEvents(historyEvents);
const reviewState = const reviewState =
deriveReviewStateFromEvents(historyEvents) ?? derivedReviewState !== null
normalizeFallbackReviewState(parsed.reviewState, status); ? normalizeFallbackReviewState(derivedReviewState, status)
: normalizeFallbackReviewState(parsed.reviewState, status);
const task = { const task = {
id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '', id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',

View file

@ -9,7 +9,10 @@ import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/membe
import { nameColorSet } from '@renderer/utils/projectColor'; import { nameColorSet } from '@renderer/utils/projectColor';
import { projectColor } from '@renderer/utils/projectColor'; import { projectColor } from '@renderer/utils/projectColor';
import { projectLabelFromPath } from '@renderer/utils/taskGrouping'; import { projectLabelFromPath } from '@renderer/utils/taskGrouping';
import { getTaskKanbanColumn } from '@shared/utils/reviewState'; import {
getTeamTaskWorkflowColumn,
isTeamTaskNeedsFixActionable,
} from '@shared/utils/teamTaskState';
import { format, isThisYear, isToday, isYesterday } from 'date-fns'; import { format, isThisYear, isToday, isYesterday } from 'date-fns';
import { CheckCircle2, Circle, Eye, Loader2, ShieldCheck, Trash2 } from 'lucide-react'; import { CheckCircle2, Circle, Eye, Loader2, ShieldCheck, Trash2 } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
@ -105,7 +108,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
} }
}, [isRenaming, displaySubject]); }, [isRenaming, displaySubject]);
const reviewColumn = getTaskKanbanColumn(task); const reviewColumn = getTeamTaskWorkflowColumn(task);
const cfg = const cfg =
reviewColumn === 'approved' reviewColumn === 'approved'
? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const) ? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const)
@ -212,7 +215,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
</span> </span>
))} ))}
{displaySubject} {displaySubject}
{task.reviewState === 'needsFix' && ( {isTeamTaskNeedsFixActionable(task) && (
<span <span
className={`ml-1.5 inline-block rounded-full px-1.5 py-0.5 align-middle text-[10px] font-medium leading-none ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`} className={`ml-1.5 inline-block rounded-full px-1.5 py-0.5 align-middle text-[10px] font-medium leading-none ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
> >

View file

@ -1,7 +1,10 @@
import { useSyncExternalStore } from 'react'; import { useSyncExternalStore } from 'react';
import { getSnapshot, getUnreadCount, subscribe } from '@renderer/services/commentReadStorage'; import { getSnapshot, getUnreadCount, subscribe } from '@renderer/services/commentReadStorage';
import { getTaskKanbanColumn } from '@shared/utils/reviewState'; import {
getTeamTaskWorkflowColumn,
isTeamTaskNeedsFixActionable,
} from '@shared/utils/teamTaskState';
export type TaskStatusFilterId = export type TaskStatusFilterId =
| 'todo' | 'todo'
@ -50,10 +53,10 @@ export function taskMatchesStatus(
if (statusIds.size === 0) return false; if (statusIds.size === 0) return false;
if (statusIds.size === STATUS_OPTIONS.length) return task.status !== 'deleted'; if (statusIds.size === STATUS_OPTIONS.length) return task.status !== 'deleted';
const kanbanColumn = getTaskKanbanColumn(task); const kanbanColumn = getTeamTaskWorkflowColumn(task);
const inNeedsFix = task.reviewState === 'needsFix'; const inNeedsFix = isTeamTaskNeedsFixActionable(task);
const inTodo = task.status === 'pending' && !kanbanColumn && !inNeedsFix; const inTodo = task.status === 'pending' && !kanbanColumn && !inNeedsFix;
const inProgress = task.status === 'in_progress' && !kanbanColumn; const inProgress = task.status === 'in_progress' && !kanbanColumn && !inNeedsFix;
const inDone = task.status === 'completed' && !kanbanColumn && !inNeedsFix; const inDone = task.status === 'completed' && !kanbanColumn && !inNeedsFix;
const inReview = kanbanColumn === 'review'; const inReview = kanbanColumn === 'review';
const inApproved = kanbanColumn === 'approved'; const inApproved = kanbanColumn === 'approved';

View file

@ -7,7 +7,10 @@ import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils'; import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
import { getTaskKanbanColumn } from '@shared/utils/reviewState'; import {
getTeamTaskWorkflowColumn,
isTeamTaskNeedsFixActionable,
} from '@shared/utils/teamTaskState';
import { formatTaskDisplayLabel, taskMatchesRef } from '@shared/utils/taskIdentity'; import { formatTaskDisplayLabel, taskMatchesRef } from '@shared/utils/taskIdentity';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
@ -29,7 +32,7 @@ const STATUS_COLORS: Record<string, { text: string; bg: string }> = {
}; };
function getEffectiveColumn(task: TeamTaskWithKanban): string { function getEffectiveColumn(task: TeamTaskWithKanban): string {
const reviewColumn = getTaskKanbanColumn(task); const reviewColumn = getTeamTaskWorkflowColumn(task);
if (reviewColumn) return reviewColumn; if (reviewColumn) return reviewColumn;
if (task.status === 'pending') return 'todo'; if (task.status === 'pending') return 'todo';
if (task.status === 'completed') return 'done'; if (task.status === 'completed') return 'done';
@ -159,7 +162,7 @@ export const TaskTooltip = memo(function TaskTooltip({
> >
{label} {label}
</span> </span>
{task.reviewState === 'needsFix' ? ( {isTeamTaskNeedsFixActionable(task) ? (
<span <span
className={`inline-block rounded px-1.5 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`} className={`inline-block rounded px-1.5 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
> >

View file

@ -10,7 +10,9 @@ import {
buildMemberColorMap, buildMemberColorMap,
displayMemberName, displayMemberName,
} from '@renderer/utils/memberHelpers'; } from '@renderer/utils/memberHelpers';
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
import { ChevronRight } from 'lucide-react'; import { ChevronRight } from 'lucide-react';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
@ -27,7 +29,7 @@ interface ActiveTasksBlockProps {
interface ActivityEntry { interface ActivityEntry {
member: ResolvedTeamMember; member: ResolvedTeamMember;
task: TeamTaskWithKanban | undefined; task: TeamTaskWithKanban;
taskId: string; taskId: string;
kind: 'working' | 'reviewing'; kind: 'working' | 'reviewing';
} }
@ -53,8 +55,8 @@ export const ActiveTasksBlock = memo(function ActiveTasksBlock({
for (const m of members) { for (const m of members) {
if (!m.currentTaskId) continue; if (!m.currentTaskId) continue;
const task = taskMap.get(m.currentTaskId); const task = taskMap.get(m.currentTaskId);
// Defense-in-depth: hide banner for approved/completed tasks even if currentTaskId is stale // Defense-in-depth: hide stale currentTaskId until backend refresh clears it.
if (task && (task.reviewState === 'approved' || task.status === 'completed')) continue; if (!isDisplayableCurrentTask(task)) continue;
workingMemberNames.add(m.name); workingMemberNames.add(m.name);
entries.push({ member: m, task, taskId: m.currentTaskId, kind: 'working' }); entries.push({ member: m, task, taskId: m.currentTaskId, kind: 'working' });
} }
@ -63,7 +65,7 @@ export const ActiveTasksBlock = memo(function ActiveTasksBlock({
for (const m of members) { for (const m of members) {
if (workingMemberNames.has(m.name)) continue; if (workingMemberNames.has(m.name)) continue;
const reviewTask = tasks.find( const reviewTask = tasks.find(
(t) => t.reviewer === m.name && (t.reviewState === 'review' || t.kanbanColumn === 'review') (t) => t.reviewer === m.name && getTeamTaskWorkflowColumn(t) === 'review'
); );
if (reviewTask) { if (reviewTask) {
entries.push({ member: m, task: reviewTask, taskId: reviewTask.id, kind: 'reviewing' }); entries.push({ member: m, task: reviewTask, taskId: reviewTask.id, kind: 'reviewing' });

View file

@ -63,6 +63,7 @@ import {
getKnownSlashCommand, getKnownSlashCommand,
parseStandaloneSlashCommand, parseStandaloneSlashCommand,
} from '@shared/utils/slashCommands'; } from '@shared/utils/slashCommands';
import { isTaskStallRemediationMessage } from '@shared/utils/teamAutomationMessages';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { import {
AlertTriangle, AlertTriangle,
@ -379,6 +380,68 @@ const PassiveIdlePeerSummaryRow = ({
); );
}; };
const TaskStallRemediationRow = ({
teamName,
recipientName,
recipientColor,
taskRef,
timestamp,
onMemberNameClick,
onTaskIdClick,
}: {
teamName: string;
recipientName: string;
recipientColor?: string;
taskRef?: NonNullable<InboxMessage['taskRefs']>[number];
timestamp: string;
onMemberNameClick?: (memberName: string) => void;
onTaskIdClick?: (taskId: string) => void;
}): React.JSX.Element => {
const taskLabel = taskRef
? formatTaskDisplayLabel({ id: taskRef.taskId, displayId: taskRef.displayId })
: null;
return (
<div className="flex items-center gap-2 px-3 py-1.5" style={{ opacity: 0.82 }}>
<span className="bg-amber-500/12 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-300">
automation
</span>
<span className="text-[11px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
stall nudge
</span>
<MoveRight size={10} style={{ color: CARD_ICON_MUTED }} className="shrink-0" />
<MemberBadge
name={recipientName}
color={recipientColor}
teamName={teamName}
hideAvatar
onClick={onMemberNameClick}
/>
<span className="min-w-0 flex-1 truncate text-[11px]" style={{ color: CARD_TEXT_LIGHT }}>
Asked teammate to continue stalled task
{taskRef && taskLabel ? (
<>
{' '}
<button
type="button"
className="font-medium text-blue-300 hover:text-blue-200"
onClick={(event) => {
event.stopPropagation();
onTaskIdClick?.(taskRef.taskId);
}}
>
{taskLabel}
</button>
</>
) : null}
</span>
<span className="shrink-0 text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{timestamp}
</span>
</div>
);
};
const BootstrapSystemRow = ({ const BootstrapSystemRow = ({
teamName, teamName,
eventKind, eventKind,
@ -926,6 +989,20 @@ export const ActivityItem = memo(
); );
} }
if (isTaskStallRemediationMessage(message)) {
return (
<TaskStallRemediationRow
teamName={teamName}
recipientName={message.to ?? 'teammate'}
recipientColor={recipientColor}
taskRef={message.taskRefs?.[0]}
timestamp={timestamp}
onMemberNameClick={onMemberNameClick}
onTaskIdClick={onTaskIdClick}
/>
);
}
if (bootstrapDisplay) { if (bootstrapDisplay) {
return ( return (
<BootstrapSystemRow <BootstrapSystemRow

View file

@ -28,7 +28,7 @@ import {
extractTaskRefsFromText, extractTaskRefsFromText,
stripEncodedTaskReferenceMetadata, stripEncodedTaskReferenceMetadata,
} from '@renderer/utils/taskReferenceUtils'; } from '@renderer/utils/taskReferenceUtils';
import { getTaskKanbanColumn } from '@shared/utils/reviewState'; import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { AlertTriangle, ChevronDown, ChevronRight, Search } from 'lucide-react'; import { AlertTriangle, ChevronDown, ChevronRight, Search } from 'lucide-react';
@ -153,7 +153,7 @@ export const CreateTaskDialog = ({
// Only show non-internal, non-deleted tasks as candidates for blocking // Only show non-internal, non-deleted tasks as candidates for blocking
const availableTasks = tasks.filter( const availableTasks = tasks.filter(
(t) => t.status !== 'deleted' && getTaskKanbanColumn(t) !== 'approved' (t) => t.status !== 'deleted' && getTeamTaskWorkflowColumn(t) !== 'approved'
); );
const toggleBlockedBy = (taskId: string): void => { const toggleBlockedBy = (taskId: string): void => {

View file

@ -54,7 +54,11 @@ import {
} from '@renderer/utils/taskChangeRequest'; } from '@renderer/utils/taskChangeRequest';
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
import { isLeadMember } from '@shared/utils/leadDetection'; import { isLeadMember } from '@shared/utils/leadDetection';
import { getTaskKanbanColumn } from '@shared/utils/reviewState'; import {
getTeamTaskWorkflowColumn,
isTeamTaskFinishedForDependency,
isTeamTaskNeedsFixActionable,
} from '@shared/utils/teamTaskState';
import { import {
deriveTaskDisplayId, deriveTaskDisplayId,
formatTaskDisplayLabel, formatTaskDisplayLabel,
@ -598,12 +602,10 @@ export const TaskDetailDialog = ({
); );
} }
const kanbanColumn = const kanbanColumn = getTeamTaskWorkflowColumn({
kanbanTaskState?.column ?? ...currentTask,
getTaskKanbanColumn({ ...(kanbanTaskState?.column ? { kanbanColumn: kanbanTaskState.column } : {}),
reviewState: currentTask.reviewState, });
kanbanColumn: currentTask.kanbanColumn,
});
const status = currentTask.status; const status = currentTask.status;
const statusStyle = const statusStyle =
kanbanColumn && KANBAN_COLUMN_DISPLAY[kanbanColumn] kanbanColumn && KANBAN_COLUMN_DISPLAY[kanbanColumn]
@ -659,13 +661,13 @@ export const TaskDetailDialog = ({
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal"> <Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal">
{formatTaskDisplayLabel(currentTask)} {formatTaskDisplayLabel(currentTask)}
</Badge> </Badge>
{(currentTask.reviewState === 'approved' || currentTask.reviewState === 'review') && {(kanbanColumn === 'approved' || kanbanColumn === 'review') &&
currentTask.reviewer && currentTask.reviewer &&
currentTask.reviewer !== 'user' ? ( currentTask.reviewer !== 'user' ? (
(() => { (() => {
const reviewerColor = colorMap.get(currentTask.reviewer); const reviewerColor = colorMap.get(currentTask.reviewer);
const colors = const colors =
currentTask.reviewState === 'review' kanbanColumn === 'review'
? getTeamColorSet('blue') ? getTeamColorSet('blue')
: getTeamColorSet(reviewerColor ?? ''); : getTeamColorSet(reviewerColor ?? '');
const reviewerBadgeStyle = { const reviewerBadgeStyle = {
@ -677,7 +679,7 @@ export const TaskDetailDialog = ({
}; };
const lastReviewEvent = currentTask.historyEvents const lastReviewEvent = currentTask.historyEvents
?.filter((e) => ?.filter((e) =>
currentTask.reviewState === 'approved' kanbanColumn === 'approved'
? e.type === 'review_approved' ? e.type === 'review_approved'
: e.type === 'review_requested' || e.type === 'review_started' : e.type === 'review_requested' || e.type === 'review_started'
) )
@ -731,7 +733,7 @@ export const TaskDetailDialog = ({
{statusLabel} {statusLabel}
</span> </span>
)} )}
{currentTask.reviewState === 'needsFix' ? ( {isTeamTaskNeedsFixActionable(currentTask) ? (
<span <span
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`} className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
> >
@ -941,7 +943,9 @@ export const TaskDetailDialog = ({
</span> </span>
{blockedByIds.map((id) => { {blockedByIds.map((id) => {
const depTask = taskMap.get(id); const depTask = taskMap.get(id);
const isCompleted = depTask?.status === 'completed'; const isCompleted = depTask
? isTeamTaskFinishedForDependency(depTask)
: false;
const label = depTask const label = depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}` ? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(id)}`; : `#${deriveTaskDisplayId(id)}`;
@ -977,7 +981,9 @@ export const TaskDetailDialog = ({
</span> </span>
{blocksIds.map((id) => { {blocksIds.map((id) => {
const depTask = taskMap.get(id); const depTask = taskMap.get(id);
const isCompleted = depTask?.status === 'completed'; const isCompleted = depTask
? isTeamTaskFinishedForDependency(depTask)
: false;
const label = depTask const label = depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}` ? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(id)}`; : `#${deriveTaskDisplayId(id)}`;

View file

@ -9,6 +9,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
import { useResizableColumns } from '@renderer/hooks/useResizableColumns'; import { useResizableColumns } from '@renderer/hooks/useResizableColumns';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { isTeamTaskNeedsFixActionable } from '@shared/utils/teamTaskState';
import { import {
CheckCircle2, CheckCircle2,
ChevronDown, ChevronDown,
@ -161,7 +162,7 @@ function estimateGridSkeletonCardHeight(
if (task.subject.length > 54) height += 10; if (task.subject.length > 54) height += 10;
if (task.subject.length > 92) height += 8; if (task.subject.length > 92) height += 8;
if (task.needsClarification) height += 16; if (task.needsClarification) height += 16;
if (task.reviewState === 'needsFix') height += 14; if (isTeamTaskNeedsFixActionable(task)) height += 14;
if ((task.blockedBy?.length ?? 0) > 0) height += 18; if ((task.blockedBy?.length ?? 0) > 0) height += 18;
if ((task.blocks?.length ?? 0) > 0) height += 18; if ((task.blocks?.length ?? 0) > 0) height += 18;

View file

@ -13,6 +13,10 @@ import {
buildTaskChangeRequestOptions, buildTaskChangeRequestOptions,
canDisplayTaskChangesForOptions, canDisplayTaskChangesForOptions,
} from '@renderer/utils/taskChangeRequest'; } from '@renderer/utils/taskChangeRequest';
import {
isTeamTaskFinishedForDependency,
isTeamTaskNeedsFixActionable,
} from '@shared/utils/teamTaskState';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { import {
ArrowLeftFromLine, ArrowLeftFromLine,
@ -65,7 +69,7 @@ const DependencyBadge = ({
onScrollToTask, onScrollToTask,
}: DependencyBadgeProps): React.JSX.Element => { }: DependencyBadgeProps): React.JSX.Element => {
const depTask = taskMap.get(taskId); const depTask = taskMap.get(taskId);
const isCompleted = depTask?.status === 'completed'; const isCompleted = depTask ? isTeamTaskFinishedForDependency(depTask) : false;
const label = depTask const label = depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}` ? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(taskId)}`; : `#${deriveTaskDisplayId(taskId)}`;
@ -334,7 +338,7 @@ export const KanbanTaskCard = memo(
{task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'} {task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'}
</span> </span>
) : null} ) : null}
{task.reviewState === 'needsFix' ? ( {isTeamTaskNeedsFixActionable(task) ? (
<span <span
className={`mt-1 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`} className={`mt-1 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
> >

View file

@ -163,8 +163,15 @@ export const MemberCard = memo(function MemberCard({
selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : [] selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : []
); );
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]); const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
const presentationMember =
member.currentTaskId && !currentTask
? {
...member,
currentTaskId: null,
}
: member;
const launchPresentation = buildMemberLaunchPresentation({ const launchPresentation = buildMemberLaunchPresentation({
member, member: presentationMember,
spawnStatus, spawnStatus,
spawnLaunchState, spawnLaunchState,
spawnLivenessSource, spawnLivenessSource,

View file

@ -18,7 +18,9 @@ import {
getRuntimeMemorySourceLabel, getRuntimeMemorySourceLabel,
resolveMemberRuntimeSummary, resolveMemberRuntimeSummary,
} from '@renderer/utils/memberRuntimeSummary'; } from '@renderer/utils/memberRuntimeSummary';
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
import { isLeadMember } from '@shared/utils/leadDetection'; import { isLeadMember } from '@shared/utils/leadDetection';
import { isTeamTaskFinishedForDependency } from '@shared/utils/teamTaskState';
import { import {
BarChart3, BarChart3,
FileText, FileText,
@ -155,12 +157,22 @@ export const MemberDetailDialog = ({
}, [member, memberMessages, members, tasks, teamName]); }, [member, memberMessages, members, tasks, teamName]);
const inProgressTasks = useMemo( const inProgressTasks = useMemo(
() => memberTasks.filter((t) => t.status === 'in_progress').length, () => memberTasks.filter(isDisplayableCurrentTask).length,
[memberTasks] [memberTasks]
); );
const currentTaskCandidate = useMemo(
() =>
member?.currentTaskId
? (tasks.find((task) => task.id === member.currentTaskId) ?? null)
: null,
[member?.currentTaskId, tasks]
);
const displayableCurrentTask = isDisplayableCurrentTask(currentTaskCandidate)
? currentTaskCandidate
: null;
const completedTasks = useMemo( const completedTasks = useMemo(
() => memberTasks.filter((t) => t.status === 'completed').length, () => memberTasks.filter(isTeamTaskFinishedForDependency).length,
[memberTasks] [memberTasks]
); );
@ -255,7 +267,11 @@ export const MemberDetailDialog = ({
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<DialogHeader className="shrink-0"> <DialogHeader className="shrink-0">
<MemberDetailHeader <MemberDetailHeader
member={member} member={
member.currentTaskId && !displayableCurrentTask
? { ...member, currentTaskId: null }
: member
}
runtimeSummary={runtimeSummary} runtimeSummary={runtimeSummary}
isTeamAlive={isTeamAlive} isTeamAlive={isTeamAlive}
isTeamProvisioning={isTeamProvisioning} isTeamProvisioning={isTeamProvisioning}

View file

@ -24,6 +24,7 @@ import {
buildMemberLaunchPresentation, buildMemberLaunchPresentation,
displayMemberName, displayMemberName,
} from '@renderer/utils/memberHelpers'; } from '@renderer/utils/memberHelpers';
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
import { import {
buildMemberLaunchDiagnosticsPayload, buildMemberLaunchDiagnosticsPayload,
getMemberLaunchDiagnosticsErrorMessage, getMemberLaunchDiagnosticsErrorMessage,
@ -31,6 +32,7 @@ import {
hasMemberLaunchDiagnosticsError, hasMemberLaunchDiagnosticsError,
} from '@renderer/utils/memberLaunchDiagnostics'; } from '@renderer/utils/memberLaunchDiagnostics';
import { isLeadMember } from '@shared/utils/leadDetection'; import { isLeadMember } from '@shared/utils/leadDetection';
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
import { ExternalLink } from 'lucide-react'; import { ExternalLink } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
@ -126,8 +128,19 @@ export const MemberHoverCard = memo(function MemberHoverCard({
progress?.state === 'ready' && getLaunchJoinState(launchJoinMilestones).hasMembersStillJoining; progress?.state === 'ready' && getLaunchJoinState(launchJoinMilestones).hasMembersStillJoining;
const colors = getTeamColorSet(color ?? member.color ?? ''); const colors = getTeamColorSet(color ?? member.color ?? '');
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType); const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
const currentTaskCandidate: TeamTaskWithKanban | null = member.currentTaskId
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
: null;
const currentTask = isDisplayableCurrentTask(currentTaskCandidate) ? currentTaskCandidate : null;
const presentationMember =
member.currentTaskId && !currentTask
? {
...member,
currentTaskId: null,
}
: member;
const launchPresentation = buildMemberLaunchPresentation({ const launchPresentation = buildMemberLaunchPresentation({
member, member: presentationMember,
spawnStatus: spawnEntry?.status, spawnStatus: spawnEntry?.status,
spawnLaunchState: spawnEntry?.launchState, spawnLaunchState: spawnEntry?.launchState,
spawnLivenessSource: spawnEntry?.livenessSource, spawnLivenessSource: spawnEntry?.livenessSource,
@ -171,15 +184,12 @@ export const MemberHoverCard = memo(function MemberHoverCard({
const showCopyDiagnostics = const showCopyDiagnostics =
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) && hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload); hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
const currentTask: TeamTaskWithKanban | null = member.currentTaskId
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
: null;
const reviewTask: TeamTaskWithKanban | null = tasks const reviewTask: TeamTaskWithKanban | null = tasks
? (tasks.find( ? (tasks.find(
(task) => (task) =>
task.reviewer === member.name && task.reviewer === member.name &&
task.id !== member.currentTaskId && task.id !== currentTask?.id &&
(task.reviewState === 'review' || task.kanbanColumn === 'review') getTeamTaskWorkflowColumn(task) === 'review'
) ?? null) ) ?? null)
: null; : null;

View file

@ -2,7 +2,9 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary'; import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary';
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
import { isLeadMember } from '@shared/utils/leadDetection'; import { isLeadMember } from '@shared/utils/leadDetection';
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
import { MemberCard } from './MemberCard'; import { MemberCard } from './MemberCard';
@ -425,7 +427,7 @@ export const MemberList = memo(function MemberList({
const result = new Map<string, TeamTaskWithKanban>(); const result = new Map<string, TeamTaskWithKanban>();
if (!taskMap) return result; if (!taskMap) return result;
for (const task of taskMap.values()) { for (const task of taskMap.values()) {
if (task.reviewer && (task.reviewState === 'review' || task.kanbanColumn === 'review')) { if (task.reviewer && getTeamTaskWorkflowColumn(task) === 'review') {
result.set(task.reviewer, task); result.set(task.reviewer, task);
} }
} }
@ -455,11 +457,14 @@ export const MemberList = memo(function MemberList({
<div ref={containerRef} className="flex flex-col gap-1"> <div ref={containerRef} className="flex flex-col gap-1">
<div className={gridClass}> <div className={gridClass}>
{activeMembers.map((member) => { {activeMembers.map((member) => {
const currentTask = const currentTaskCandidate =
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null; member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
const currentTask = isDisplayableCurrentTask(currentTaskCandidate)
? currentTaskCandidate
: null;
const reviewCandidate = reviewTaskByMember.get(member.name) ?? null; const reviewCandidate = reviewTaskByMember.get(member.name) ?? null;
const reviewTask = const reviewTask =
reviewCandidate && reviewCandidate.id !== member.currentTaskId ? reviewCandidate : null; reviewCandidate && reviewCandidate.id !== currentTask?.id ? reviewCandidate : null;
const spawnEntry = memberSpawnStatuses?.get(member.name); const spawnEntry = memberSpawnStatuses?.get(member.name);
const runtimeEntry = memberRuntimeEntries?.get(member.name); const runtimeEntry = memberRuntimeEntries?.get(member.name);
return ( return (

View file

@ -7,7 +7,10 @@ import {
TASK_STATUS_LABELS, TASK_STATUS_LABELS,
TASK_STATUS_STYLES, TASK_STATUS_STYLES,
} from '@renderer/utils/memberHelpers'; } from '@renderer/utils/memberHelpers';
import { getTaskKanbanColumn } from '@shared/utils/reviewState'; import {
getTeamTaskWorkflowColumn,
isTeamTaskNeedsFixActionable,
} from '@shared/utils/teamTaskState';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import type { TeamTaskWithKanban } from '@shared/types'; import type { TeamTaskWithKanban } from '@shared/types';
@ -44,7 +47,7 @@ export const MemberTasksTab = ({ tasks, onTaskClick }: MemberTasksTabProps): Rea
<div className="max-h-[320px] overflow-y-auto"> <div className="max-h-[320px] overflow-y-auto">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{visibleTasks.map((task) => { {visibleTasks.map((task) => {
const col = getTaskKanbanColumn(task); const col = getTeamTaskWorkflowColumn(task);
const style = const style =
col && KANBAN_COLUMN_DISPLAY[col] col && KANBAN_COLUMN_DISPLAY[col]
? { bg: KANBAN_COLUMN_DISPLAY[col].bg, text: KANBAN_COLUMN_DISPLAY[col].text } ? { bg: KANBAN_COLUMN_DISPLAY[col].bg, text: KANBAN_COLUMN_DISPLAY[col].text }
@ -71,7 +74,7 @@ export const MemberTasksTab = ({ tasks, onTaskClick }: MemberTasksTabProps): Rea
> >
{label} {label}
</span> </span>
{task.reviewState === 'needsFix' ? ( {isTeamTaskNeedsFixActionable(task) ? (
<span <span
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`} className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
> >

View file

@ -23,6 +23,7 @@ import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics'; import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics';
import { isLeadMember } from '@shared/utils/leadDetection'; import { isLeadMember } from '@shared/utils/leadDetection';
import { isTaskStallRemediationMessage } from '@shared/utils/teamAutomationMessages';
import { import {
CheckCheck, CheckCheck,
ChevronsDownUp, ChevronsDownUp,
@ -587,6 +588,7 @@ export const MessagesPanel = memo(function MessagesPanel({
const activityTimelineMessages = useMemo(() => { const activityTimelineMessages = useMemo(() => {
return filterTeamMessages(effectiveMessages, { return filterTeamMessages(effectiveMessages, {
includeAutomationEvents: true,
includePassiveIdlePeerSummariesWhenNoiseHidden: true, includePassiveIdlePeerSummariesWhenNoiseHidden: true,
leadNames, leadNames,
timeWindow, timeWindow,
@ -600,6 +602,7 @@ export const MessagesPanel = memo(function MessagesPanel({
effectiveMessages.filter( effectiveMessages.filter(
(m) => (m) =>
m.messageKind !== 'task_comment_notification' && m.messageKind !== 'task_comment_notification' &&
!isTaskStallRemediationMessage(m) &&
!shouldExcludeInboxTextFromReplyCandidates(typeof m.text === 'string' ? m.text : '') !shouldExcludeInboxTextFromReplyCandidates(typeof m.text === 'string' ? m.text : '')
), ),
[effectiveMessages] [effectiveMessages]

View file

@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { computePendingCrossTeamReplies } from '@renderer/utils/crossTeamPendingReplies'; import { computePendingCrossTeamReplies } from '@renderer/utils/crossTeamPendingReplies';
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
import { ChevronRight } from 'lucide-react'; import { ChevronRight } from 'lucide-react';
import { ActiveTasksBlock } from '../activity/ActiveTasksBlock'; import { ActiveTasksBlock } from '../activity/ActiveTasksBlock';
@ -55,8 +56,7 @@ export const StatusBlock = ({
return members.some((m) => { return members.some((m) => {
if (!m.currentTaskId) return false; if (!m.currentTaskId) return false;
const task = tMap.get(m.currentTaskId); const task = tMap.get(m.currentTaskId);
if (task && (task.reviewState === 'approved' || task.status === 'completed')) return false; return isDisplayableCurrentTask(task);
return true;
}); });
}, [members, tasks]); }, [members, tasks]);

View file

@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
import { isTaskLogActivityChangeEvent } from '@renderer/utils/teamChangeEvents'; import { isTaskLogActivityChangeEvent } from '@renderer/utils/teamChangeEvents';
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
import { ExecutionSessionsSection } from './ExecutionSessionsSection'; import { ExecutionSessionsSection } from './ExecutionSessionsSection';
import { isBoardTaskActivityUiEnabled, isBoardTaskExactLogsUiEnabled } from './featureGates'; import { isBoardTaskActivityUiEnabled, isBoardTaskExactLogsUiEnabled } from './featureGates';
@ -66,7 +67,8 @@ export const TaskLogsPanel = ({
const countReloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const countReloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const countRequestSeqRef = useRef(0); const countRequestSeqRef = useRef(0);
const hasTaskLogStream = availableTabs.includes('stream'); const hasTaskLogStream = availableTabs.includes('stream');
const taskLogActivityTrackingEnabled = task.status === 'in_progress' && hasTaskLogStream; const taskIsActivelyWorked = isDisplayableCurrentTask(task);
const taskLogActivityTrackingEnabled = taskIsActivelyWorked && hasTaskLogStream;
const taskLogSummaryEnabled = hasOpenedContent && hasTaskLogStream; const taskLogSummaryEnabled = hasOpenedContent && hasTaskLogStream;
useEffect(() => { useEffect(() => {
@ -261,7 +263,7 @@ export const TaskLogsPanel = ({
teamName={teamName} teamName={teamName}
taskId={task.id} taskId={task.id}
taskStatus={task.status} taskStatus={task.status}
liveEnabled={isOpen && task.status === 'in_progress'} liveEnabled={isOpen && taskIsActivelyWorked}
/> />
</TabsContent> </TabsContent>
) : null} ) : null}

View file

@ -5,7 +5,10 @@ import {
REVIEW_STATE_DISPLAY, REVIEW_STATE_DISPLAY,
TASK_STATUS_LABELS, TASK_STATUS_LABELS,
} from '@renderer/utils/memberHelpers'; } from '@renderer/utils/memberHelpers';
import { getTaskKanbanColumn } from '@shared/utils/reviewState'; import {
getTeamTaskWorkflowColumn,
isTeamTaskNeedsFixActionable,
} from '@shared/utils/teamTaskState';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import type { TeamTaskWithKanban } from '@shared/types'; import type { TeamTaskWithKanban } from '@shared/types';
@ -17,7 +20,7 @@ interface TaskRowProps {
export const TaskRow = memo(function TaskRow({ task }: TaskRowProps): React.JSX.Element { export const TaskRow = memo(function TaskRow({ task }: TaskRowProps): React.JSX.Element {
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? []; const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? []; const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
const kanbanColumn = getTaskKanbanColumn(task); const kanbanColumn = getTeamTaskWorkflowColumn(task);
return ( return (
<tr className="border-t border-[var(--color-border)]"> <tr className="border-t border-[var(--color-border)]">
@ -35,7 +38,7 @@ export const TaskRow = memo(function TaskRow({ task }: TaskRowProps): React.JSX.
? KANBAN_COLUMN_DISPLAY[kanbanColumn].label ? KANBAN_COLUMN_DISPLAY[kanbanColumn].label
: (TASK_STATUS_LABELS[task.status] ?? task.status)} : (TASK_STATUS_LABELS[task.status] ?? task.status)}
</span> </span>
{task.reviewState === 'needsFix' ? ( {isTeamTaskNeedsFixActionable(task) ? (
<span <span
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`} className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
> >

View file

@ -13,6 +13,7 @@ import {
canDisplayTaskChangesForOptions, canDisplayTaskChangesForOptions,
} from '@renderer/utils/taskChangeRequest'; } from '@renderer/utils/taskChangeRequest';
import { isTaskLogActivityChangeEvent } from '@renderer/utils/teamChangeEvents'; import { isTaskLogActivityChangeEvent } from '@renderer/utils/teamChangeEvents';
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { isVersionOlder, normalizeVersion } from '@shared/utils/version'; import { isVersionOlder, normalizeVersion } from '@shared/utils/version';
import { create } from 'zustand'; import { create } from 'zustand';
@ -736,7 +737,7 @@ export function initializeNotificationListeners(): () => void {
} }
const candidateTasks = teamData.tasks.filter((task) => { const candidateTasks = teamData.tasks.filter((task) => {
if (task.status !== 'in_progress') { if (!isDisplayableCurrentTask(task)) {
return false; return false;
} }
return canDisplayTaskChangesForOptions(buildTaskChangeRequestOptions(task)); return canDisplayTaskChangesForOptions(buildTaskChangeRequestOptions(task));
@ -770,7 +771,7 @@ export function initializeNotificationListeners(): () => void {
} }
const currentTask = currentTeamData.tasks.find((task) => task.id === nextTask.id); const currentTask = currentTeamData.tasks.find((task) => task.id === nextTask.id);
if (currentTask?.status !== 'in_progress') { if (!isDisplayableCurrentTask(currentTask)) {
continue; continue;
} }

View file

@ -13,12 +13,15 @@ import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
import { isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout'; import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId'; import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
import {
getTeamTaskWorkflowColumn,
isTeamTaskFinalForCompletionNotification,
isTeamTaskNeedsFixActionable,
} from '@shared/utils/teamTaskState';
import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics'; import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics';
import { getWorktreeNavigationState } from '../utils/stateResetHelpers'; import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
@ -1387,11 +1390,12 @@ function detectStatusChangeNotifications(
if (!oldTask) continue; if (!oldTask) continue;
// Detect kanbanColumn change to 'approved' (status stays 'completed', column changes) // Detect kanbanColumn change to 'approved' (status stays 'completed', column changes)
const taskKanbanColumn = getTaskKanbanColumn(task); const taskKanbanColumn = getTeamTaskWorkflowColumn(task);
const oldTaskKanbanColumn = getTaskKanbanColumn(oldTask); const oldTaskKanbanColumn = getTeamTaskWorkflowColumn(oldTask);
const becameApproved = taskKanbanColumn === 'approved' && oldTaskKanbanColumn !== 'approved'; const becameApproved = taskKanbanColumn === 'approved' && oldTaskKanbanColumn !== 'approved';
const becameReview = taskKanbanColumn === 'review' && oldTaskKanbanColumn !== 'review'; const becameReview = taskKanbanColumn === 'review' && oldTaskKanbanColumn !== 'review';
const becameNeedsFix = task.reviewState === 'needsFix' && oldTask.reviewState !== 'needsFix'; const becameNeedsFix =
isTeamTaskNeedsFixActionable(task) && !isTeamTaskNeedsFixActionable(oldTask);
const statusChanged = oldTask.status !== task.status; const statusChanged = oldTask.status !== task.status;
if (!statusChanged && !becameApproved && !becameReview && !becameNeedsFix) continue; if (!statusChanged && !becameApproved && !becameReview && !becameNeedsFix) continue;
@ -1686,7 +1690,7 @@ function detectAllTasksCompletedNotification(
for (const [teamName, tasks] of teamTasks) { for (const [teamName, tasks] of teamTasks) {
if (tasks.length === 0) continue; if (tasks.length === 0) continue;
const allCompleted = tasks.every((t) => t.status === 'completed' || t.status === 'deleted'); const allCompleted = tasks.every(isTeamTaskFinalForCompletionNotification);
if (!allCompleted) { if (!allCompleted) {
// Reset so we can notify again if tasks become all-completed later // Reset so we can notify again if tasks become all-completed later
notifiedAllCompletedTeams.delete(teamName); notifiedAllCompletedTeams.delete(teamName);
@ -1697,8 +1701,7 @@ function detectAllTasksCompletedNotification(
// Check that at least one task was NOT completed before (real transition) // Check that at least one task was NOT completed before (real transition)
const oldTeamTasks = oldTasks.filter((t) => t.teamName === teamName); const oldTeamTasks = oldTasks.filter((t) => t.teamName === teamName);
const wasAlreadyAllCompleted = const wasAlreadyAllCompleted =
oldTeamTasks.length > 0 && oldTeamTasks.length > 0 && oldTeamTasks.every(isTeamTaskFinalForCompletionNotification);
oldTeamTasks.every((t) => t.status === 'completed' || t.status === 'deleted');
if (wasAlreadyAllCompleted) { if (wasAlreadyAllCompleted) {
notifiedAllCompletedTeams.add(teamName); notifiedAllCompletedTeams.add(teamName);
continue; continue;
@ -3045,13 +3048,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
); );
} }
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:${task.status}`); notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:${task.status}`);
if (task.reviewState === 'needsFix') { if (isTeamTaskNeedsFixActionable(task)) {
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:needsFix`); notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:needsFix`);
} }
if (getTaskKanbanColumn(task) === 'approved') { if (getTeamTaskWorkflowColumn(task) === 'approved') {
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:approved`); notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:approved`);
} }
if (getTaskKanbanColumn(task) === 'review') { if (getTeamTaskWorkflowColumn(task) === 'review') {
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:review`); notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:review`);
} }
// Seed comment keys to prevent false notifications // Seed comment keys to prevent false notifications
@ -3069,7 +3072,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
teamTasksMap.set(task.teamName, list); teamTasksMap.set(task.teamName, list);
} }
for (const [teamName, teamTasks] of teamTasksMap) { for (const [teamName, teamTasks] of teamTasksMap) {
if (teamTasks.every((t) => t.status === 'completed' || t.status === 'deleted')) { if (teamTasks.every(isTeamTaskFinalForCompletionNotification)) {
notifiedAllCompletedTeams.add(teamName); notifiedAllCompletedTeams.add(teamName);
} }
} }

View file

@ -332,7 +332,8 @@ function isOpenCodeRuntimeDeliveryAdvisoryMessage(message: string | undefined):
displayMessage.startsWith('OpenCode returned an empty assistant turn') || displayMessage.startsWith('OpenCode returned an empty assistant turn') ||
displayMessage.startsWith('OpenCode accepted the prompt') || displayMessage.startsWith('OpenCode accepted the prompt') ||
displayMessage.startsWith('OpenCode responded, but did not create') || displayMessage.startsWith('OpenCode responded, but did not create') ||
displayMessage.startsWith('OpenCode created a reply without') displayMessage.startsWith('OpenCode created a reply without') ||
displayMessage.startsWith('OpenCode used tools, but did not create')
); );
} }
@ -360,6 +361,9 @@ function formatRuntimeAdvisoryDisplayMessage(message: string | undefined): strin
) { ) {
return 'OpenCode created a reply without the required relayOfMessageId correlation.'; return 'OpenCode created a reply without the required relayOfMessageId correlation.';
} }
if (trimmed === 'non_visible_tool_without_task_progress') {
return 'OpenCode used tools, but did not create a visible reply or task progress proof.';
}
if ( if (
trimmed.startsWith( trimmed.startsWith(
'OpenCode bootstrap MCP did not complete required tools before assistant response:' 'OpenCode bootstrap MCP did not complete required tools before assistant response:'
@ -389,6 +393,8 @@ function formatRuntimeAdvisoryBaseLabel(
return 'Network error'; return 'Network error';
case 'provider_overloaded': case 'provider_overloaded':
return providerLabel ? `${providerLabel} overload` : 'Provider overload'; return providerLabel ? `${providerLabel} overload` : 'Provider overload';
case 'protocol_proof_missing':
return providerId === 'opencode' ? 'OpenCode proof missing' : 'Protocol proof missing';
case 'backend_error': case 'backend_error':
case 'unknown': case 'unknown':
if ( if (
@ -416,6 +422,8 @@ function formatRuntimeAdvisoryBaseLabel(
return 'Network retry'; return 'Network retry';
case 'provider_overloaded': case 'provider_overloaded':
return providerLabel ? `${providerLabel} overload retry` : 'Provider overload retry'; return providerLabel ? `${providerLabel} overload retry` : 'Provider overload retry';
case 'protocol_proof_missing':
return providerId === 'opencode' ? 'OpenCode proof missing' : 'Protocol proof missing';
case 'backend_error': case 'backend_error':
case 'unknown': case 'unknown':
return 'Provider retry'; return 'Provider retry';
@ -458,6 +466,13 @@ function formatRuntimeAdvisoryTitle(
'Provider is temporarily overloaded.', 'Provider is temporarily overloaded.',
advisory.message advisory.message
); );
case 'protocol_proof_missing':
return appendRuntimeAdvisoryRawMessage(
providerId === 'opencode'
? 'OpenCode delivery completed without required visible/progress proof.'
: 'Runtime delivery completed without required protocol proof.',
advisory.message
);
case 'backend_error': case 'backend_error':
case 'unknown': case 'unknown':
if ( if (
@ -509,6 +524,13 @@ function formatRuntimeAdvisoryTitle(
'Provider is temporarily overloaded. SDK is retrying automatically.', 'Provider is temporarily overloaded. SDK is retrying automatically.',
advisory.message advisory.message
); );
case 'protocol_proof_missing':
return appendRuntimeAdvisoryRawMessage(
providerId === 'opencode'
? 'OpenCode delivery is waiting for required visible/progress proof.'
: 'Runtime delivery is waiting for required protocol proof.',
advisory.message
);
case 'backend_error': case 'backend_error':
case 'unknown': case 'unknown':
return appendRuntimeAdvisoryRawMessage( return appendRuntimeAdvisoryRawMessage(
@ -565,6 +587,9 @@ export function getMemberRuntimeAdvisoryTone(
if (!advisory) { if (!advisory) {
return null; return null;
} }
if (advisory.reasonCode === 'protocol_proof_missing') {
return 'warning';
}
return advisory.kind === 'api_error' ? 'error' : 'warning'; return advisory.kind === 'api_error' ? 'error' : 'warning';
} }

View file

@ -46,6 +46,9 @@ function formatOpenCodeRuntimeDeliveryFailureReason(reason: string | null | unde
) { ) {
return 'OpenCode created a reply without the required relayOfMessageId correlation.'; return 'OpenCode created a reply without the required relayOfMessageId correlation.';
} }
if (normalized === 'non_visible_tool_without_task_progress') {
return 'OpenCode used tools, but did not create a visible reply or task progress proof.';
}
return ''; return '';
} }

View file

@ -1,3 +1,10 @@
import {
getTeamTaskWorkflowColumn,
isTeamTaskDeleted,
isTeamTaskFinishedForDependency,
isTeamTaskNeedsFixActionable,
} from '@shared/utils/teamTaskState';
import type { GlobalTask } from '@shared/types'; import type { GlobalTask } from '@shared/types';
export function normalizePath(p: string): string { export function normalizePath(p: string): string {
@ -15,10 +22,29 @@ export interface TaskStatusCounts {
completed: number; completed: number;
} }
function incrementStatus(counts: TaskStatusCounts, status: string): TaskStatusCounts { function incrementTaskStatus(
if (status === 'pending') return { ...counts, pending: counts.pending + 1 }; counts: TaskStatusCounts,
if (status === 'in_progress') return { ...counts, inProgress: counts.inProgress + 1 }; task: {
if (status === 'completed') return { ...counts, completed: counts.completed + 1 }; status: string;
reviewState?: string | null;
kanbanColumn?: string | null;
deletedAt?: string | null;
}
): TaskStatusCounts {
if (isTeamTaskDeleted(task)) return counts;
if (getTeamTaskWorkflowColumn(task) === 'approved') {
return { ...counts, completed: counts.completed + 1 };
}
if (isTeamTaskNeedsFixActionable(task)) {
return task.status === 'in_progress'
? { ...counts, inProgress: counts.inProgress + 1 }
: { ...counts, pending: counts.pending + 1 };
}
if (task.status === 'pending') return { ...counts, pending: counts.pending + 1 };
if (isTeamTaskFinishedForDependency(task)) {
return { ...counts, completed: counts.completed + 1 };
}
if (task.status === 'in_progress') return { ...counts, inProgress: counts.inProgress + 1 };
return counts; return counts;
} }
@ -29,7 +55,7 @@ export function buildTaskCountsByProject(tasks: GlobalTask[]): Map<string, TaskS
if (!task.projectPath) continue; if (!task.projectPath) continue;
const key = normalizePath(task.projectPath); const key = normalizePath(task.projectPath);
const counts = map.get(key) ?? { pending: 0, inProgress: 0, completed: 0 }; const counts = map.get(key) ?? { pending: 0, inProgress: 0, completed: 0 };
map.set(key, incrementStatus(counts, task.status)); map.set(key, incrementTaskStatus(counts, task));
} }
return map; return map;
} }
@ -40,22 +66,28 @@ export function buildTaskCountsByTeam(tasks: GlobalTask[]): Map<string, TaskStat
for (const task of tasks) { for (const task of tasks) {
const key = task.teamName; const key = task.teamName;
const counts = map.get(key) ?? { pending: 0, inProgress: 0, completed: 0 }; const counts = map.get(key) ?? { pending: 0, inProgress: 0, completed: 0 };
map.set(key, incrementStatus(counts, task.status)); map.set(key, incrementTaskStatus(counts, task));
} }
return map; return map;
} }
/** Build a map of owner name (lowercase) -> task status counts (ignores deleted). */ /** Build a map of owner name (lowercase) -> task status counts (ignores deleted). */
export function buildTaskCountsByOwner( export function buildTaskCountsByOwner(
tasks: { owner?: string | null; status: string }[] tasks: {
owner?: string | null;
status: string;
reviewState?: string | null;
kanbanColumn?: string | null;
deletedAt?: string | null;
}[]
): Map<string, TaskStatusCounts> { ): Map<string, TaskStatusCounts> {
const map = new Map<string, TaskStatusCounts>(); const map = new Map<string, TaskStatusCounts>();
for (const task of tasks) { for (const task of tasks) {
const owner = task.owner?.trim(); const owner = task.owner?.trim();
if (!owner || task.status === 'deleted') continue; if (!owner || isTeamTaskDeleted(task)) continue;
const key = owner.toLowerCase(); const key = owner.toLowerCase();
const counts = map.get(key) ?? { pending: 0, inProgress: 0, completed: 0 }; const counts = map.get(key) ?? { pending: 0, inProgress: 0, completed: 0 };
map.set(key, incrementStatus(counts, task.status)); map.set(key, incrementTaskStatus(counts, task));
} }
return map; return map;
} }

View file

@ -4,6 +4,7 @@ import {
} from '@renderer/utils/bootstrapPromptSanitizer'; } from '@renderer/utils/bootstrapPromptSanitizer';
import { shouldKeepIdleMessageInActivityWhenNoiseHidden } from '@renderer/utils/idleNotificationSemantics'; import { shouldKeepIdleMessageInActivityWhenNoiseHidden } from '@renderer/utils/idleNotificationSemantics';
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
import { isTaskStallRemediationMessage } from '@shared/utils/teamAutomationMessages';
import { isTeamInternalControlMessageEnvelope } from '@shared/utils/teamInternalControlMessages'; import { isTeamInternalControlMessageEnvelope } from '@shared/utils/teamInternalControlMessages';
import type { InboxMessage } from '@shared/types'; import type { InboxMessage } from '@shared/types';
@ -111,6 +112,7 @@ export function filterTeamMessages(
messages: InboxMessage[], messages: InboxMessage[],
options: { options: {
includePassiveIdlePeerSummariesWhenNoiseHidden?: boolean; includePassiveIdlePeerSummariesWhenNoiseHidden?: boolean;
includeAutomationEvents?: boolean;
leadNames?: Iterable<string>; leadNames?: Iterable<string>;
timeWindow?: { start: number; end: number } | null; timeWindow?: { start: number; end: number } | null;
filter: TeamMessagesFilter; filter: TeamMessagesFilter;
@ -119,6 +121,7 @@ export function filterTeamMessages(
): InboxMessage[] { ): InboxMessage[] {
const { const {
includePassiveIdlePeerSummariesWhenNoiseHidden = false, includePassiveIdlePeerSummariesWhenNoiseHidden = false,
includeAutomationEvents = false,
leadNames: rawLeadNames, leadNames: rawLeadNames,
timeWindow, timeWindow,
filter, filter,
@ -127,7 +130,10 @@ export function filterTeamMessages(
const leadNames = normalizeLeadNames(rawLeadNames); const leadNames = normalizeLeadNames(rawLeadNames);
let list = messages.filter( let list = messages.filter(
(m) => m.messageKind !== 'task_comment_notification' && !isTeamInternalControlMessageEnvelope(m) (m) =>
m.messageKind !== 'task_comment_notification' &&
(includeAutomationEvents || !isTaskStallRemediationMessage(m)) &&
!isTeamInternalControlMessageEnvelope(m)
); );
if (timeWindow) { if (timeWindow) {
list = list.filter((m) => { list = list.filter((m) => {

View 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));
}

View file

@ -434,6 +434,7 @@ export type InboxMessageKind =
| 'slash_command' | 'slash_command'
| 'slash_command_result' | 'slash_command_result'
| 'task_comment_notification' | 'task_comment_notification'
| 'task_stall_remediation'
| 'member_work_sync_nudge' | 'member_work_sync_nudge'
| 'agent_error'; | 'agent_error';
@ -828,6 +829,7 @@ export interface MemberRuntimeAdvisory {
| 'codex_native_timeout' | 'codex_native_timeout'
| 'network_error' | 'network_error'
| 'provider_overloaded' | 'provider_overloaded'
| 'protocol_proof_missing'
| 'backend_error' | 'backend_error'
| 'unknown'; | 'unknown';
message?: string; message?: string;

View file

@ -14,18 +14,8 @@ export function normalizeReviewState(value: unknown): TeamReviewState {
} }
export function getReviewStateFromTask(task: ReviewStateLike): TeamReviewState { export function getReviewStateFromTask(task: ReviewStateLike): TeamReviewState {
// Prefer derivation from historyEvents when available
if (Array.isArray(task.historyEvents) && task.historyEvents.length > 0) {
const derived = getDerivedReviewStateFromHistory({
historyEvents: task.historyEvents as TaskHistoryEvent[],
});
if (derived) {
return derived;
}
}
const fallbackStatus = typeof task.status === 'string' ? task.status : null; const fallbackStatus = typeof task.status === 'string' ? task.status : null;
const normalizeFallback = (value: unknown): TeamReviewState | null => { const normalizeForStatus = (value: unknown): TeamReviewState | null => {
const explicit = normalizeReviewState(value); const explicit = normalizeReviewState(value);
if (explicit === 'none') return null; if (explicit === 'none') return null;
@ -36,16 +26,28 @@ export function getReviewStateFromTask(task: ReviewStateLike): TeamReviewState {
return explicit === 'needsFix' ? 'needsFix' : 'none'; return explicit === 'needsFix' ? 'needsFix' : 'none';
} }
if (fallbackStatus === 'completed') { if (fallbackStatus === 'completed') {
return explicit === 'review' || explicit === 'approved' ? explicit : 'none'; return explicit === 'review' || explicit === 'approved' || explicit === 'needsFix'
? explicit
: 'none';
} }
return explicit; return explicit;
}; };
const explicit = normalizeFallback(task.reviewState); // Prefer derivation from historyEvents when available
if (Array.isArray(task.historyEvents) && task.historyEvents.length > 0) {
const derived = getDerivedReviewStateFromHistory({
historyEvents: task.historyEvents as TaskHistoryEvent[],
});
if (derived !== null) {
return normalizeForStatus(derived) ?? 'none';
}
}
const explicit = normalizeForStatus(task.reviewState);
if (explicit) return explicit; if (explicit) return explicit;
if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') { if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') {
return normalizeFallback(task.kanbanColumn) ?? 'none'; return normalizeForStatus(task.kanbanColumn) ?? 'none';
} }
return 'none'; return 'none';

View file

@ -1,4 +1,5 @@
import { getReviewStateFromTask } from './reviewState'; import { getReviewStateFromTask } from './reviewState';
import { getTeamTaskWorkflowColumn } from './teamTaskState';
import type { TeamReviewState } from '@shared/types'; import type { TeamReviewState } from '@shared/types';
@ -9,6 +10,7 @@ interface TaskChangeStateLike {
reviewState?: TeamReviewState | null; reviewState?: TeamReviewState | null;
historyEvents?: unknown[]; historyEvents?: unknown[];
kanbanColumn?: 'review' | 'approved' | null; kanbanColumn?: 'review' | 'approved' | null;
deletedAt?: string | null;
} }
function getEffectiveReviewState(task: TaskChangeStateLike): TeamReviewState { function getEffectiveReviewState(task: TaskChangeStateLike): TeamReviewState {
@ -17,8 +19,15 @@ function getEffectiveReviewState(task: TaskChangeStateLike): TeamReviewState {
export function getTaskChangeStateBucket(task: TaskChangeStateLike): TaskChangeStateBucket { export function getTaskChangeStateBucket(task: TaskChangeStateLike): TaskChangeStateBucket {
const reviewState = getEffectiveReviewState(task); const reviewState = getEffectiveReviewState(task);
if (reviewState === 'approved') return 'approved'; const workflowColumn = getTeamTaskWorkflowColumn({
if (reviewState === 'review') return 'review'; status: task.status ?? '',
reviewState,
kanbanColumn: task.kanbanColumn,
deletedAt: task.deletedAt,
});
if (workflowColumn === 'approved') return 'approved';
if (workflowColumn === 'review') return 'review';
if (reviewState === 'needsFix') return 'active';
return task.status === 'completed' ? 'completed' : 'active'; return task.status === 'completed' ? 'completed' : 'active';
} }

View 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:')
);
}

View 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;
}

View file

@ -43,6 +43,177 @@ describe('buildActionableWorkAgenda', () => {
]); ]);
}); });
it('does not keep stale terminal task state in the work agenda', () => {
const agenda = buildActionableWorkAgenda({
teamName: 'team-a',
memberName: 'jack',
generatedAt: '2026-05-06T19:06:07.257Z',
members: [{ name: 'jack' }],
tasks: [
{
id: 'task-completed',
displayId: '#6d4db591',
subject: 'Completed after stale work-sync status',
status: 'completed',
owner: 'jack',
},
{
id: 'task-deleted',
subject: 'Deleted after stale work-sync status',
status: 'in_progress',
owner: 'jack',
deletedAt: '2026-05-06T19:06:07.257Z',
},
{
id: 'task-review-approved',
subject: 'Approved review after stale work-sync status',
status: 'in_progress',
owner: 'jack',
reviewState: 'approved',
},
{
id: 'task-kanban-approved',
subject: 'Approved kanban after stale work-sync status',
status: 'in_progress',
owner: 'jack',
kanbanColumn: 'approved',
},
{
id: 'task-stale-needsfix-approved',
subject: 'Approved task after stale needsFix status',
status: 'in_progress',
owner: 'jack',
reviewState: 'needsFix',
kanbanColumn: 'approved',
},
],
hash,
});
expect(agenda.items).toEqual([]);
});
it('projects reopened in-progress work after a previous completion', () => {
const agenda = buildActionableWorkAgenda({
teamName: 'team-a',
memberName: 'jack',
generatedAt: '2026-05-06T18:56:19.173Z',
members: [{ name: 'jack' }],
tasks: [
{
id: 'task-reopened',
displayId: '#6d4db591',
subject: 'Reopened work',
status: 'in_progress',
owner: 'jack',
historyEvents: [
{
id: 'evt-completed',
type: 'status_changed',
timestamp: '2026-05-06T18:50:05.662Z',
from: 'in_progress',
to: 'completed',
},
{
id: 'evt-reopened',
type: 'status_changed',
timestamp: '2026-05-06T18:56:19.173Z',
from: 'completed',
to: 'in_progress',
},
],
},
],
hash,
});
expect(agenda.items.map((item) => [item.taskId, item.reason])).toEqual([
['task-reopened', 'owned_in_progress_task'],
]);
});
it('does not treat approved dependencies as waiting blockers', () => {
const agenda = buildActionableWorkAgenda({
teamName: 'team-a',
memberName: 'jack',
generatedAt: '2026-05-06T19:06:07.257Z',
members: [{ name: 'jack' }],
tasks: [
{
id: 'task-approved',
subject: 'Approved dependency',
status: 'in_progress',
owner: 'alice',
kanbanColumn: 'approved',
},
{
id: 'task-dependent',
subject: 'Depends on approved task',
status: 'in_progress',
owner: 'jack',
blockedBy: ['task-approved'],
},
],
hash,
});
expect(agenda.items.map((item) => [item.taskId, item.reason])).toEqual([
['task-dependent', 'owned_in_progress_task'],
]);
});
it('keeps dependencies blocked while completed work is still in review', () => {
const agenda = buildActionableWorkAgenda({
teamName: 'team-a',
memberName: 'jack',
generatedAt: '2026-05-06T19:06:07.257Z',
members: [{ name: 'jack' }, { name: 'alice' }],
tasks: [
{
id: 'task-review',
subject: 'Dependency waiting for review',
status: 'completed',
owner: 'alice',
reviewState: 'review',
kanbanColumn: 'review',
},
{
id: 'task-dependent',
subject: 'Depends on reviewed task',
status: 'in_progress',
owner: 'jack',
blockedBy: ['task-review'],
},
],
hash,
});
expect(agenda.items).toEqual([]);
});
it('does not let stale kanban approved hide a reopened pending task', () => {
const agenda = buildActionableWorkAgenda({
teamName: 'team-a',
memberName: 'jack',
generatedAt: '2026-05-06T19:06:07.257Z',
members: [{ name: 'jack' }],
tasks: [
{
id: 'task-reopened-pending',
subject: 'Reopened pending work',
status: 'pending',
owner: 'jack',
kanbanColumn: 'approved',
},
],
hash,
});
expect(agenda.items.map((item) => [item.taskId, item.reason])).toEqual([
['task-reopened-pending', 'owned_pending_task'],
]);
});
it('assigns active review work to the current-cycle reviewer only', () => { it('assigns active review work to the current-cycle reviewer only', () => {
const agenda = buildActionableWorkAgenda({ const agenda = buildActionableWorkAgenda({
teamName: 'team-a', teamName: 'team-a',
@ -78,6 +249,98 @@ describe('buildActionableWorkAgenda', () => {
}); });
}); });
it('keeps completed tasks actionable for the current reviewer while workflow is review', () => {
const agenda = buildActionableWorkAgenda({
teamName: 'team-a',
memberName: 'alice',
generatedAt: '2026-04-29T00:00:00.000Z',
members: [{ name: 'alice' }, { name: 'bob' }],
tasks: [
{
id: 'task-review',
subject: 'Review completed work',
status: 'completed',
owner: 'bob',
reviewState: 'review',
historyEvents: [
{
id: 'evt-review',
type: 'review_requested',
timestamp: '2026-04-29T00:00:00.000Z',
reviewer: 'alice',
},
],
},
],
hash,
});
expect(agenda.items).toHaveLength(1);
expect(agenda.items[0]).toMatchObject({
taskId: 'task-review',
kind: 'review',
assignee: 'alice',
});
});
it('does not assign owner work while stale in-progress task is in review workflow', () => {
const ownerAgenda = buildActionableWorkAgenda({
teamName: 'team-a',
memberName: 'bob',
generatedAt: '2026-04-29T00:00:00.000Z',
members: [{ name: 'alice' }, { name: 'bob' }],
tasks: [
{
id: 'task-review',
subject: 'Review in progress status',
status: 'in_progress',
owner: 'bob',
reviewState: 'none',
kanbanColumn: 'review',
historyEvents: [
{
id: 'evt-review',
type: 'review_requested',
timestamp: '2026-04-29T00:00:00.000Z',
reviewer: 'alice',
},
],
},
],
hash,
});
const reviewerAgenda = buildActionableWorkAgenda({
teamName: 'team-a',
memberName: 'alice',
generatedAt: '2026-04-29T00:00:00.000Z',
members: [{ name: 'alice' }, { name: 'bob' }],
tasks: [
{
id: 'task-review',
subject: 'Review in progress status',
status: 'in_progress',
owner: 'bob',
reviewState: 'none',
kanbanColumn: 'review',
historyEvents: [
{
id: 'evt-review',
type: 'review_requested',
timestamp: '2026-04-29T00:00:00.000Z',
reviewer: 'alice',
},
],
},
],
hash,
});
expect(ownerAgenda.items).toEqual([]);
expect(reviewerAgenda.items.map((item) => [item.taskId, item.kind, item.reason])).toEqual([
['task-review', 'review', 'current_cycle_review_assigned'],
]);
});
it('does not resurrect a stale reviewer after review was approved', () => { it('does not resurrect a stale reviewer after review was approved', () => {
const agenda = buildActionableWorkAgenda({ const agenda = buildActionableWorkAgenda({
teamName: 'team-a', teamName: 'team-a',
@ -257,12 +520,20 @@ describe('buildActionableWorkAgenda', () => {
owner: 'bob', owner: 'bob',
reviewState: 'needsFix', reviewState: 'needsFix',
}, },
{
id: 'task-2',
subject: 'Fix completed review',
status: 'completed',
owner: 'bob',
reviewState: 'needsFix',
},
], ],
hash, hash,
}); });
expect(agenda.items.map((item) => [item.taskId, item.kind, item.reason])).toEqual([ expect(agenda.items.map((item) => [item.taskId, item.kind, item.reason])).toEqual([
['task-1', 'work', 'review_changes_requested'], ['task-1', 'work', 'review_changes_requested'],
['task-2', 'work', 'review_changes_requested'],
]); ]);
}); });

View file

@ -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');
});
});

View file

@ -129,4 +129,90 @@ describe('MemberWorkSyncTaskImpactResolver', () => {
diagnostics: [], diagnostics: [],
}); });
}); });
it('does not target owners of already approved dependent tasks', async () => {
const tasks: TeamTask[] = [
{
id: 'task-a',
subject: 'Changed dependency',
status: 'completed',
owner: 'alice',
},
{
id: 'task-approved-dependent',
subject: 'Already approved dependent',
status: 'in_progress',
owner: 'tom',
blockedBy: ['task-a'],
},
];
const resolver = new MemberWorkSyncTaskImpactResolver({
taskReader: { getTasks: vi.fn(async () => tasks) },
kanbanManager: {
getState: vi.fn(async () => ({
tasks: {
'task-approved-dependent': {
column: 'approved',
movedAt: '2026-05-06T19:06:07.257Z',
},
},
})),
},
activeMemberSource: {
loadActiveMemberNames: vi.fn(async () => ['alice', 'team-lead', 'tom']),
},
} as never);
await expect(resolver.resolve({ teamName: 'team-a', taskId: 'task-a' })).resolves.toEqual({
memberNames: ['alice'],
fallbackTeamWide: false,
diagnostics: [],
});
});
it('does not treat stale review state as reviewer-missing when kanban says approved', async () => {
const tasks: TeamTask[] = [
{
id: 'task-approved',
subject: 'Approved after review',
status: 'in_progress',
owner: 'alice',
reviewState: 'review',
historyEvents: [
{
id: 'evt-review',
type: 'review_requested',
timestamp: '2026-05-06T19:00:00.000Z',
from: 'none',
to: 'review',
reviewer: 'bob',
},
],
},
];
const resolver = new MemberWorkSyncTaskImpactResolver({
taskReader: { getTasks: vi.fn(async () => tasks) },
kanbanManager: {
getState: vi.fn(async () => ({
tasks: {
'task-approved': {
column: 'approved',
movedAt: '2026-05-06T19:06:07.257Z',
},
},
})),
},
activeMemberSource: {
loadActiveMemberNames: vi.fn(async () => ['alice', 'bob', 'team-lead']),
},
} as never);
await expect(
resolver.resolve({ teamName: 'team-a', taskId: 'task-approved' })
).resolves.toEqual({
memberNames: ['alice'],
fallbackTeamWide: false,
diagnostics: [],
});
});
}); });

View file

@ -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([]);
});
});

View file

@ -1,117 +1,47 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { selectOpenCodeRuntimeDeliveryReason } from '../../../../src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics'; import {
isActionRequiredOpenCodeRuntimeDeliveryReason,
import type { OpenCodePromptDeliveryLedgerRecord } from '../../../../src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger'; selectOpenCodeRuntimeDeliveryReason,
} from '../../../../src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics';
function record(
input: Partial<OpenCodePromptDeliveryLedgerRecord>
): OpenCodePromptDeliveryLedgerRecord {
return {
id: 'opencode-prompt:test',
teamName: 'forge-labs',
memberName: 'bob',
laneId: 'secondary:opencode:bob',
runId: 'run-1',
runtimeSessionId: 'ses-1',
inboxMessageId: 'msg-1',
inboxTimestamp: '2026-05-06T18:31:36.478Z',
source: 'watcher',
messageKind: null,
replyRecipient: 'team-lead',
actionMode: null,
taskRefs: [],
payloadHash: 'sha256:test',
status: 'failed_terminal',
responseState: 'not_observed',
attempts: 3,
maxAttempts: 3,
acceptanceUnknown: false,
nextAttemptAt: null,
lastAttemptAt: null,
lastObservedAt: null,
acceptedAt: null,
respondedAt: null,
failedAt: '2026-05-06T18:33:42.896Z',
inboxReadCommittedAt: null,
inboxReadCommitError: null,
prePromptCursor: null,
postPromptCursor: null,
deliveredUserMessageId: null,
observedAssistantMessageId: null,
observedAssistantPreview: null,
observedToolCallNames: [],
observedVisibleMessageId: null,
visibleReplyMessageId: null,
visibleReplyInbox: null,
visibleReplyCorrelation: null,
lastReason: null,
diagnostics: [],
createdAt: '2026-05-06T18:31:36.636Z',
updatedAt: '2026-05-06T18:33:42.896Z',
...input,
};
}
describe('OpenCodeRuntimeDeliveryDiagnostics', () => { describe('OpenCodeRuntimeDeliveryDiagnostics', () => {
it('skips internal bootstrap MCP diagnostics when a provider error is available', () => { it('treats OpenRouter key limit errors as action-required delivery failures', () => {
const reason = selectOpenCodeRuntimeDeliveryReason( const reason =
record({ 'Key limit exceeded (total limit). Manage it using https://openrouter.ai/settings/keys';
responseState: 'empty_assistant_turn',
lastReason: 'empty_assistant_turn',
diagnostics: [
'OpenCode app MCP was reattached before message delivery.',
'OpenCode bootstrap MCP did not complete required tools before assistant response: runtime_bootstrap_checkin, member_briefing',
'Latest assistant message msg_1 failed with APIError - Insufficient credits. Add more credits.',
'empty_assistant_turn',
],
})
);
expect(reason).toBe('Insufficient credits. Add more credits.'); expect(isActionRequiredOpenCodeRuntimeDeliveryReason(reason)).toBe(true);
}); });
it('falls back to empty assistant turn when diagnostics are only internal noise', () => { it('does not treat protocol proof repair reasons as action-required provider failures', () => {
const reason = selectOpenCodeRuntimeDeliveryReason( expect(isActionRequiredOpenCodeRuntimeDeliveryReason('visible_reply_still_required')).toBe(
record({ false
responseState: 'empty_assistant_turn',
lastReason: 'empty_assistant_turn',
diagnostics: [
'OpenCode bridge command timed out',
'OpenCode bootstrap MCP did not complete required tools before assistant response: runtime_bootstrap_checkin, member_briefing',
'empty_assistant_turn',
],
})
); );
expect(reason).toBe('OpenCode returned an empty assistant turn.');
}); });
it('maps missing visible reply proof to a readable protocol error', () => { it('selects a concrete OpenCode runtime delivery diagnostic before generic fallback text', () => {
const reason = selectOpenCodeRuntimeDeliveryReason( const record = {
record({ diagnostics: [
responseState: 'responded_non_visible_tool', 'Latest assistant message for opencode session abc failed with APIError - Key limit exceeded (total limit). Manage it using https://openrouter.ai/settings/keys',
lastReason: 'visible_reply_still_required', ],
diagnostics: [ lastReason: 'OpenCode runtime delivery failed',
'OpenCode bootstrap MCP did not complete required tools before assistant response: runtime_bootstrap_checkin, member_briefing', responseState: 'session_error',
'visible_reply_still_required', status: 'accepted',
], } as Parameters<typeof selectOpenCodeRuntimeDeliveryReason>[0];
})
);
expect(reason).toBe('OpenCode responded, but did not create a visible message_send reply.'); expect(selectOpenCodeRuntimeDeliveryReason(record)).toContain('Key limit exceeded');
}); });
it('never exposes only internal generic bootstrap diagnostics as the user-facing reason', () => { it('formats non-visible tool progress failures without exposing the internal reason code', () => {
const reason = selectOpenCodeRuntimeDeliveryReason( const record = {
record({ diagnostics: ['non_visible_tool_without_task_progress'],
diagnostics: [ lastReason: 'non_visible_tool_without_task_progress',
'OpenCode app MCP was reattached before message delivery.', responseState: 'responded_non_visible_tool',
'OpenCode bootstrap MCP did not complete required tools before assistant response: runtime_bootstrap_checkin, member_briefing', status: 'failed_terminal',
], } as Parameters<typeof selectOpenCodeRuntimeDeliveryReason>[0];
})
);
expect(reason).toBe('OpenCode runtime delivery did not complete.'); expect(selectOpenCodeRuntimeDeliveryReason(record)).toBe(
'OpenCode used tools, but did not create a visible reply or task progress proof.'
);
}); });
}); });

View file

@ -1908,6 +1908,187 @@ describe('TeamDataService', () => {
}); });
}); });
it('preserves kanban approved overlay even when task status is still in_progress', async () => {
const harness = createGetTeamDataHarness({
config: {
name: 'My team',
members: [{ name: 'jack', role: 'developer' }],
},
getTasks: async () => [
{
id: 'task-approved',
subject: 'Approved but stale status',
status: 'in_progress',
owner: 'jack',
reviewState: 'none',
},
],
getState: async () => ({
teamName: 'my-team',
reviewers: [],
tasks: {
'task-approved': {
column: 'approved',
movedAt: '2026-05-06T19:06:07.257Z',
},
},
}),
});
const data = await harness.service.getTeamData('my-team');
expect(data.tasks[0]).toMatchObject({
id: 'task-approved',
status: 'in_progress',
reviewState: 'approved',
kanbanColumn: 'approved',
});
expect(harness.resolveMembersSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Array),
expect.any(Array),
expect.arrayContaining([
expect.objectContaining({
id: 'task-approved',
kanbanColumn: 'approved',
}),
]),
expect.any(Object)
);
});
it('lets current kanban approved overlay win over stale review history', async () => {
const harness = createGetTeamDataHarness({
config: {
name: 'My team',
members: [{ name: 'jack', role: 'developer' }],
},
getTasks: async () => [
{
id: 'task-approved',
subject: 'Approved after review',
status: 'in_progress',
owner: 'jack',
reviewState: 'none',
historyEvents: [
{
id: 'review-started',
type: 'review_started',
timestamp: '2026-05-06T19:00:00.000Z',
to: 'review',
},
],
},
],
getState: async () => ({
teamName: 'my-team',
reviewers: [],
tasks: {
'task-approved': {
column: 'approved',
movedAt: '2026-05-06T19:06:07.257Z',
},
},
}),
});
const data = await harness.service.getTeamData('my-team');
expect(data.tasks[0]).toMatchObject({
id: 'task-approved',
status: 'in_progress',
reviewState: 'approved',
kanbanColumn: 'approved',
reviewer: null,
});
});
it('lets current kanban review overlay win over stale approved review state', async () => {
const harness = createGetTeamDataHarness({
config: {
name: 'My team',
members: [{ name: 'jack', role: 'developer' }],
},
getTasks: async () => [
{
id: 'task-review',
subject: 'Moved back to review',
status: 'completed',
owner: 'jack',
reviewState: 'approved',
},
],
getState: async () => ({
teamName: 'my-team',
reviewers: [],
tasks: {
'task-review': {
column: 'review',
reviewer: 'carol',
movedAt: '2026-05-06T19:06:07.257Z',
},
},
}),
});
const data = await harness.service.getTeamData('my-team');
expect(data.tasks[0]).toMatchObject({
id: 'task-review',
status: 'completed',
reviewState: 'review',
kanbanColumn: 'review',
reviewer: 'carol',
});
});
it('does not preserve stale kanban approved overlay for reopened pending tasks', async () => {
const harness = createGetTeamDataHarness({
config: {
name: 'My team',
members: [{ name: 'jack', role: 'developer' }],
},
getTasks: async () => [
{
id: 'task-reopened',
subject: 'Reopened pending task',
status: 'pending',
owner: 'jack',
reviewState: 'none',
historyEvents: [
{
id: 'review-approved',
type: 'review_approved',
timestamp: '2026-05-06T19:00:00.000Z',
from: 'review',
to: 'approved',
actor: 'carol',
},
],
},
],
getState: async () => ({
teamName: 'my-team',
reviewers: [],
tasks: {
'task-reopened': {
column: 'approved',
movedAt: '2026-05-06T19:06:07.257Z',
},
},
}),
});
const data = await harness.service.getTeamData('my-team');
expect(data.tasks[0]).toMatchObject({
id: 'task-reopened',
status: 'pending',
reviewState: 'none',
});
expect(data.tasks[0]?.kanbanColumn).toBeUndefined();
});
it('applies kanban overlay review state in global task projections', async () => { it('applies kanban overlay review state in global task projections', async () => {
const service = new TeamDataService( const service = new TeamDataService(
{ {
@ -1967,6 +2148,64 @@ describe('TeamDataService', () => {
}); });
}); });
it('lets kanban approved overlay win over stale review history in global task projections', async () => {
const service = new TeamDataService(
{
listTeams: vi.fn(async () => [
{
teamName: 'my-team',
displayName: 'My team',
projectPath: '/repo',
},
]),
} as never,
{
getAllTasks: vi.fn(async () => [
{
id: 'task-global-approved',
teamName: 'my-team',
subject: 'Global approved task',
status: 'completed',
owner: 'bob',
reviewState: 'none',
historyEvents: [
{
id: 'evt-review',
type: 'review_started',
to: 'review',
timestamp: '2026-03-01T09:00:00.000Z',
},
],
},
]),
} as never,
{} as never,
{} as never,
{} as never,
{
getState: vi.fn(async () => ({
teamName: 'my-team',
reviewers: [],
tasks: {
'task-global-approved': {
column: 'approved',
reviewer: 'carol',
movedAt: '2026-03-01T10:00:00.000Z',
},
},
})),
} as never
);
const tasks = await service.getAllTasks();
expect(tasks[0]).toMatchObject({
id: 'task-global-approved',
reviewState: 'approved',
kanbanColumn: 'approved',
});
});
it('propagates leadSessionId for kanban-driven review transitions', async () => { it('propagates leadSessionId for kanban-driven review transitions', async () => {
const requestReviewMock = vi.fn(); const requestReviewMock = vi.fn();
const approveReviewMock = vi.fn(); const approveReviewMock = vi.fn();

View file

@ -41,6 +41,86 @@ describe('TeamMemberResolver', () => {
expect(lead?.agentType).toBe('team-lead'); expect(lead?.agentType).toBe('team-lead');
}); });
it('does not expose completed, deleted, or approved tasks as current work', () => {
const resolver = new TeamMemberResolver();
const config: TeamConfig = {
name: 'Team',
members: [
{ name: 'team-lead', agentType: 'team-lead', role: 'lead' },
{ name: 'alice', agentType: 'general-purpose' },
{ name: 'bob', agentType: 'general-purpose' },
{ name: 'carol', agentType: 'general-purpose' },
{ name: 'dave', agentType: 'general-purpose' },
],
};
const tasks: TeamTaskWithKanban[] = [
{
id: 'task-completed',
subject: 'Done',
status: 'completed',
owner: 'alice',
},
{
id: 'task-deleted',
subject: 'Deleted',
status: 'deleted',
owner: 'bob',
deletedAt: '2026-05-06T00:00:00.000Z',
},
{
id: 'task-approved-review',
subject: 'Approved review',
status: 'in_progress',
owner: 'carol',
reviewState: 'approved',
},
{
id: 'task-approved-kanban',
subject: 'Approved kanban',
status: 'in_progress',
owner: 'dave',
kanbanColumn: 'approved',
},
{
id: 'task-review-kanban',
subject: 'Review kanban',
status: 'in_progress',
owner: 'dave',
kanbanColumn: 'review',
},
];
const members = resolver.resolveMembers(config, [], [], tasks);
expect(members.find((member) => member.name === 'alice')?.currentTaskId).toBeNull();
expect(members.find((member) => member.name === 'bob')?.currentTaskId).toBeNull();
expect(members.find((member) => member.name === 'carol')?.currentTaskId).toBeNull();
expect(members.find((member) => member.name === 'dave')?.currentTaskId).toBeNull();
});
it('keeps real in-progress task as current work', () => {
const resolver = new TeamMemberResolver();
const config: TeamConfig = {
name: 'Team',
members: [
{ name: 'team-lead', agentType: 'team-lead', role: 'lead' },
{ name: 'alice', agentType: 'general-purpose' },
],
};
const tasks: TeamTaskWithKanban[] = [
{
id: 'task-active',
subject: 'Active',
status: 'in_progress',
owner: 'alice',
},
];
const members = resolver.resolveMembers(config, [], [], tasks);
expect(members.find((member) => member.name === 'alice')?.currentTaskId).toBe('task-active');
});
it('filters out "user" pseudo-member even when present in config, meta, or inboxNames', () => { it('filters out "user" pseudo-member even when present in config, meta, or inboxNames', () => {
const resolver = new TeamMemberResolver(); const resolver = new TeamMemberResolver();
const config: TeamConfig = { const config: TeamConfig = {

View file

@ -163,6 +163,10 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
'All credentials for model claude-opus-4-6 are cooling down via provider claude.', 'All credentials for model claude-opus-4-6 are cooling down via provider claude.',
], ],
['auth_error', 'Authentication failed due to invalid API key.'], ['auth_error', 'Authentication failed due to invalid API key.'],
[
'quota_exhausted',
'Key limit exceeded (total limit). Manage it using https://openrouter.ai/settings/keys',
],
['codex_native_timeout', 'Codex native exec timed out after 120000ms.'], ['codex_native_timeout', 'Codex native exec timed out after 120000ms.'],
['network_error', 'Fetch failed because the network connection timed out.'], ['network_error', 'Fetch failed because the network connection timed out.'],
['provider_overloaded', 'Service unavailable: provider temporarily unavailable (503).'], ['provider_overloaded', 'Service unavailable: provider temporarily unavailable (503).'],
@ -357,6 +361,99 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
expect(advisory?.message).not.toContain('Latest assistant message'); expect(advisory?.message).not.toContain('Latest assistant message');
}); });
it('classifies terminal OpenCode protocol proof failures as warnings, not provider errors', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-'));
setClaudeBasePathOverride(tmpDir);
const teamName = 'relay-works';
const laneId = 'secondary:opencode:jack';
const nowIso = new Date().toISOString();
const laneDir = path.join(
tmpDir,
'teams',
teamName,
'.opencode-runtime',
'lanes',
encodeURIComponent(laneId)
);
await fs.mkdir(laneDir, { recursive: true });
await fs.writeFile(
path.join(tmpDir, 'teams', teamName, '.opencode-runtime', 'lanes.json'),
JSON.stringify({
version: 1,
updatedAt: nowIso,
lanes: {
[laneId]: { laneId, state: 'active', updatedAt: nowIso },
},
}),
'utf8'
);
await fs.writeFile(
path.join(laneDir, 'opencode-prompt-delivery-ledger.json'),
JSON.stringify({
schemaVersion: 1,
updatedAt: nowIso,
data: [
{
id: 'opencode-prompt:proof-missing',
teamName,
memberName: 'jack',
laneId,
runId: 'run-1',
runtimeSessionId: 'ses-1',
inboxMessageId: 'msg-1',
inboxTimestamp: nowIso,
source: 'watcher',
messageKind: null,
replyRecipient: 'team-lead',
actionMode: null,
taskRefs: [{ taskId: 'task-1', displayId: 'task-1', teamName }],
payloadHash: 'sha256:test',
status: 'failed_terminal',
responseState: 'responded_non_visible_tool',
attempts: 3,
maxAttempts: 3,
acceptanceUnknown: false,
nextAttemptAt: null,
lastAttemptAt: nowIso,
lastObservedAt: nowIso,
acceptedAt: nowIso,
respondedAt: nowIso,
failedAt: nowIso,
inboxReadCommittedAt: null,
inboxReadCommitError: null,
prePromptCursor: null,
postPromptCursor: null,
deliveredUserMessageId: 'delivered-1',
observedAssistantMessageId: 'assistant-1',
observedAssistantPreview: null,
observedToolCallNames: ['task_get'],
observedVisibleMessageId: null,
visibleReplyMessageId: null,
visibleReplyInbox: null,
visibleReplyCorrelation: null,
lastReason: 'non_visible_tool_without_task_progress',
diagnostics: ['non_visible_tool_without_task_progress'],
createdAt: nowIso,
updatedAt: nowIso,
},
],
}),
'utf8'
);
const service = new TeamMemberRuntimeAdvisoryService({
findMemberLogs: vi.fn(async () => []),
});
const advisory = await service.getMemberAdvisory(teamName, 'jack');
expect(advisory).toMatchObject({
kind: 'api_error',
reasonCode: 'protocol_proof_missing',
message: 'OpenCode used tools, but did not create a visible reply or task progress proof.',
});
});
it('suppresses stale OpenCode prompt delivery advisories after a visible runtime reply exists', async () => { it('suppresses stale OpenCode prompt delivery advisories after a visible runtime reply exists', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-')); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-'));
setClaudeBasePathOverride(tmpDir); setClaudeBasePathOverride(tmpDir);

View file

@ -252,7 +252,7 @@ Messages:
expect(getInboxMessages).toHaveBeenCalledTimes(2); expect(getInboxMessages).toHaveBeenCalledTimes(2);
}); });
it('adds UI-only OpenCode bootstrap start rows for side-lane teammates', async () => { it('adds UI-only bootstrap start rows for side-lane teammates', async () => {
const opencodeConfig: TeamConfig = { const opencodeConfig: TeamConfig = {
name: 'relay-works-14', name: 'relay-works-14',
description: 'relay-works-14 team for provisioning flow', description: 'relay-works-14 team for provisioning flow',
@ -281,7 +281,7 @@ Messages:
from: 'team-lead', from: 'team-lead',
to: 'bob', to: 'bob',
source: 'system_notification', source: 'system_notification',
messageId: 'opencode-bootstrap-start:relay-works-14:bob', messageId: 'bootstrap-start:relay-works-14:bob',
timestamp: '2026-04-30T17:42:26.947Z', timestamp: '2026-04-30T17:42:26.947Z',
}); });
expect(feed.messages[0]?.text).toContain('Provider override for this teammate: opencode.'); expect(feed.messages[0]?.text).toContain('Provider override for this teammate: opencode.');
@ -292,4 +292,39 @@ Messages:
'The team has already been created and you are being attached as a persistent teammate.' 'The team has already been created and you are being attached as a persistent teammate.'
); );
}); });
it('keeps UI-only bootstrap start rows for members with stale inactive config flags', async () => {
const configWithStaleInactiveMember: TeamConfig = {
name: 'atlas-hq',
description: 'atlas-hq team for provisioning flow',
members: [
{ name: 'team-lead', role: 'Lead', providerId: 'codex' },
{
name: 'alice',
role: 'reviewer',
providerId: 'anthropic',
model: 'claude-opus-4-6',
joinedAt: 1778102486293,
isActive: false,
} as NonNullable<TeamConfig['members']>[number],
],
};
const service = new TeamMessageFeedService({
getConfig: vi.fn(async () => configWithStaleInactiveMember),
getInboxMessages: vi.fn(async () => []),
getLeadSessionMessages: vi.fn(async () => []),
getSentMessages: vi.fn(async () => []),
});
const feed = await service.getFeed('atlas-hq');
expect(feed.messages).toHaveLength(1);
expect(feed.messages[0]).toMatchObject({
from: 'team-lead',
to: 'alice',
source: 'system_notification',
messageId: 'bootstrap-start:atlas-hq:alice',
timestamp: '2026-05-06T21:21:26.293Z',
});
});
}); });

View file

@ -68,6 +68,7 @@ describe('TeamTaskStallNotifier', () => {
taskRefs: [alert.taskRef], taskRefs: [alert.taskRef],
actionMode: 'do', actionMode: 'do',
source: 'system_notification', source: 'system_notification',
messageKind: 'task_stall_remediation',
}) })
); );
expect(teamProvisioningService.relayOpenCodeMemberInboxMessages).toHaveBeenCalledWith( expect(teamProvisioningService.relayOpenCodeMemberInboxMessages).toHaveBeenCalledWith(

View file

@ -37,6 +37,8 @@ describe('TeamTaskStallSnapshotSource', () => {
}, },
], ],
}, },
{ id: 'task-approved', subject: 'Approved', status: 'in_progress' },
{ id: 'task-reopened', subject: 'Reopened', status: 'pending', reviewState: 'approved' },
]; ];
const deletedTasks = [{ id: 'task-deleted', subject: 'D', status: 'deleted' }]; const deletedTasks = [{ id: 'task-deleted', subject: 'D', status: 'deleted' }];
const transcriptContext = { const transcriptContext = {
@ -98,6 +100,10 @@ describe('TeamTaskStallSnapshotSource', () => {
movedAt: '2026-04-19T12:00:00.000Z', movedAt: '2026-04-19T12:00:00.000Z',
reviewer: 'alice', reviewer: 'alice',
}, },
'task-approved': {
column: 'approved',
movedAt: '2026-04-19T12:05:00.000Z',
},
}, },
})), })),
}; };
@ -136,23 +142,30 @@ describe('TeamTaskStallSnapshotSource', () => {
); );
const snapshot = await source.getSnapshot('demo'); const snapshot = await source.getSnapshot('demo');
const expectedWorkflowActiveTasks = [
activeTasks[0],
activeTasks[1],
{ ...activeTasks[2], reviewState: 'approved' },
{ ...activeTasks[3], reviewState: 'none' },
];
expect(snapshot).not.toBeNull(); expect(snapshot).not.toBeNull();
expect(batchIndexer.buildIndex).toHaveBeenCalledWith({ expect(batchIndexer.buildIndex).toHaveBeenCalledWith({
teamName: 'demo', teamName: 'demo',
tasks: [...activeTasks, ...deletedTasks], tasks: [...expectedWorkflowActiveTasks, ...deletedTasks],
messages: rawMessages, messages: rawMessages,
}); });
expect(freshnessReader.readSignals).toHaveBeenCalledWith('/tmp/project', ['task-a', 'task-b']); expect(freshnessReader.readSignals).toHaveBeenCalledWith('/tmp/project', ['task-a', 'task-b']);
expect(exactRowReader.parseFiles).toHaveBeenCalledWith(['/tmp/project/session-a.jsonl', '/tmp/project/session-b.jsonl']); expect(exactRowReader.parseFiles).toHaveBeenCalledWith(['/tmp/project/session-a.jsonl', '/tmp/project/session-b.jsonl']);
expect(openCodeEvidenceSource.readEvidence).toHaveBeenCalledWith({ expect(openCodeEvidenceSource.readEvidence).toHaveBeenCalledWith({
teamName: 'demo', teamName: 'demo',
tasks: [activeTasks[0], activeTasks[1]], tasks: [expectedWorkflowActiveTasks[0], expectedWorkflowActiveTasks[1]],
providerByMemberName: new Map([ providerByMemberName: new Map([
['team-lead', 'codex'], ['team-lead', 'codex'],
['alice', 'opencode'], ['alice', 'opencode'],
]), ]),
}); });
expect(snapshot?.activeTasks).toEqual(expectedWorkflowActiveTasks);
expect(snapshot?.inProgressTasks.map((task) => task.id)).toEqual(['task-a']); expect(snapshot?.inProgressTasks.map((task) => task.id)).toEqual(['task-a']);
expect(snapshot?.reviewOpenTasks.map((task) => task.id)).toEqual(['task-b']); expect(snapshot?.reviewOpenTasks.map((task) => task.id)).toEqual(['task-b']);
expect(snapshot?.leadName).toBe('team-lead'); expect(snapshot?.leadName).toBe('team-lead');

View 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');
});
});

View file

@ -6,12 +6,44 @@ describe('taskFiltersState', () => {
it('treats needsFix as distinct from normal todo/done buckets', () => { it('treats needsFix as distinct from normal todo/done buckets', () => {
const pendingNeedsFixTask = { status: 'pending', reviewState: 'needsFix' as const }; const pendingNeedsFixTask = { status: 'pending', reviewState: 'needsFix' as const };
const completedNeedsFixTask = { status: 'completed', reviewState: 'needsFix' as const }; const completedNeedsFixTask = { status: 'completed', reviewState: 'needsFix' as const };
const activeNeedsFixTask = { status: 'in_progress', reviewState: 'needsFix' as const };
const normalPendingTask = { status: 'pending', reviewState: 'none' as const }; const normalPendingTask = { status: 'pending', reviewState: 'none' as const };
expect(taskMatchesStatus(pendingNeedsFixTask, new Set(['needs_fix']))).toBe(true); expect(taskMatchesStatus(pendingNeedsFixTask, new Set(['needs_fix']))).toBe(true);
expect(taskMatchesStatus(completedNeedsFixTask, new Set(['needs_fix']))).toBe(true); expect(taskMatchesStatus(completedNeedsFixTask, new Set(['needs_fix']))).toBe(true);
expect(taskMatchesStatus(activeNeedsFixTask, new Set(['needs_fix']))).toBe(true);
expect(taskMatchesStatus(pendingNeedsFixTask, new Set(['todo']))).toBe(false); expect(taskMatchesStatus(pendingNeedsFixTask, new Set(['todo']))).toBe(false);
expect(taskMatchesStatus(completedNeedsFixTask, new Set(['done']))).toBe(false); expect(taskMatchesStatus(completedNeedsFixTask, new Set(['done']))).toBe(false);
expect(taskMatchesStatus(activeNeedsFixTask, new Set(['in_progress']))).toBe(false);
expect(taskMatchesStatus(normalPendingTask, new Set(['todo']))).toBe(true); expect(taskMatchesStatus(normalPendingTask, new Set(['todo']))).toBe(true);
}); });
it('treats completed review workflow as review, not done', () => {
const completedReviewTask = {
status: 'completed',
reviewState: 'review' as const,
kanbanColumn: 'review' as const,
};
expect(taskMatchesStatus(completedReviewTask, new Set(['review']))).toBe(true);
expect(taskMatchesStatus(completedReviewTask, new Set(['done']))).toBe(false);
});
it('lets current workflow overlay win over stale needsFix in filters', () => {
const approvedTask = {
status: 'in_progress',
reviewState: 'needsFix' as const,
kanbanColumn: 'approved' as const,
};
const reviewTask = {
status: 'completed',
reviewState: 'needsFix' as const,
kanbanColumn: 'review' as const,
};
expect(taskMatchesStatus(approvedTask, new Set(['approved']))).toBe(true);
expect(taskMatchesStatus(approvedTask, new Set(['needs_fix']))).toBe(false);
expect(taskMatchesStatus(reviewTask, new Set(['review']))).toBe(true);
expect(taskMatchesStatus(reviewTask, new Set(['needs_fix']))).toBe(false);
});
}); });

View file

@ -541,4 +541,41 @@ describe('ActivityItem legacy system message fallback', () => {
await Promise.resolve(); await Promise.resolve();
}); });
}); });
it('renders task stall remediation as a compact automation row', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const message: InboxMessage = {
from: 'system',
to: 'jack',
text: 'Task #1c24a4c4 may be stalled after a low-signal progress update.',
summary: 'Potential stalled task',
timestamp: new Date('2026-04-13T13:36:00.000Z').toISOString(),
read: true,
source: 'system_notification',
messageKind: 'task_stall_remediation',
messageId: 'task-stall:demo:task-a:epoch-a',
taskRefs: [{ taskId: 'task-a', displayId: '#1c24a4c4', teamName: 'my-team' }],
};
await act(async () => {
root.render(React.createElement(ActivityItem, { message, teamName: 'my-team' }));
await Promise.resolve();
});
expect(host.textContent).toContain('automation');
expect(host.textContent).toContain('stall nudge');
expect(host.textContent).toContain('jack');
expect(host.textContent).toContain('#1c24a4c4');
expect(host.textContent).not.toContain('may be stalled after a low-signal progress update');
expect(host.textContent).not.toContain('Do not send acknowledgement-only replies');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
}); });

View file

@ -2,7 +2,7 @@ import React, { act } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { MemberSpawnStatusEntry, ResolvedTeamMember } from '@shared/types'; import type { MemberSpawnStatusEntry, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
vi.mock('@renderer/components/team/members/MemberCard', () => ({ vi.mock('@renderer/components/team/members/MemberCard', () => ({
MemberCard: ({ MemberCard: ({
@ -10,6 +10,8 @@ vi.mock('@renderer/components/team/members/MemberCard', () => ({
spawnError, spawnError,
spawnStatus, spawnStatus,
spawnLaunchState, spawnLaunchState,
currentTask,
reviewTask,
onRestartMember, onRestartMember,
onSkipMemberForLaunch, onSkipMemberForLaunch,
}: { }: {
@ -17,6 +19,8 @@ vi.mock('@renderer/components/team/members/MemberCard', () => ({
spawnError?: string; spawnError?: string;
spawnStatus?: string; spawnStatus?: string;
spawnLaunchState?: string; spawnLaunchState?: string;
currentTask?: TeamTaskWithKanban | null;
reviewTask?: TeamTaskWithKanban | null;
onRestartMember?: (memberName: string) => void; onRestartMember?: (memberName: string) => void;
onSkipMemberForLaunch?: (memberName: string) => void; onSkipMemberForLaunch?: (memberName: string) => void;
}) => }) =>
@ -24,6 +28,12 @@ vi.mock('@renderer/components/team/members/MemberCard', () => ({
'div', 'div',
{ 'data-testid': `member-${member.name}` }, { 'data-testid': `member-${member.name}` },
spawnError ?? '', spawnError ?? '',
currentTask
? React.createElement('span', { 'data-testid': `current-${member.name}` }, currentTask.id)
: null,
reviewTask
? React.createElement('span', { 'data-testid': `review-${member.name}` }, reviewTask.id)
: null,
onRestartMember && (spawnStatus === 'error' || spawnLaunchState === 'failed_to_start') onRestartMember && (spawnStatus === 'error' || spawnLaunchState === 'failed_to_start')
? React.createElement( ? React.createElement(
'button', 'button',
@ -195,6 +205,41 @@ describe('MemberList spawn-status memoization', () => {
}); });
}); });
it('shows a review task when a stale currentTaskId points at the same non-active task', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const members: ResolvedTeamMember[] = [{ ...member, currentTaskId: 'task-review' }];
const reviewTask: TeamTaskWithKanban = {
id: 'task-review',
subject: 'Review this',
status: 'completed',
reviewState: 'review',
kanbanColumn: 'review',
reviewer: 'bob',
};
await act(async () => {
root.render(
React.createElement(MemberList, {
members,
isTeamAlive: true,
taskMap: new Map([[reviewTask.id, reviewTask]]),
})
);
await Promise.resolve();
});
expect(host.querySelector('[data-testid="current-bob"]')).toBeNull();
expect(host.querySelector('[data-testid="review-bob"]')?.textContent).toBe('task-review');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('passes skip callbacks to failed member cards and rerenders when the callback changes', async () => { it('passes skip callbacks to failed member cards and rerenders when the callback changes', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div'); const host = document.createElement('div');

View file

@ -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);
});
});

View file

@ -6,6 +6,7 @@ import {
getSpawnCardClass, getSpawnCardClass,
getMemberRuntimeAdvisoryLabel, getMemberRuntimeAdvisoryLabel,
getMemberRuntimeAdvisoryTitle, getMemberRuntimeAdvisoryTitle,
getMemberRuntimeAdvisoryTone,
isOpenCodeRelaunchActionable, isOpenCodeRelaunchActionable,
} from '@renderer/utils/memberHelpers'; } from '@renderer/utils/memberHelpers';
@ -703,15 +704,18 @@ describe('memberHelpers spawn-aware presence', () => {
const advisory = { const advisory = {
kind: 'api_error' as const, kind: 'api_error' as const,
observedAt: '2026-04-07T09:00:00.000Z', observedAt: '2026-04-07T09:00:00.000Z',
reasonCode: 'backend_error' as const, reasonCode: 'protocol_proof_missing' as const,
message: 'visible_reply_still_required', message: 'visible_reply_still_required',
}; };
expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode delivery error'); expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('OpenCode proof missing');
expect(getMemberRuntimeAdvisoryTone(advisory)).toBe('warning');
const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode'); const title = getMemberRuntimeAdvisoryTitle(advisory, 'opencode');
expect(title).toContain('OpenCode runtime delivery error.'); expect(title).toContain(
'OpenCode delivery completed without required visible/progress proof.'
);
expect(title).toContain('OpenCode responded, but did not create a visible message_send reply.'); expect(title).toContain('OpenCode responded, but did not create a visible message_send reply.');
expect(title).not.toContain('visible_reply_still_required'); expect(title).not.toContain('visible_reply_still_required');
}); });
@ -732,6 +736,42 @@ describe('memberHelpers spawn-aware presence', () => {
expect(title).not.toContain('runtime_bootstrap_checkin'); expect(title).not.toContain('runtime_bootstrap_checkin');
}); });
it('formats non-visible tool progress advisory reasons before showing them in titles', () => {
const title = getMemberRuntimeAdvisoryTitle(
{
kind: 'api_error',
observedAt: '2026-04-07T09:00:00.000Z',
reasonCode: 'protocol_proof_missing',
message: 'non_visible_tool_without_task_progress',
},
'opencode'
);
expect(
getMemberRuntimeAdvisoryLabel(
{
kind: 'api_error',
observedAt: '2026-04-07T09:00:00.000Z',
reasonCode: 'protocol_proof_missing',
message: 'non_visible_tool_without_task_progress',
},
'opencode'
)
).toBe('OpenCode proof missing');
expect(
getMemberRuntimeAdvisoryTone({
kind: 'api_error',
observedAt: '2026-04-07T09:00:00.000Z',
reasonCode: 'protocol_proof_missing',
message: 'non_visible_tool_without_task_progress',
})
).toBe('warning');
expect(title).toContain(
'OpenCode used tools, but did not create a visible reply or task progress proof.'
);
expect(title).not.toContain('non_visible_tool_without_task_progress');
});
it('renders Codex native timeout separately from network errors', () => { it('renders Codex native timeout separately from network errors', () => {
const advisory = { const advisory = {
kind: 'api_error' as const, kind: 'api_error' as const,

View file

@ -77,4 +77,25 @@ describe('openCodeRuntimeDeliveryDiagnostics', () => {
reason: 'visible_reply_still_required', reason: 'visible_reply_still_required',
}); });
}); });
it('surfaces missing task progress proof as a readable failure', () => {
const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({
deliveredToInbox: true,
messageId: 'msg-progress-required',
runtimeDelivery: {
providerId: 'opencode',
attempted: true,
delivered: false,
responsePending: false,
responseState: 'responded_non_visible_tool',
ledgerStatus: 'failed_terminal',
reason: 'non_visible_tool_without_task_progress',
diagnostics: ['non_visible_tool_without_task_progress'],
},
});
expect(diagnostics.warning).toBe(
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. Reason: OpenCode used tools, but did not create a visible reply or task progress proof.'
);
});
}); });

View 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,
});
});
});

View file

@ -534,4 +534,54 @@ Messages:
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0].messageId).toBe('msg-2'); expect(result[0].messageId).toBe('msg-2');
}); });
it('hides task stall remediation automation rows from conversational message counts by default', () => {
const messages = [
makeMessage({
messageId: 'task-stall:demo:task-a:epoch-a',
from: 'system',
to: 'jack',
source: 'system_notification',
messageKind: 'task_stall_remediation',
summary: 'Potential stalled task',
text: 'Task #abcd1234 may be stalled.',
}),
makeMessage({
messageId: 'msg-2',
text: 'Visible message',
}),
];
const result = filterTeamMessages(messages, {
timeWindow: null,
filter: { from: new Set(), to: new Set(), showNoise: true },
searchQuery: '',
});
expect(result.map((message) => message.messageId)).toEqual(['msg-2']);
});
it('can include task stall remediation automation rows for the activity timeline', () => {
const messages = [
makeMessage({
messageId: 'task-stall:demo:task-a:legacy-epoch',
from: 'system',
to: 'jack',
source: 'system_notification',
summary: 'Potential stalled task',
text: 'Task #abcd1234 may be stalled.',
}),
];
const result = filterTeamMessages(messages, {
includeAutomationEvents: true,
timeWindow: null,
filter: { from: new Set(), to: new Set(), showNoise: true },
searchQuery: '',
});
expect(result.map((message) => message.messageId)).toEqual([
'task-stall:demo:task-a:legacy-epoch',
]);
});
}); });

View file

@ -111,6 +111,39 @@ describe('reviewState utils', () => {
).toBe('none'); ).toBe('none');
}); });
it('lets canonical pending status clear stale review history when no reopen event exists', () => {
expect(
getReviewStateFromTask({
status: 'pending',
historyEvents: [
{
id: '1',
timestamp: '2026-01-01T00:00:00Z',
type: 'review_approved',
from: 'review',
to: 'approved',
actor: 'alice',
},
],
})
).toBe('none');
expect(
getReviewStateFromTask({
status: 'pending',
historyEvents: [
{
id: '1',
timestamp: '2026-01-01T00:00:00Z',
type: 'review_requested',
from: 'none',
to: 'review',
reviewer: 'bob',
},
],
})
).toBe('none');
});
it('falls back to persisted legacy reviewState when history has no review signal', () => { it('falls back to persisted legacy reviewState when history has no review signal', () => {
expect( expect(
getReviewStateFromTask({ getReviewStateFromTask({
@ -139,4 +172,25 @@ describe('reviewState utils', () => {
'needsFix' 'needsFix'
); );
}); });
it('keeps completed needsFix as a non-final review correction state', () => {
expect(getReviewStateFromTask({ reviewState: 'needsFix', status: 'completed' })).toBe(
'needsFix'
);
expect(
getReviewStateFromTask({
status: 'completed',
historyEvents: [
{
id: '1',
timestamp: '2026-01-01T00:00:00Z',
type: 'review_changes_requested',
from: 'review',
to: 'needsFix',
actor: 'reviewer',
},
],
})
).toBe('needsFix');
});
}); });

View file

@ -67,4 +67,63 @@ describe('taskChangeState utils', () => {
}) })
).toBe('active'); ).toBe('active');
}); });
it('treats in-progress tasks approved through kanban overlay as approved', () => {
const bucket = getTaskChangeStateBucket({
status: 'in_progress',
kanbanColumn: 'approved',
});
expect(bucket).toBe('approved');
expect(isTaskChangeSummaryCacheable(bucket)).toBe(true);
});
it('does not treat pending tasks with stale approved kanban overlay as approved', () => {
expect(
getTaskChangeStateBucket({
status: 'pending',
kanbanColumn: 'approved',
})
).toBe('active');
});
it('does not treat pending tasks with stale review kanban overlay as review', () => {
expect(
getTaskChangeStateBucket({
status: 'pending',
kanbanColumn: 'review',
})
).toBe('active');
});
it('lets current kanban review overlay win over stale approved review state', () => {
expect(
getTaskChangeStateBucket({
status: 'completed',
reviewState: 'approved',
kanbanColumn: 'review',
})
).toBe('review');
});
it('does not cache completed tasks that still need fixes', () => {
const bucket = getTaskChangeStateBucket({
status: 'completed',
reviewState: 'needsFix',
});
expect(bucket).toBe('active');
expect(isTaskChangeSummaryCacheable(bucket)).toBe(false);
});
it('lets current approved overlay win over stale needsFix for change summary caching', () => {
const bucket = getTaskChangeStateBucket({
status: 'completed',
reviewState: 'needsFix',
kanbanColumn: 'approved',
});
expect(bucket).toBe('approved');
expect(isTaskChangeSummaryCacheable(bucket)).toBe(true);
});
}); });