feat: enhance TeamMemberLogsFinder with team detection and task handling improvements

- Updated fileMentionsTaskId method to include an 'assumeTeam' parameter for better team detection in logs.
- Added logic to extract team information from process entries and message content, improving task attribution accuracy.
- Enhanced handling of task updates without explicit team names, ensuring correct task processing in solo scenarios.
- Improved overall robustness of log parsing and team identification, facilitating better task management in team environments.
This commit is contained in:
iliya 2026-03-04 17:08:34 +02:00
parent 878653790c
commit 85684b59e8
10 changed files with 202 additions and 40 deletions

View file

@ -129,7 +129,7 @@ export class TeamMemberLogsFinder {
const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`); const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`);
try { try {
await fs.access(leadJsonl); await fs.access(leadJsonl);
if (await this.fileMentionsTaskId(leadJsonl, teamName, taskId)) { if (await this.fileMentionsTaskId(leadJsonl, teamName, taskId, true)) {
const leadSummary = await this.parseLeadSessionSummary( const leadSummary = await this.parseLeadSessionSummary(
leadJsonl, leadJsonl,
projectId, projectId,
@ -470,7 +470,8 @@ export class TeamMemberLogsFinder {
private async fileMentionsTaskId( private async fileMentionsTaskId(
filePath: string, filePath: string,
teamName: string, teamName: string,
taskId: string taskId: string,
assumeTeam: boolean = false
): Promise<boolean> { ): Promise<boolean> {
const teamLower = teamName.trim().toLowerCase(); const teamLower = teamName.trim().toLowerCase();
const taskIdStr = taskId.trim(); const taskIdStr = taskId.trim();
@ -505,17 +506,84 @@ export class TeamMemberLogsFinder {
return Boolean(cmdTaskId && cmdTaskId === taskIdStr); return Boolean(cmdTaskId && cmdTaskId === taskIdStr);
}; };
const matchesTeamMentionText = (text: string): boolean => {
const t = text.toLowerCase();
if (!t.includes(teamLower)) return false;
// Strongest signal: spawn/system prompt format includes: on team "X" (X)
// Use substring checks to avoid regex word-boundary issues with kebab-case names.
if (t.includes(`on team "${teamLower}"`)) return true;
if (t.includes(`on team '${teamLower}'`)) return true;
if (t.includes(`on team ${teamLower}`)) return true;
if (t.includes(`(${teamLower})`)) return true;
return false;
};
const extractTeamFromProcess = (entry: Record<string, unknown>): string | null => {
const init = entry.init as Record<string, unknown> | undefined;
const process = (entry.process ?? init?.process) as Record<string, unknown> | undefined;
const team = process?.team as Record<string, unknown> | undefined;
const raw =
typeof team?.teamName === 'string'
? team.teamName
: typeof team?.team_name === 'string'
? team.team_name
: typeof team?.name === 'string'
? team.name
: null;
return typeof raw === 'string' ? raw.trim() : null;
};
try { try {
const stream = createReadStream(filePath, { encoding: 'utf8' }); const stream = createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
let teamSeen = assumeTeam;
let taskSeenWithoutTeam = false;
for await (const line of rl) { for await (const line of rl) {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed) continue; if (!trimmed) continue;
try { try {
const entry = JSON.parse(trimmed) as Record<string, unknown>; const entry = JSON.parse(trimmed) as Record<string, unknown>;
// Team detection (for TaskUpdate without team_name): accept only if we can
// confidently attribute the file to this team.
if (!teamSeen) {
const procTeam = extractTeamFromProcess(entry);
if (procTeam?.toLowerCase() === teamLower) {
teamSeen = true;
}
}
if (!teamSeen) {
const msg = entry.message as Record<string, unknown> | undefined;
const rawContent = msg?.content ?? entry.content;
if (typeof rawContent === 'string' && matchesTeamMentionText(rawContent)) {
teamSeen = true;
}
}
const content = this.extractEntryContent(entry); const content = this.extractEntryContent(entry);
if (!Array.isArray(content)) continue; if (!Array.isArray(content)) continue;
if (!teamSeen) {
// Check message text blocks for team mention (common in Solo spawn prompts)
for (const block of content) {
if (!block || typeof block !== 'object') continue;
const b = block as Record<string, unknown>;
if (
b.type === 'text' &&
typeof b.text === 'string' &&
matchesTeamMentionText(b.text)
) {
teamSeen = true;
break;
}
}
}
if (teamSeen && taskSeenWithoutTeam) {
rl.close();
stream.destroy();
return true;
}
for (const block of content) { for (const block of content) {
if (!block || typeof block !== 'object') continue; if (!block || typeof block !== 'object') continue;
const b = block as Record<string, unknown>; const b = block as Record<string, unknown>;
@ -530,14 +598,24 @@ export class TeamMemberLogsFinder {
const inputTeam = extractTeamFromInput(input); const inputTeam = extractTeamFromInput(input);
const rawTaskId = input.taskId ?? input.task_id; const rawTaskId = input.taskId ?? input.task_id;
const inputTaskId = extractTaskIdFromUnknown(rawTaskId); const inputTaskId = extractTaskIdFromUnknown(rawTaskId);
if ( if (inputTaskId && inputTaskId === taskIdStr) {
inputTeam?.toLowerCase() === teamLower && // If team is present in the input, require exact match.
inputTaskId && if (inputTeam) {
inputTaskId === taskIdStr if (inputTeam.toLowerCase() === teamLower) {
) { rl.close();
rl.close(); stream.destroy();
stream.destroy(); return true;
return true; }
} else {
// Some agents use TaskUpdate without team_name (common in Solo).
// Only accept when we have a separate team marker for this file.
if (teamSeen) {
rl.close();
stream.destroy();
return true;
}
taskSeenWithoutTeam = true;
}
} }
// Deterministic CLI match: teamctl command line (Bash tool). // Deterministic CLI match: teamctl command line (Bash tool).
@ -550,6 +628,12 @@ export class TeamMemberLogsFinder {
} }
} }
} }
if (teamSeen && taskSeenWithoutTeam) {
rl.close();
stream.destroy();
return true;
}
} catch { } catch {
// ignore parse errors // ignore parse errors
} }

View file

@ -760,14 +760,22 @@ function buildLaunchPrompt(
if (isSolo) { if (isSolo) {
step2And3Block = `2) Skip — solo team, no teammates to spawn. step2And3Block = `2) Skip — solo team, no teammates to spawn.
3) Execute tasks sequentially and keep the board + user updated: 3) SOLO TASK EXECUTION (IMPORTANT timing matters):
- Do NOT start executing tasks in THIS reconnect turn.
- This turn is ONLY to reconnect and confirm you are ready.
- After the reconnect is marked ready, you will receive a follow-up message telling you to begin work.
When you receive that follow-up message:
- 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, not blocked by incomplete dependencies).
- If the task is unassigned, set yourself ("${leadName}") as owner. - If the task is unassigned, set yourself ("${leadName}") as owner.
- BEFORE doing any work on a task: mark it started (in_progress). - BEFORE doing any work on a task: mark it started (in_progress).
- Immediately SendMessage "user" that you started task #<id> (what you're doing + next step). - Immediately SendMessage "user" that you started task #<id> (what you're doing + next step).
- While working: after each meaningful milestone/decision/blocker, add a task comment on #<id>. If the milestone is user-relevant, also SendMessage "user". - While working: after each meaningful milestone/decision/blocker, add a task comment on #<id>. If the milestone is user-relevant, also SendMessage "user".
- On completion: add a final task comment (what changed + how to verify), mark the task completed, then SendMessage "user" that task #<id> is complete and what you will do next. - On completion: add a final task comment (what changed + how to verify), mark the task completed, then SendMessage "user" that task #<id> is complete and what you will do next.
- Do NOT start the next task until the current task is completed (default: one task in_progress at a time).`; - Do NOT start the next task until the current task is completed (default: one task in_progress at a time).
For this reconnect turn: review the task board snapshot above and output a short summary (12 sentences) confirming reconnect is complete and you are ready.`;
} else { } else {
// Build per-member task snapshots to include in each teammate's spawn prompt // Build per-member task snapshots to include in each teammate's spawn prompt
const memberTaskBlocks = new Map<string, string>(); const memberTaskBlocks = new Map<string, string>();
@ -859,7 +867,7 @@ Steps (execute in this exact order):
${step2And3Block} ${step2And3Block}
4) After all steps, output a short summary of reconnected members and resumed tasks. 4) After all steps, output a short summary of reconnected members and what happens next.
${membersFooter} ${membersFooter}
`; `;
@ -2613,6 +2621,48 @@ export class TeamProvisioningService {
void this.relayLeadInboxMessages(run.teamName).catch((e: unknown) => void this.relayLeadInboxMessages(run.teamName).catch((e: unknown) =>
logger.warn(`[${run.teamName}] post-reconnect relay failed: ${e}`) logger.warn(`[${run.teamName}] post-reconnect relay failed: ${e}`)
); );
// Solo teams have no teammate processes to resume work; kick off task execution
// as a separate turn AFTER the launch is marked ready so the UI doesn't mix
// long-running task output into the "Launching team" live output stream.
if (run.request.members.length === 0) {
void (async () => {
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')
);
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).`,
`- 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).`,
`- While working: after each meaningful milestone/decision/blocker, add a task comment on #<id>. If user-relevant, also SendMessage "user".`,
`- On completion: add a final task comment (what changed + how to verify), mark the task completed, then SendMessage "user" that task #<id> is complete and what you will do next.`,
`- Do NOT start the next task until the current task is completed (default: one task in_progress at a time).`,
board.trim(),
]
.filter(Boolean)
.join('\n\n');
await this.sendMessageToTeam(run.teamName, message);
} catch (error) {
logger.warn(
`[${run.teamName}] Failed to kick off solo task resumption: ${
error instanceof Error ? error.message : String(error)
}`
);
}
})();
}
return; return;
} }

View file

@ -83,13 +83,14 @@ export const SidebarTaskItem = ({
const displaySubject = getDisplaySubject?.(task) ?? task.subject; const displaySubject = getDisplaySubject?.(task) ?? task.subject;
const [editValue, setEditValue] = useState(displaySubject); const [editValue, setEditValue] = useState(displaySubject);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
// Focus input when rename starts // Focus input when rename starts
useEffect(() => { useEffect(() => {
if (isRenaming && inputRef.current) { if (!isRenaming) return;
inputRef.current.focus(); const raf = requestAnimationFrame(() => {
inputRef.current.select(); inputRef.current?.focus();
} inputRef.current?.select();
});
return () => cancelAnimationFrame(raf);
}, [isRenaming]); }, [isRenaming]);
// Reset edit value when renaming starts // Reset edit value when renaming starts
@ -175,11 +176,8 @@ export const SidebarTaskItem = ({
onRenameCancel?.(); onRenameCancel?.();
} }
}} }}
className="min-w-0 flex-1 rounded border bg-transparent px-1 py-0 text-[13px] font-medium leading-tight text-text focus:outline-none" className="min-w-0 flex-1 border-none bg-transparent p-0 text-[13px] font-medium leading-tight focus:outline-none"
style={{ style={{ color: 'var(--color-text-muted)' }}
borderColor: 'var(--color-border-emphasis)',
backgroundColor: 'var(--color-surface-raised)',
}}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
) : ( ) : (

View file

@ -33,7 +33,7 @@ export const TaskContextMenu = ({
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<div className="w-full">{children}</div> <div className="w-full">{children}</div>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent> <ContextMenuContent onCloseAutoFocus={(e) => e.preventDefault()}>
<ContextMenuItem onSelect={onTogglePin}> <ContextMenuItem onSelect={onTogglePin}>
{isPinned ? ( {isPinned ? (
<> <>

View file

@ -97,7 +97,7 @@ export const CollapsibleTeamSection = ({
{action && <div className="relative z-10 flex shrink-0 items-center">{action}</div>} {action && <div className="relative z-10 flex shrink-0 items-center">{action}</div>}
</div> </div>
{isOpen && ( {isOpen && (
<div className={`mt-1.5 min-w-0 overflow-x-hidden pb-2 ${contentClassName ?? ''}`}> <div className={`mt-1.5 min-w-0 overflow-x-clip pb-2 ${contentClassName ?? ''}`}>
{children} {children}
</div> </div>
)} )}

View file

@ -545,7 +545,7 @@ export const TaskDetailDialog = ({
} }
}} }}
> >
<MarkdownViewer content={currentTask.description} maxHeight="max-h-[180px]" /> <MarkdownViewer content={currentTask.description} maxHeight="max-h-[180px]" bare />
<Pencil <Pencil
size={12} size={12}
className="mt-1 text-[var(--color-text-muted)] opacity-0 transition-opacity group-hover:opacity-100" className="mt-1 text-[var(--color-text-muted)] opacity-0 transition-opacity group-hover:opacity-100"
@ -625,7 +625,7 @@ export const TaskDetailDialog = ({
contentClassName="pl-2.5" contentClassName="pl-2.5"
defaultOpen defaultOpen
> >
<div className="min-w-0 overflow-hidden"> <div className="min-w-0">
<MemberLogsTab <MemberLogsTab
teamName={teamName} teamName={teamName}
taskId={currentTask.id} taskId={currentTask.id}

View file

@ -177,7 +177,7 @@ export const MemberLogsTab = ({
} }
return ( return (
<div className="max-h-[400px] w-full min-w-0 space-y-1.5 overflow-y-auto overflow-x-hidden pr-1"> <div className="w-full min-w-0 space-y-1.5">
{logs.map((log) => ( {logs.map((log) => (
<LogCard <LogCard
key={ key={
@ -229,11 +229,11 @@ const LogCard = ({
const timeAgo = formatRelativeTime(log.startTime); const timeAgo = formatRelativeTime(log.startTime);
return ( return (
<div className="min-w-0 overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]"> <div className="min-w-0 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] [overflow:clip]">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
className="flex w-full min-w-0 items-center gap-2 overflow-hidden px-3 py-2 text-left text-xs hover:bg-[var(--color-surface-raised)]" className="sticky -top-6 z-10 flex w-full min-w-0 items-center gap-2 overflow-hidden rounded-t-md border-b border-transparent bg-[var(--color-surface)] px-3 py-2 text-left text-xs hover:bg-[var(--color-surface-raised)]"
onClick={onToggle} onClick={onToggle}
> >
{expanded ? ( {expanded ? (
@ -279,7 +279,7 @@ const LogCard = ({
</div> </div>
)} )}
{!detailLoading && detailChunks && ( {!detailLoading && detailChunks && (
<div className="max-h-[360px] w-full min-w-0 overflow-y-auto overflow-x-hidden pr-1"> <div className="w-full min-w-0">
<MemberExecutionLog <MemberExecutionLog
chunks={detailChunks} chunks={detailChunks}
memberName={log.kind === 'lead_session' ? (log.memberName ?? undefined) : undefined} memberName={log.kind === 'lead_session' ? (log.memberName ?? undefined) : undefined}

View file

@ -12,6 +12,7 @@ import { useStore } from '@renderer/store';
import { getFileHunkCount, REVIEW_INSTANT_APPLY } from '@renderer/store/slices/changeReviewSlice'; import { getFileHunkCount, REVIEW_INSTANT_APPLY } from '@renderer/store/slices/changeReviewSlice';
import { buildSelectionAction } from '@renderer/utils/buildSelectionAction'; import { buildSelectionAction } from '@renderer/utils/buildSelectionAction';
import { buildSelectionInfo, SELECTION_DEBOUNCE_MS } from '@renderer/utils/codemirrorSelectionInfo'; import { buildSelectionInfo, SELECTION_DEBOUNCE_MS } from '@renderer/utils/codemirrorSelectionInfo';
import { sortItemsAsTree } from '@renderer/utils/fileTreeBuilder';
import { ChevronDown, Clock, X } from 'lucide-react'; import { ChevronDown, Clock, X } from 'lucide-react';
import { acceptAllChunks, computeChunkIndexAtPos, rejectAllChunks } from './CodeMirrorDiffUtils'; import { acceptAllChunks, computeChunkIndexAtPos, rejectAllChunks } from './CodeMirrorDiffUtils';
@ -171,12 +172,15 @@ export const ChangeReviewDialog = ({
scrollContainerRef, scrollContainerRef,
}); });
// File paths for viewed tracking // Sort files to match the visual order of the file tree (directories first, then alphabetical)
const allFilePaths = useMemo( const sortedFiles = useMemo(
() => (activeChangeSet?.files ?? []).map((f) => f.filePath), () => sortItemsAsTree(activeChangeSet?.files ?? [], (f) => f.relativePath),
[activeChangeSet] [activeChangeSet]
); );
// File paths for viewed tracking
const allFilePaths = useMemo(() => sortedFiles.map((f) => f.filePath), [sortedFiles]);
const pathChangeLabels = useMemo(() => { const pathChangeLabels = useMemo(() => {
if (!activeChangeSet) if (!activeChangeSet)
return {} as Record< return {} as Record<
@ -599,7 +603,7 @@ export const ChangeReviewDialog = ({
); );
const diffNav = useDiffNavigation( const diffNav = useDiffNavigation(
activeChangeSet?.files ?? [], sortedFiles,
activeFilePath, activeFilePath,
scrollToFile, scrollToFile,
activeEditorViewRef, activeEditorViewRef,
@ -1104,7 +1108,7 @@ export const ChangeReviewDialog = ({
className="relative flex min-h-0 flex-1 flex-col overflow-hidden" className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
> >
<ContinuousScrollView <ContinuousScrollView
files={activeChangeSet.files} files={sortedFiles}
fileContents={fileContents} fileContents={fileContents}
fileContentsLoading={fileContentsLoading} fileContentsLoading={fileContentsLoading}
viewedSet={viewedSet} viewedSet={viewedSet}

View file

@ -82,3 +82,29 @@ export function sortTreeNodes<T>(nodes: TreeNode<T>[]): TreeNode<T>[] {
return a.name.localeCompare(b.name); return a.name.localeCompare(b.name);
}); });
} }
/**
* Flatten a sorted tree into a list of leaf items in display order.
* Mirrors the visual order of ReviewFileTree (directories first, then alphabetical at each level).
*/
function collectLeaves<T>(nodes: TreeNode<T>[], out: T[]): void {
for (const node of sortTreeNodes(nodes)) {
if (node.isFile && node.data != null) {
out.push(node.data);
} else {
collectLeaves(node.children, out);
}
}
}
/**
* Sort a flat list of items to match the visual order of the file tree
* (directories first, then alphabetical at each level).
*/
export function sortItemsAsTree<T>(items: T[], getPath: (item: T) => string): T[] {
if (items.length <= 1) return items;
const tree = buildTree(items, getPath);
const result: T[] = [];
collectLeaves(tree, result);
return result;
}

View file

@ -521,7 +521,7 @@ describe('TeamMemberLogsFinder', () => {
const projectRoot = path.join(tmpDir, 'projects', projectId); const projectRoot = path.join(tmpDir, 'projects', projectId);
await fs.mkdir(path.join(projectRoot, sessionId, 'subagents'), { recursive: true }); await fs.mkdir(path.join(projectRoot, sessionId, 'subagents'), { recursive: true });
// Team A subagent referencing taskId 9 for team-a // Team A subagent referencing taskId 9 (no team_name in tool input, as in Solo/older runs)
await fs.writeFile( await fs.writeFile(
path.join(projectRoot, sessionId, 'subagents', 'agent-a1.jsonl'), path.join(projectRoot, sessionId, 'subagents', 'agent-a1.jsonl'),
[ [
@ -542,7 +542,7 @@ describe('TeamMemberLogsFinder', () => {
{ {
type: 'tool_use', type: 'tool_use',
name: 'TaskUpdate', name: 'TaskUpdate',
input: { team_name: teamA, taskId: '9', status: 'in_progress' }, input: { taskId: '9', status: 'in_progress' },
}, },
], ],
}, },
@ -551,7 +551,7 @@ describe('TeamMemberLogsFinder', () => {
'utf8' 'utf8'
); );
// Team B subagent referencing taskId 9 for team-b (must NOT be included when querying team-a) // Team B subagent referencing taskId 9 (must NOT be included when querying team-a)
await fs.writeFile( await fs.writeFile(
path.join(projectRoot, sessionId, 'subagents', 'agent-b1.jsonl'), path.join(projectRoot, sessionId, 'subagents', 'agent-b1.jsonl'),
[ [
@ -569,7 +569,7 @@ describe('TeamMemberLogsFinder', () => {
{ {
type: 'tool_use', type: 'tool_use',
name: 'TaskUpdate', name: 'TaskUpdate',
input: { team_name: teamB, taskId: '9', status: 'in_progress' }, input: { taskId: '9', status: 'in_progress' },
}, },
], ],
}, },