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:
parent
878653790c
commit
85684b59e8
10 changed files with 202 additions and 40 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (1–2 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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue