feat: implement task change caching and enhance message identity in TeamInboxWriter

- Introduced a new caching mechanism for task changes in ChangeExtractorService to improve performance and reduce redundant data fetching.
- Updated TeamInboxWriter to preserve message identity fields such as messageId and timestamp for better deduplication across live and persisted messages.
- Enhanced SendMessageRequest interface to include additional fields like color, toolSummary, and toolCalls for richer message context.
- Improved log file reference resolution in TeamMemberLogsFinder to include filePath for better tracking of log sources.
This commit is contained in:
iliya 2026-03-10 00:47:13 +02:00
parent e2afcbd3b7
commit e99cbe1335
8 changed files with 143 additions and 25 deletions

View file

@ -31,6 +31,11 @@ interface CacheEntry {
expiresAt: number;
}
interface TaskChangeCacheEntry {
data: TaskChangeSetV2;
expiresAt: number;
}
/** Ссылка на JSONL файл с привязкой к memberName */
interface LogFileRef {
filePath: string;
@ -39,7 +44,9 @@ interface LogFileRef {
export class ChangeExtractorService {
private cache = new Map<string, CacheEntry>();
private taskChangeCache = new Map<string, TaskChangeCacheEntry>();
private readonly cacheTtl = 30 * 1000; // 30 сек — shorter TTL to reduce stale data risk
private readonly taskChangeCacheTtl = 20 * 1000; // 20 сек для task changes
constructor(
private readonly logsFinder: TeamMemberLogsFinder,
@ -115,6 +122,12 @@ export class ChangeExtractorService {
since?: string;
}
): Promise<TaskChangeSetV2> {
const cacheKey = `task:${teamName}:${taskId}`;
const cached = this.taskChangeCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.data;
}
const taskMeta = await this.readTaskMeta(teamName, taskId);
const logs = await this.logsFinder.findLogsForTask(teamName, taskId, {
owner: options?.owner ?? taskMeta?.owner,
@ -124,7 +137,12 @@ export class ChangeExtractorService {
});
const logRefs = await this.resolveLogFileRefs(teamName, logs);
if (logRefs.length === 0) {
return this.emptyTaskChangeSet(teamName, taskId);
const empty = this.emptyTaskChangeSet(teamName, taskId);
this.taskChangeCache.set(cacheKey, {
data: empty,
expiresAt: Date.now() + this.taskChangeCacheTtl,
});
return empty;
}
const projectPath = await this.resolveProjectPath(teamName);
@ -162,7 +180,7 @@ export class ChangeExtractorService {
},
};
return {
const intervalResult: TaskChangeSetV2 = {
teamName,
taskId,
files,
@ -177,9 +195,24 @@ export class ChangeExtractorService {
? ['No file edits found within persisted workIntervals.']
: ['Task boundaries missing — scoped by workIntervals timestamps.'],
};
this.taskChangeCache.set(cacheKey, {
data: intervalResult,
expiresAt: Date.now() + this.taskChangeCacheTtl,
});
return intervalResult;
}
return this.fallbackSingleTaskScope(teamName, taskId, logRefs, projectPath);
const fallbackResult = await this.fallbackSingleTaskScope(
teamName,
taskId,
logRefs,
projectPath
);
this.taskChangeCache.set(cacheKey, {
data: fallbackResult,
expiresAt: Date.now() + this.taskChangeCacheTtl,
});
return fallbackResult;
}
// Фильтруем snippets по tool_use IDs из scope
@ -192,7 +225,7 @@ export class ChangeExtractorService {
warnings.push('Some task boundaries could not be precisely determined.');
}
return {
const result: TaskChangeSetV2 = {
teamName,
taskId,
files,
@ -204,6 +237,11 @@ export class ChangeExtractorService {
scope: allScopes[0],
warnings,
};
this.taskChangeCache.set(cacheKey, {
data: result,
expiresAt: Date.now() + this.taskChangeCacheTtl,
});
return result;
}
/** Получить краткую статистику */
@ -684,14 +722,27 @@ export class ChangeExtractorService {
return false;
}
/** Конвертировать MemberLogSummary[] в LogFileRef[] через findMemberLogPaths */
/** Конвертировать MemberLogSummary[] в LogFileRef[] */
private async resolveLogFileRefs(
teamName: string,
logs: MemberLogSummary[]
): Promise<LogFileRef[]> {
const refs: LogFileRef[] = [];
const byMember = new Map<string, MemberLogSummary[]>();
const logsNeedingResolve: MemberLogSummary[] = [];
for (const log of logs) {
const memberName = log.memberName ?? 'unknown';
if (log.filePath) {
refs.push({ filePath: log.filePath, memberName });
} else {
logsNeedingResolve.push(log);
}
}
if (logsNeedingResolve.length === 0) return refs;
const byMember = new Map<string, MemberLogSummary[]>();
for (const log of logsNeedingResolve) {
const name = log.memberName ?? 'unknown';
if (!byMember.has(name)) byMember.set(name, []);
byMember.get(name)!.push(log);

View file

@ -12,7 +12,7 @@ import type { InboxMessage, SendMessageRequest, SendMessageResult } from '@share
export class TeamInboxWriter {
async sendMessage(teamName: string, request: SendMessageRequest): Promise<SendMessageResult> {
const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${request.member}.json`);
const messageId = randomUUID();
const messageId = request.messageId?.trim() || randomUUID();
const attachmentMeta = request.attachments?.map((a) => ({
id: a.id,
@ -25,17 +25,20 @@ export class TeamInboxWriter {
from: request.from ?? 'user',
to: request.to ?? request.member,
text: request.text,
timestamp: new Date().toISOString(),
timestamp: request.timestamp ?? new Date().toISOString(),
read: false,
summary: request.summary,
messageId,
attachments: attachmentMeta?.length ? attachmentMeta : undefined,
...(request.source && { source: request.source }),
...(request.leadSessionId && { leadSessionId: request.leadSessionId }),
...(request.color && { color: request.color }),
...(request.conversationId && { conversationId: request.conversationId }),
...(request.replyToConversationId && {
replyToConversationId: request.replyToConversationId,
}),
...(request.toolSummary && { toolSummary: request.toolSummary }),
...(request.toolCalls && { toolCalls: request.toolCalls }),
};
await withFileLock(inboxPath, async () => {

View file

@ -767,6 +767,7 @@ export class TeamMemberLogsFinder {
durationMs: Math.max(0, durationMs),
messageCount: metadata.messageCount,
isOngoing,
filePath,
};
}
@ -990,6 +991,7 @@ export class TeamMemberLogsFinder {
durationMs: Math.max(0, durationMs),
messageCount: metadata.messageCount,
isOngoing,
filePath: jsonlPath,
};
}

View file

@ -725,11 +725,6 @@ export const LeadThoughtsGroupRow = ({
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{thoughts.length} thoughts
</span>
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{formatTime(oldest.timestamp) === formatTime(newest.timestamp)
? formatTime(oldest.timestamp)
: `${formatTime(oldest.timestamp)}${formatTime(newest.timestamp)}`}
</span>
{!isBodyVisible && headerTextPreview ? (
<Tooltip>
<TooltipTrigger asChild>
@ -764,6 +759,11 @@ export const LeadThoughtsGroupRow = ({
</TooltipContent>
</Tooltip>
) : null}
<span className="ml-auto shrink-0 text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{formatTime(oldest.timestamp) === formatTime(newest.timestamp)
? formatTime(oldest.timestamp)
: `${formatTime(oldest.timestamp)}${formatTime(newest.timestamp)}`}
</span>
</div>
{/* Scrollable body — live thoughts follow bottom unless user scrolls up */}

View file

@ -466,12 +466,17 @@ export const CodeMirrorDiffView = ({
// Merge toolbar: always visible for nearest chunk, follows cursor when hovering on chunk
if (showMergeControls) {
// Helper: pin chunkButtons to right edge of visible viewport, accounting for horizontal scroll
// Helper: pin chunkButtons to right edge of visible viewport, accounting for horizontal scroll.
// Uses getBoundingClientRect() so the offset from gutters / CM content padding is handled exactly.
const pinToViewportRight = (btnContainer: HTMLElement, scroller: Element): void => {
const scrollerEl = scroller as HTMLElement;
const scrollerRect = scroller.getBoundingClientRect();
const chunkEl = btnContainer.parentElement;
if (!chunkEl) return;
const chunkRect = chunkEl.getBoundingClientRect();
const btnWidth = btnContainer.offsetWidth || 200;
// Position at: scrollLeft + visible width - button width - margin
btnContainer.style.left = `${scrollerEl.scrollLeft + scrollerEl.clientWidth - btnWidth - 8}px`;
const margin = 12;
// left is relative to .cm-deletedChunk — so we compute from scroller's right edge
btnContainer.style.left = `${scrollerRect.right - chunkRect.left - btnWidth - margin}px`;
btnContainer.style.right = 'auto';
};

View file

@ -394,11 +394,17 @@ const portionCollapseField = StateField.define<PortionCollapseState>({
// the visible viewport width, making `position: sticky; left: 0` actually constrain them.
function syncCollapseWidths(view: EditorView): void {
const w = view.scrollDOM.clientWidth;
if (!w) return;
const scrollerRect = view.scrollDOM.getBoundingClientRect();
if (!scrollerRect.width) return;
const els = view.dom.querySelectorAll<HTMLElement>('.cm-portion-collapse');
for (const el of els) {
el.style.width = `${w}px`;
// The widget lives inside .cm-content which may have a left offset (gutters).
// Compute available width from scroller's right edge minus the element's left position.
const elRect = el.getBoundingClientRect();
const w = scrollerRect.right - elRect.left;
if (w > 0) {
el.style.width = `${w}px`;
}
}
}

View file

@ -271,14 +271,19 @@ export interface SendMessageRequest {
text: string;
summary?: string;
from?: string;
timestamp?: string;
messageId?: string;
/** Override the `to` field in the stored message (defaults to `member`). */
to?: string;
color?: string;
attachments?: AttachmentPayload[];
source?: InboxMessage['source'];
/** Lead session ID for session boundary detection. */
leadSessionId?: string;
conversationId?: string;
replyToConversationId?: string;
toolSummary?: string;
toolCalls?: ToolCallMeta[];
}
export interface SendMessageResult {
@ -525,6 +530,8 @@ export interface MemberLogSummaryBase {
durationMs: number;
messageCount: number;
isOngoing: boolean;
/** Absolute path to JSONL file when known (avoids redundant findMemberLogPaths scan). */
filePath?: string;
}
export interface MemberSubagentLogSummary extends MemberLogSummaryBase {

View file

@ -49,11 +49,16 @@ vi.mock('crypto', async (importOriginal) => {
};
});
vi.mock('fs', () => ({
promises: {
readFile: hoisted.readFile,
},
}));
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
return {
...actual,
promises: {
...actual.promises,
readFile: hoisted.readFile,
},
};
});
vi.mock('../../../../src/main/utils/pathDecoder', () => ({
getTeamsBasePath: () => '/mock/teams',
@ -63,6 +68,14 @@ vi.mock('../../../../src/main/services/team/atomicWrite', () => ({
atomicWriteAsync: hoisted.atomicWrite,
}));
vi.mock('../../../../src/main/services/team/fileLock', () => ({
withFileLock: async (_path: string, fn: () => Promise<unknown>) => await fn(),
}));
vi.mock('../../../../src/main/services/team/inboxLock', () => ({
withInboxLock: async (_path: string, fn: () => Promise<unknown>) => await fn(),
}));
import { TeamInboxWriter } from '../../../../src/main/services/team/TeamInboxWriter';
describe('TeamInboxWriter', () => {
@ -144,6 +157,37 @@ describe('TeamInboxWriter', () => {
expect(persisted[0].source).toBe('system_notification');
});
it('preserves provided message identity fields for dedup across live and persisted rows', async () => {
const result = await writer.sendMessage('my-team', {
member: 'alice',
from: 'team-lead',
to: 'team-best.user',
text: 'Hello cross-team',
summary: 'Cross-team reply',
messageId: 'lead-sendmsg-run-1-123',
timestamp: '2026-03-10T00:33:55.000Z',
source: 'lead_process',
color: 'purple',
toolSummary: '1 tool',
toolCalls: [{ name: 'SendMessage', preview: 'team-best.user' }],
});
const persisted = JSON.parse(hoisted.files.get(inboxPath) ?? '[]') as Record<string, unknown>[];
expect(result.messageId).toBe('lead-sendmsg-run-1-123');
expect(persisted[0]).toMatchObject({
from: 'team-lead',
to: 'team-best.user',
text: 'Hello cross-team',
summary: 'Cross-team reply',
messageId: 'lead-sendmsg-run-1-123',
timestamp: '2026-03-10T00:33:55.000Z',
source: 'lead_process',
color: 'purple',
toolSummary: '1 tool',
toolCalls: [{ name: 'SendMessage', preview: 'team-best.user' }],
});
});
it('omits source field from payload when not provided in request', async () => {
await writer.sendMessage('my-team', {
member: 'alice',