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;
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue