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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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_result'
| 'task_comment_notification'
| 'task_stall_remediation'
| 'member_work_sync_nudge'
| 'agent_error';
@ -828,6 +829,7 @@ export interface MemberRuntimeAdvisory {
| 'codex_native_timeout'
| 'network_error'
| 'provider_overloaded'
| 'protocol_proof_missing'
| 'backend_error'
| 'unknown';
message?: string;

View file

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

View file

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

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', () => {
const agenda = buildActionableWorkAgenda({
teamName: 'team-a',
@ -78,6 +249,98 @@ describe('buildActionableWorkAgenda', () => {
});
});
it('keeps completed tasks actionable for the current reviewer while workflow is review', () => {
const agenda = buildActionableWorkAgenda({
teamName: 'team-a',
memberName: 'alice',
generatedAt: '2026-04-29T00:00:00.000Z',
members: [{ name: 'alice' }, { name: 'bob' }],
tasks: [
{
id: 'task-review',
subject: 'Review completed work',
status: 'completed',
owner: 'bob',
reviewState: 'review',
historyEvents: [
{
id: 'evt-review',
type: 'review_requested',
timestamp: '2026-04-29T00:00:00.000Z',
reviewer: 'alice',
},
],
},
],
hash,
});
expect(agenda.items).toHaveLength(1);
expect(agenda.items[0]).toMatchObject({
taskId: 'task-review',
kind: 'review',
assignee: 'alice',
});
});
it('does not assign owner work while stale in-progress task is in review workflow', () => {
const ownerAgenda = buildActionableWorkAgenda({
teamName: 'team-a',
memberName: 'bob',
generatedAt: '2026-04-29T00:00:00.000Z',
members: [{ name: 'alice' }, { name: 'bob' }],
tasks: [
{
id: 'task-review',
subject: 'Review in progress status',
status: 'in_progress',
owner: 'bob',
reviewState: 'none',
kanbanColumn: 'review',
historyEvents: [
{
id: 'evt-review',
type: 'review_requested',
timestamp: '2026-04-29T00:00:00.000Z',
reviewer: 'alice',
},
],
},
],
hash,
});
const reviewerAgenda = buildActionableWorkAgenda({
teamName: 'team-a',
memberName: 'alice',
generatedAt: '2026-04-29T00:00:00.000Z',
members: [{ name: 'alice' }, { name: 'bob' }],
tasks: [
{
id: 'task-review',
subject: 'Review in progress status',
status: 'in_progress',
owner: 'bob',
reviewState: 'none',
kanbanColumn: 'review',
historyEvents: [
{
id: 'evt-review',
type: 'review_requested',
timestamp: '2026-04-29T00:00:00.000Z',
reviewer: 'alice',
},
],
},
],
hash,
});
expect(ownerAgenda.items).toEqual([]);
expect(reviewerAgenda.items.map((item) => [item.taskId, item.kind, item.reason])).toEqual([
['task-review', 'review', 'current_cycle_review_assigned'],
]);
});
it('does not resurrect a stale reviewer after review was approved', () => {
const agenda = buildActionableWorkAgenda({
teamName: 'team-a',
@ -257,12 +520,20 @@ describe('buildActionableWorkAgenda', () => {
owner: 'bob',
reviewState: 'needsFix',
},
{
id: 'task-2',
subject: 'Fix completed review',
status: 'completed',
owner: 'bob',
reviewState: 'needsFix',
},
],
hash,
});
expect(agenda.items.map((item) => [item.taskId, item.kind, item.reason])).toEqual([
['task-1', 'work', 'review_changes_requested'],
['task-2', 'work', 'review_changes_requested'],
]);
});

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

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 () => {
const service = new TeamDataService(
{
@ -1967,6 +2148,64 @@ describe('TeamDataService', () => {
});
});
it('lets kanban approved overlay win over stale review history in global task projections', async () => {
const service = new TeamDataService(
{
listTeams: vi.fn(async () => [
{
teamName: 'my-team',
displayName: 'My team',
projectPath: '/repo',
},
]),
} as never,
{
getAllTasks: vi.fn(async () => [
{
id: 'task-global-approved',
teamName: 'my-team',
subject: 'Global approved task',
status: 'completed',
owner: 'bob',
reviewState: 'none',
historyEvents: [
{
id: 'evt-review',
type: 'review_started',
to: 'review',
timestamp: '2026-03-01T09:00:00.000Z',
},
],
},
]),
} as never,
{} as never,
{} as never,
{} as never,
{
getState: vi.fn(async () => ({
teamName: 'my-team',
reviewers: [],
tasks: {
'task-global-approved': {
column: 'approved',
reviewer: 'carol',
movedAt: '2026-03-01T10:00:00.000Z',
},
},
})),
} as never
);
const tasks = await service.getAllTasks();
expect(tasks[0]).toMatchObject({
id: 'task-global-approved',
reviewState: 'approved',
kanbanColumn: 'approved',
});
});
it('propagates leadSessionId for kanban-driven review transitions', async () => {
const requestReviewMock = vi.fn();
const approveReviewMock = vi.fn();

View file

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

View file

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

View file

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

View file

@ -68,6 +68,7 @@ describe('TeamTaskStallNotifier', () => {
taskRefs: [alert.taskRef],
actionMode: 'do',
source: 'system_notification',
messageKind: 'task_stall_remediation',
})
);
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 transcriptContext = {
@ -98,6 +100,10 @@ describe('TeamTaskStallSnapshotSource', () => {
movedAt: '2026-04-19T12:00:00.000Z',
reviewer: 'alice',
},
'task-approved': {
column: 'approved',
movedAt: '2026-04-19T12:05:00.000Z',
},
},
})),
};
@ -136,23 +142,30 @@ describe('TeamTaskStallSnapshotSource', () => {
);
const snapshot = await source.getSnapshot('demo');
const expectedWorkflowActiveTasks = [
activeTasks[0],
activeTasks[1],
{ ...activeTasks[2], reviewState: 'approved' },
{ ...activeTasks[3], reviewState: 'none' },
];
expect(snapshot).not.toBeNull();
expect(batchIndexer.buildIndex).toHaveBeenCalledWith({
teamName: 'demo',
tasks: [...activeTasks, ...deletedTasks],
tasks: [...expectedWorkflowActiveTasks, ...deletedTasks],
messages: rawMessages,
});
expect(freshnessReader.readSignals).toHaveBeenCalledWith('/tmp/project', ['task-a', 'task-b']);
expect(exactRowReader.parseFiles).toHaveBeenCalledWith(['/tmp/project/session-a.jsonl', '/tmp/project/session-b.jsonl']);
expect(openCodeEvidenceSource.readEvidence).toHaveBeenCalledWith({
teamName: 'demo',
tasks: [activeTasks[0], activeTasks[1]],
tasks: [expectedWorkflowActiveTasks[0], expectedWorkflowActiveTasks[1]],
providerByMemberName: new Map([
['team-lead', 'codex'],
['alice', 'opencode'],
]),
});
expect(snapshot?.activeTasks).toEqual(expectedWorkflowActiveTasks);
expect(snapshot?.inProgressTasks.map((task) => task.id)).toEqual(['task-a']);
expect(snapshot?.reviewOpenTasks.map((task) => task.id)).toEqual(['task-b']);
expect(snapshot?.leadName).toBe('team-lead');

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', () => {
const pendingNeedsFixTask = { status: 'pending', reviewState: 'needsFix' as const };
const completedNeedsFixTask = { status: 'completed', reviewState: 'needsFix' as const };
const activeNeedsFixTask = { status: 'in_progress', reviewState: 'needsFix' as const };
const normalPendingTask = { status: 'pending', reviewState: 'none' as const };
expect(taskMatchesStatus(pendingNeedsFixTask, new Set(['needs_fix']))).toBe(true);
expect(taskMatchesStatus(completedNeedsFixTask, new Set(['needs_fix']))).toBe(true);
expect(taskMatchesStatus(activeNeedsFixTask, new Set(['needs_fix']))).toBe(true);
expect(taskMatchesStatus(pendingNeedsFixTask, new Set(['todo']))).toBe(false);
expect(taskMatchesStatus(completedNeedsFixTask, new Set(['done']))).toBe(false);
expect(taskMatchesStatus(activeNeedsFixTask, new Set(['in_progress']))).toBe(false);
expect(taskMatchesStatus(normalPendingTask, new Set(['todo']))).toBe(true);
});
it('treats completed review workflow as review, not done', () => {
const completedReviewTask = {
status: 'completed',
reviewState: 'review' as const,
kanbanColumn: 'review' as const,
};
expect(taskMatchesStatus(completedReviewTask, new Set(['review']))).toBe(true);
expect(taskMatchesStatus(completedReviewTask, new Set(['done']))).toBe(false);
});
it('lets current workflow overlay win over stale needsFix in filters', () => {
const approvedTask = {
status: 'in_progress',
reviewState: 'needsFix' as const,
kanbanColumn: 'approved' as const,
};
const reviewTask = {
status: 'completed',
reviewState: 'needsFix' as const,
kanbanColumn: 'review' as const,
};
expect(taskMatchesStatus(approvedTask, new Set(['approved']))).toBe(true);
expect(taskMatchesStatus(approvedTask, new Set(['needs_fix']))).toBe(false);
expect(taskMatchesStatus(reviewTask, new Set(['review']))).toBe(true);
expect(taskMatchesStatus(reviewTask, new Set(['needs_fix']))).toBe(false);
});
});

View file

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

View file

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

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

View file

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

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

View file

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