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`);
try {
await fs.access(leadJsonl);
if (await this.fileMentionsTaskId(leadJsonl, teamName, taskId)) {
if (await this.fileMentionsTaskId(leadJsonl, teamName, taskId, true)) {
const leadSummary = await this.parseLeadSessionSummary(
leadJsonl,
projectId,
@ -470,7 +470,8 @@ export class TeamMemberLogsFinder {
private async fileMentionsTaskId(
filePath: string,
teamName: string,
taskId: string
taskId: string,
assumeTeam: boolean = false
): Promise<boolean> {
const teamLower = teamName.trim().toLowerCase();
const taskIdStr = taskId.trim();
@ -505,17 +506,84 @@ export class TeamMemberLogsFinder {
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 {
const stream = createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
let teamSeen = assumeTeam;
let taskSeenWithoutTeam = false;
for await (const line of rl) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
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);
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) {
if (!block || typeof block !== 'object') continue;
const b = block as Record<string, unknown>;
@ -530,14 +598,24 @@ export class TeamMemberLogsFinder {
const inputTeam = extractTeamFromInput(input);
const rawTaskId = input.taskId ?? input.task_id;
const inputTaskId = extractTaskIdFromUnknown(rawTaskId);
if (
inputTeam?.toLowerCase() === teamLower &&
inputTaskId &&
inputTaskId === taskIdStr
) {
rl.close();
stream.destroy();
return true;
if (inputTaskId && inputTaskId === taskIdStr) {
// If team is present in the input, require exact match.
if (inputTeam) {
if (inputTeam.toLowerCase() === teamLower) {
rl.close();
stream.destroy();
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).
@ -550,6 +628,12 @@ export class TeamMemberLogsFinder {
}
}
}
if (teamSeen && taskSeenWithoutTeam) {
rl.close();
stream.destroy();
return true;
}
} catch {
// ignore parse errors
}

View file

@ -760,14 +760,22 @@ function buildLaunchPrompt(
if (isSolo) {
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).
- If the task is unassigned, set yourself ("${leadName}") 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 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.
- 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 {
// Build per-member task snapshots to include in each teammate's spawn prompt
const memberTaskBlocks = new Map<string, string>();
@ -859,7 +867,7 @@ Steps (execute in this exact order):
${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}
`;
@ -2613,6 +2621,48 @@ export class TeamProvisioningService {
void this.relayLeadInboxMessages(run.teamName).catch((e: unknown) =>
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;
}

View file

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

View file

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

View file

@ -97,7 +97,7 @@ export const CollapsibleTeamSection = ({
{action && <div className="relative z-10 flex shrink-0 items-center">{action}</div>}
</div>
{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}
</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
size={12}
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"
defaultOpen
>
<div className="min-w-0 overflow-hidden">
<div className="min-w-0">
<MemberLogsTab
teamName={teamName}
taskId={currentTask.id}

View file

@ -177,7 +177,7 @@ export const MemberLogsTab = ({
}
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) => (
<LogCard
key={
@ -229,11 +229,11 @@ const LogCard = ({
const timeAgo = formatRelativeTime(log.startTime);
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>
<TooltipTrigger asChild>
<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}
>
{expanded ? (
@ -279,7 +279,7 @@ const LogCard = ({
</div>
)}
{!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
chunks={detailChunks}
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 { buildSelectionAction } from '@renderer/utils/buildSelectionAction';
import { buildSelectionInfo, SELECTION_DEBOUNCE_MS } from '@renderer/utils/codemirrorSelectionInfo';
import { sortItemsAsTree } from '@renderer/utils/fileTreeBuilder';
import { ChevronDown, Clock, X } from 'lucide-react';
import { acceptAllChunks, computeChunkIndexAtPos, rejectAllChunks } from './CodeMirrorDiffUtils';
@ -171,12 +172,15 @@ export const ChangeReviewDialog = ({
scrollContainerRef,
});
// File paths for viewed tracking
const allFilePaths = useMemo(
() => (activeChangeSet?.files ?? []).map((f) => f.filePath),
// Sort files to match the visual order of the file tree (directories first, then alphabetical)
const sortedFiles = useMemo(
() => sortItemsAsTree(activeChangeSet?.files ?? [], (f) => f.relativePath),
[activeChangeSet]
);
// File paths for viewed tracking
const allFilePaths = useMemo(() => sortedFiles.map((f) => f.filePath), [sortedFiles]);
const pathChangeLabels = useMemo(() => {
if (!activeChangeSet)
return {} as Record<
@ -599,7 +603,7 @@ export const ChangeReviewDialog = ({
);
const diffNav = useDiffNavigation(
activeChangeSet?.files ?? [],
sortedFiles,
activeFilePath,
scrollToFile,
activeEditorViewRef,
@ -1104,7 +1108,7 @@ export const ChangeReviewDialog = ({
className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
>
<ContinuousScrollView
files={activeChangeSet.files}
files={sortedFiles}
fileContents={fileContents}
fileContentsLoading={fileContentsLoading}
viewedSet={viewedSet}

View file

@ -82,3 +82,29 @@ export function sortTreeNodes<T>(nodes: TreeNode<T>[]): TreeNode<T>[] {
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);
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(
path.join(projectRoot, sessionId, 'subagents', 'agent-a1.jsonl'),
[
@ -542,7 +542,7 @@ describe('TeamMemberLogsFinder', () => {
{
type: 'tool_use',
name: 'TaskUpdate',
input: { team_name: teamA, taskId: '9', status: 'in_progress' },
input: { taskId: '9', status: 'in_progress' },
},
],
},
@ -551,7 +551,7 @@ describe('TeamMemberLogsFinder', () => {
'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(
path.join(projectRoot, sessionId, 'subagents', 'agent-b1.jsonl'),
[
@ -569,7 +569,7 @@ describe('TeamMemberLogsFinder', () => {
{
type: 'tool_use',
name: 'TaskUpdate',
input: { team_name: teamB, taskId: '9', status: 'in_progress' },
input: { taskId: '9', status: 'in_progress' },
},
],
},