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:
parent
e2afcbd3b7
commit
e99cbe1335
8 changed files with 143 additions and 25 deletions
|
|
@ -31,6 +31,11 @@ interface CacheEntry {
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TaskChangeCacheEntry {
|
||||||
|
data: TaskChangeSetV2;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** Ссылка на JSONL файл с привязкой к memberName */
|
/** Ссылка на JSONL файл с привязкой к memberName */
|
||||||
interface LogFileRef {
|
interface LogFileRef {
|
||||||
filePath: string;
|
filePath: string;
|
||||||
|
|
@ -39,7 +44,9 @@ interface LogFileRef {
|
||||||
|
|
||||||
export class ChangeExtractorService {
|
export class ChangeExtractorService {
|
||||||
private cache = new Map<string, CacheEntry>();
|
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 cacheTtl = 30 * 1000; // 30 сек — shorter TTL to reduce stale data risk
|
||||||
|
private readonly taskChangeCacheTtl = 20 * 1000; // 20 сек для task changes
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly logsFinder: TeamMemberLogsFinder,
|
private readonly logsFinder: TeamMemberLogsFinder,
|
||||||
|
|
@ -115,6 +122,12 @@ export class ChangeExtractorService {
|
||||||
since?: string;
|
since?: string;
|
||||||
}
|
}
|
||||||
): Promise<TaskChangeSetV2> {
|
): 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 taskMeta = await this.readTaskMeta(teamName, taskId);
|
||||||
const logs = await this.logsFinder.findLogsForTask(teamName, taskId, {
|
const logs = await this.logsFinder.findLogsForTask(teamName, taskId, {
|
||||||
owner: options?.owner ?? taskMeta?.owner,
|
owner: options?.owner ?? taskMeta?.owner,
|
||||||
|
|
@ -124,7 +137,12 @@ export class ChangeExtractorService {
|
||||||
});
|
});
|
||||||
const logRefs = await this.resolveLogFileRefs(teamName, logs);
|
const logRefs = await this.resolveLogFileRefs(teamName, logs);
|
||||||
if (logRefs.length === 0) {
|
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);
|
const projectPath = await this.resolveProjectPath(teamName);
|
||||||
|
|
@ -162,7 +180,7 @@ export class ChangeExtractorService {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
const intervalResult: TaskChangeSetV2 = {
|
||||||
teamName,
|
teamName,
|
||||||
taskId,
|
taskId,
|
||||||
files,
|
files,
|
||||||
|
|
@ -177,9 +195,24 @@ export class ChangeExtractorService {
|
||||||
? ['No file edits found within persisted workIntervals.']
|
? ['No file edits found within persisted workIntervals.']
|
||||||
: ['Task boundaries missing — scoped by workIntervals timestamps.'],
|
: ['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
|
// Фильтруем snippets по tool_use IDs из scope
|
||||||
|
|
@ -192,7 +225,7 @@ export class ChangeExtractorService {
|
||||||
warnings.push('Some task boundaries could not be precisely determined.');
|
warnings.push('Some task boundaries could not be precisely determined.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const result: TaskChangeSetV2 = {
|
||||||
teamName,
|
teamName,
|
||||||
taskId,
|
taskId,
|
||||||
files,
|
files,
|
||||||
|
|
@ -204,6 +237,11 @@ export class ChangeExtractorService {
|
||||||
scope: allScopes[0],
|
scope: allScopes[0],
|
||||||
warnings,
|
warnings,
|
||||||
};
|
};
|
||||||
|
this.taskChangeCache.set(cacheKey, {
|
||||||
|
data: result,
|
||||||
|
expiresAt: Date.now() + this.taskChangeCacheTtl,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Получить краткую статистику */
|
/** Получить краткую статистику */
|
||||||
|
|
@ -684,14 +722,27 @@ export class ChangeExtractorService {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Конвертировать MemberLogSummary[] в LogFileRef[] через findMemberLogPaths */
|
/** Конвертировать MemberLogSummary[] в LogFileRef[] */
|
||||||
private async resolveLogFileRefs(
|
private async resolveLogFileRefs(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
logs: MemberLogSummary[]
|
logs: MemberLogSummary[]
|
||||||
): Promise<LogFileRef[]> {
|
): Promise<LogFileRef[]> {
|
||||||
const refs: LogFileRef[] = [];
|
const refs: LogFileRef[] = [];
|
||||||
const byMember = new Map<string, MemberLogSummary[]>();
|
const logsNeedingResolve: MemberLogSummary[] = [];
|
||||||
|
|
||||||
for (const log of logs) {
|
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';
|
const name = log.memberName ?? 'unknown';
|
||||||
if (!byMember.has(name)) byMember.set(name, []);
|
if (!byMember.has(name)) byMember.set(name, []);
|
||||||
byMember.get(name)!.push(log);
|
byMember.get(name)!.push(log);
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import type { InboxMessage, SendMessageRequest, SendMessageResult } from '@share
|
||||||
export class TeamInboxWriter {
|
export class TeamInboxWriter {
|
||||||
async sendMessage(teamName: string, request: SendMessageRequest): Promise<SendMessageResult> {
|
async sendMessage(teamName: string, request: SendMessageRequest): Promise<SendMessageResult> {
|
||||||
const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${request.member}.json`);
|
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) => ({
|
const attachmentMeta = request.attachments?.map((a) => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
|
|
@ -25,17 +25,20 @@ export class TeamInboxWriter {
|
||||||
from: request.from ?? 'user',
|
from: request.from ?? 'user',
|
||||||
to: request.to ?? request.member,
|
to: request.to ?? request.member,
|
||||||
text: request.text,
|
text: request.text,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: request.timestamp ?? new Date().toISOString(),
|
||||||
read: false,
|
read: false,
|
||||||
summary: request.summary,
|
summary: request.summary,
|
||||||
messageId,
|
messageId,
|
||||||
attachments: attachmentMeta?.length ? attachmentMeta : undefined,
|
attachments: attachmentMeta?.length ? attachmentMeta : undefined,
|
||||||
...(request.source && { source: request.source }),
|
...(request.source && { source: request.source }),
|
||||||
...(request.leadSessionId && { leadSessionId: request.leadSessionId }),
|
...(request.leadSessionId && { leadSessionId: request.leadSessionId }),
|
||||||
|
...(request.color && { color: request.color }),
|
||||||
...(request.conversationId && { conversationId: request.conversationId }),
|
...(request.conversationId && { conversationId: request.conversationId }),
|
||||||
...(request.replyToConversationId && {
|
...(request.replyToConversationId && {
|
||||||
replyToConversationId: request.replyToConversationId,
|
replyToConversationId: request.replyToConversationId,
|
||||||
}),
|
}),
|
||||||
|
...(request.toolSummary && { toolSummary: request.toolSummary }),
|
||||||
|
...(request.toolCalls && { toolCalls: request.toolCalls }),
|
||||||
};
|
};
|
||||||
|
|
||||||
await withFileLock(inboxPath, async () => {
|
await withFileLock(inboxPath, async () => {
|
||||||
|
|
|
||||||
|
|
@ -767,6 +767,7 @@ export class TeamMemberLogsFinder {
|
||||||
durationMs: Math.max(0, durationMs),
|
durationMs: Math.max(0, durationMs),
|
||||||
messageCount: metadata.messageCount,
|
messageCount: metadata.messageCount,
|
||||||
isOngoing,
|
isOngoing,
|
||||||
|
filePath,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -990,6 +991,7 @@ export class TeamMemberLogsFinder {
|
||||||
durationMs: Math.max(0, durationMs),
|
durationMs: Math.max(0, durationMs),
|
||||||
messageCount: metadata.messageCount,
|
messageCount: metadata.messageCount,
|
||||||
isOngoing,
|
isOngoing,
|
||||||
|
filePath: jsonlPath,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -725,11 +725,6 @@ export const LeadThoughtsGroupRow = ({
|
||||||
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||||
{thoughts.length} thoughts
|
{thoughts.length} thoughts
|
||||||
</span>
|
</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 ? (
|
{!isBodyVisible && headerTextPreview ? (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
@ -764,6 +759,11 @@ export const LeadThoughtsGroupRow = ({
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable body — live thoughts follow bottom unless user scrolls up */}
|
{/* Scrollable body — live thoughts follow bottom unless user scrolls up */}
|
||||||
|
|
|
||||||
|
|
@ -466,12 +466,17 @@ export const CodeMirrorDiffView = ({
|
||||||
|
|
||||||
// Merge toolbar: always visible for nearest chunk, follows cursor when hovering on chunk
|
// Merge toolbar: always visible for nearest chunk, follows cursor when hovering on chunk
|
||||||
if (showMergeControls) {
|
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 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;
|
const btnWidth = btnContainer.offsetWidth || 200;
|
||||||
// Position at: scrollLeft + visible width - button width - margin
|
const margin = 12;
|
||||||
btnContainer.style.left = `${scrollerEl.scrollLeft + scrollerEl.clientWidth - btnWidth - 8}px`;
|
// 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';
|
btnContainer.style.right = 'auto';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -394,11 +394,17 @@ const portionCollapseField = StateField.define<PortionCollapseState>({
|
||||||
// the visible viewport width, making `position: sticky; left: 0` actually constrain them.
|
// the visible viewport width, making `position: sticky; left: 0` actually constrain them.
|
||||||
|
|
||||||
function syncCollapseWidths(view: EditorView): void {
|
function syncCollapseWidths(view: EditorView): void {
|
||||||
const w = view.scrollDOM.clientWidth;
|
const scrollerRect = view.scrollDOM.getBoundingClientRect();
|
||||||
if (!w) return;
|
if (!scrollerRect.width) return;
|
||||||
const els = view.dom.querySelectorAll<HTMLElement>('.cm-portion-collapse');
|
const els = view.dom.querySelectorAll<HTMLElement>('.cm-portion-collapse');
|
||||||
for (const el of els) {
|
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`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -271,14 +271,19 @@ export interface SendMessageRequest {
|
||||||
text: string;
|
text: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
from?: string;
|
from?: string;
|
||||||
|
timestamp?: string;
|
||||||
|
messageId?: string;
|
||||||
/** Override the `to` field in the stored message (defaults to `member`). */
|
/** Override the `to` field in the stored message (defaults to `member`). */
|
||||||
to?: string;
|
to?: string;
|
||||||
|
color?: string;
|
||||||
attachments?: AttachmentPayload[];
|
attachments?: AttachmentPayload[];
|
||||||
source?: InboxMessage['source'];
|
source?: InboxMessage['source'];
|
||||||
/** Lead session ID for session boundary detection. */
|
/** Lead session ID for session boundary detection. */
|
||||||
leadSessionId?: string;
|
leadSessionId?: string;
|
||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
replyToConversationId?: string;
|
replyToConversationId?: string;
|
||||||
|
toolSummary?: string;
|
||||||
|
toolCalls?: ToolCallMeta[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SendMessageResult {
|
export interface SendMessageResult {
|
||||||
|
|
@ -525,6 +530,8 @@ export interface MemberLogSummaryBase {
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
messageCount: number;
|
messageCount: number;
|
||||||
isOngoing: boolean;
|
isOngoing: boolean;
|
||||||
|
/** Absolute path to JSONL file when known (avoids redundant findMemberLogPaths scan). */
|
||||||
|
filePath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MemberSubagentLogSummary extends MemberLogSummaryBase {
|
export interface MemberSubagentLogSummary extends MemberLogSummaryBase {
|
||||||
|
|
|
||||||
|
|
@ -49,11 +49,16 @@ vi.mock('crypto', async (importOriginal) => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('fs', () => ({
|
vi.mock('fs', async (importOriginal) => {
|
||||||
promises: {
|
const actual = await importOriginal<typeof import('fs')>();
|
||||||
readFile: hoisted.readFile,
|
return {
|
||||||
},
|
...actual,
|
||||||
}));
|
promises: {
|
||||||
|
...actual.promises,
|
||||||
|
readFile: hoisted.readFile,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('../../../../src/main/utils/pathDecoder', () => ({
|
vi.mock('../../../../src/main/utils/pathDecoder', () => ({
|
||||||
getTeamsBasePath: () => '/mock/teams',
|
getTeamsBasePath: () => '/mock/teams',
|
||||||
|
|
@ -63,6 +68,14 @@ vi.mock('../../../../src/main/services/team/atomicWrite', () => ({
|
||||||
atomicWriteAsync: hoisted.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';
|
import { TeamInboxWriter } from '../../../../src/main/services/team/TeamInboxWriter';
|
||||||
|
|
||||||
describe('TeamInboxWriter', () => {
|
describe('TeamInboxWriter', () => {
|
||||||
|
|
@ -144,6 +157,37 @@ describe('TeamInboxWriter', () => {
|
||||||
expect(persisted[0].source).toBe('system_notification');
|
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 () => {
|
it('omits source field from payload when not provided in request', async () => {
|
||||||
await writer.sendMessage('my-team', {
|
await writer.sendMessage('my-team', {
|
||||||
member: 'alice',
|
member: 'alice',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue