From f65baebb3ca37a21a9d7edb5f9a645b860593cfa Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Sat, 4 Apr 2026 03:37:55 +0800 Subject: [PATCH] Reduce swarm local persistence --- src/hooks/useInboxPoller.ts | 16 +- src/tools/AgentTool/agentMemorySnapshot.ts | 197 --------------------- src/tools/AgentTool/loadAgentsDir.ts | 41 +---- src/tools/TeamCreateTool/TeamCreateTool.ts | 3 - src/tools/shared/spawnMultiAgent.ts | 6 - src/utils/swarm/teamHelpers.ts | 58 +++++- src/utils/teammateMailbox.ts | 96 ++-------- 7 files changed, 83 insertions(+), 334 deletions(-) delete mode 100644 src/tools/AgentTool/agentMemorySnapshot.ts diff --git a/src/hooks/useInboxPoller.ts b/src/hooks/useInboxPoller.ts index 361ba63..70bc76f 100644 --- a/src/hooks/useInboxPoller.ts +++ b/src/hooks/useInboxPoller.ts @@ -59,7 +59,7 @@ import { isShutdownApproved, isShutdownRequest, isTeamPermissionUpdate, - markMessagesAsRead, + markMessagesAsReadByPredicate, readUnreadMessages, type TeammateMessage, writeToMailbox, @@ -195,10 +195,20 @@ export function useInboxPoller({ } } - // Helper to mark messages as read in the inbox file. + // Helper to remove the unread batch we just processed from the inbox file. // Called after messages are successfully delivered or reliably queued. + const deliveredMessageKeys = new Set( + unread.map(message => `${message.from}|${message.timestamp}|${message.text}`), + ) const markRead = () => { - void markMessagesAsRead(agentName, currentAppState.teamContext?.teamName) + void markMessagesAsReadByPredicate( + agentName, + message => + deliveredMessageKeys.has( + `${message.from}|${message.timestamp}|${message.text}`, + ), + currentAppState.teamContext?.teamName, + ) } // Separate permission messages from regular teammate messages diff --git a/src/tools/AgentTool/agentMemorySnapshot.ts b/src/tools/AgentTool/agentMemorySnapshot.ts deleted file mode 100644 index 4435292..0000000 --- a/src/tools/AgentTool/agentMemorySnapshot.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { mkdir, readdir, readFile, unlink, writeFile } from 'fs/promises' -import { join } from 'path' -import { z } from 'zod/v4' -import { getCwd } from '../../utils/cwd.js' -import { logForDebugging } from '../../utils/debug.js' -import { lazySchema } from '../../utils/lazySchema.js' -import { jsonParse, jsonStringify } from '../../utils/slowOperations.js' -import { type AgentMemoryScope, getAgentMemoryDir } from './agentMemory.js' - -const SNAPSHOT_BASE = 'agent-memory-snapshots' -const SNAPSHOT_JSON = 'snapshot.json' -const SYNCED_JSON = '.snapshot-synced.json' - -const snapshotMetaSchema = lazySchema(() => - z.object({ - updatedAt: z.string().min(1), - }), -) - -const syncedMetaSchema = lazySchema(() => - z.object({ - syncedFrom: z.string().min(1), - }), -) -type SyncedMeta = z.infer> - -/** - * Returns the path to the snapshot directory for an agent in the current project. - * e.g., /.claude/agent-memory-snapshots// - */ -export function getSnapshotDirForAgent(agentType: string): string { - return join(getCwd(), '.claude', SNAPSHOT_BASE, agentType) -} - -function getSnapshotJsonPath(agentType: string): string { - return join(getSnapshotDirForAgent(agentType), SNAPSHOT_JSON) -} - -function getSyncedJsonPath(agentType: string, scope: AgentMemoryScope): string { - return join(getAgentMemoryDir(agentType, scope), SYNCED_JSON) -} - -async function readJsonFile( - path: string, - schema: z.ZodType, -): Promise { - try { - const content = await readFile(path, { encoding: 'utf-8' }) - const result = schema.safeParse(jsonParse(content)) - return result.success ? result.data : null - } catch { - return null - } -} - -async function copySnapshotToLocal( - agentType: string, - scope: AgentMemoryScope, -): Promise { - const snapshotMemDir = getSnapshotDirForAgent(agentType) - const localMemDir = getAgentMemoryDir(agentType, scope) - - await mkdir(localMemDir, { recursive: true }) - - try { - const files = await readdir(snapshotMemDir, { withFileTypes: true }) - for (const dirent of files) { - if (!dirent.isFile() || dirent.name === SNAPSHOT_JSON) continue - const content = await readFile(join(snapshotMemDir, dirent.name), { - encoding: 'utf-8', - }) - await writeFile(join(localMemDir, dirent.name), content) - } - } catch (e) { - logForDebugging(`Failed to copy snapshot to local agent memory: ${e}`) - } -} - -async function saveSyncedMeta( - agentType: string, - scope: AgentMemoryScope, - snapshotTimestamp: string, -): Promise { - const syncedPath = getSyncedJsonPath(agentType, scope) - const localMemDir = getAgentMemoryDir(agentType, scope) - await mkdir(localMemDir, { recursive: true }) - const meta: SyncedMeta = { syncedFrom: snapshotTimestamp } - try { - await writeFile(syncedPath, jsonStringify(meta)) - } catch (e) { - logForDebugging(`Failed to save snapshot sync metadata: ${e}`) - } -} - -/** - * Check if a snapshot exists and whether it's newer than what we last synced. - */ -export async function checkAgentMemorySnapshot( - agentType: string, - scope: AgentMemoryScope, -): Promise<{ - action: 'none' | 'initialize' | 'prompt-update' - snapshotTimestamp?: string -}> { - const snapshotMeta = await readJsonFile( - getSnapshotJsonPath(agentType), - snapshotMetaSchema(), - ) - - if (!snapshotMeta) { - return { action: 'none' } - } - - const localMemDir = getAgentMemoryDir(agentType, scope) - - let hasLocalMemory = false - try { - const dirents = await readdir(localMemDir, { withFileTypes: true }) - hasLocalMemory = dirents.some(d => d.isFile() && d.name.endsWith('.md')) - } catch { - // Directory doesn't exist - } - - if (!hasLocalMemory) { - return { action: 'initialize', snapshotTimestamp: snapshotMeta.updatedAt } - } - - const syncedMeta = await readJsonFile( - getSyncedJsonPath(agentType, scope), - syncedMetaSchema(), - ) - - if ( - !syncedMeta || - new Date(snapshotMeta.updatedAt) > new Date(syncedMeta.syncedFrom) - ) { - return { - action: 'prompt-update', - snapshotTimestamp: snapshotMeta.updatedAt, - } - } - - return { action: 'none' } -} - -/** - * Initialize local agent memory from a snapshot (first-time setup). - */ -export async function initializeFromSnapshot( - agentType: string, - scope: AgentMemoryScope, - snapshotTimestamp: string, -): Promise { - logForDebugging( - `Initializing agent memory for ${agentType} from project snapshot`, - ) - await copySnapshotToLocal(agentType, scope) - await saveSyncedMeta(agentType, scope, snapshotTimestamp) -} - -/** - * Replace local agent memory with the snapshot. - */ -export async function replaceFromSnapshot( - agentType: string, - scope: AgentMemoryScope, - snapshotTimestamp: string, -): Promise { - logForDebugging( - `Replacing agent memory for ${agentType} with project snapshot`, - ) - // Remove existing .md files before copying to avoid orphans - const localMemDir = getAgentMemoryDir(agentType, scope) - try { - const existing = await readdir(localMemDir, { withFileTypes: true }) - for (const dirent of existing) { - if (dirent.isFile() && dirent.name.endsWith('.md')) { - await unlink(join(localMemDir, dirent.name)) - } - } - } catch { - // Directory may not exist yet - } - await copySnapshotToLocal(agentType, scope) - await saveSyncedMeta(agentType, scope, snapshotTimestamp) -} - -/** - * Mark the current snapshot as synced without changing local memory. - */ -export async function markSnapshotSynced( - agentType: string, - scope: AgentMemoryScope, - snapshotTimestamp: string, -): Promise { - await saveSyncedMeta(agentType, scope, snapshotTimestamp) -} diff --git a/src/tools/AgentTool/loadAgentsDir.ts b/src/tools/AgentTool/loadAgentsDir.ts index cb4dc35..53399ff 100644 --- a/src/tools/AgentTool/loadAgentsDir.ts +++ b/src/tools/AgentTool/loadAgentsDir.ts @@ -47,10 +47,6 @@ import { setAgentColor, } from './agentColorManager.js' import { type AgentMemoryScope, loadAgentMemoryPrompt } from './agentMemory.js' -import { - checkAgentMemorySnapshot, - initializeFromSnapshot, -} from './agentMemorySnapshot.js' import { getBuiltInAgents } from './builtInAgents.js' // Type for MCP server specification in agent definitions @@ -255,41 +251,14 @@ export function filterAgentsByMcpRequirements( } /** - * Check for and initialize agent memory from project snapshots. - * For agents with memory enabled, copies snapshot to local if no local memory exists. - * For agents with newer snapshots, logs a debug message (user prompt TODO). + * Agent memory snapshot sync is disabled in this fork to avoid copying + * project-scoped memory into persistent user/local agent memory. */ async function initializeAgentMemorySnapshots( - agents: CustomAgentDefinition[], + _agents: CustomAgentDefinition[], ): Promise { - await Promise.all( - agents.map(async agent => { - if (agent.memory !== 'user') return - const result = await checkAgentMemorySnapshot( - agent.agentType, - agent.memory, - ) - switch (result.action) { - case 'initialize': - logForDebugging( - `Initializing ${agent.agentType} memory from project snapshot`, - ) - await initializeFromSnapshot( - agent.agentType, - agent.memory, - result.snapshotTimestamp!, - ) - break - case 'prompt-update': - agent.pendingSnapshotUpdate = { - snapshotTimestamp: result.snapshotTimestamp!, - } - logForDebugging( - `Newer snapshot available for ${agent.agentType} memory (snapshot: ${result.snapshotTimestamp})`, - ) - break - } - }), + logForDebugging( + '[loadAgentsDir] Agent memory snapshot sync is disabled in this build', ) } diff --git a/src/tools/TeamCreateTool/TeamCreateTool.ts b/src/tools/TeamCreateTool/TeamCreateTool.ts index 64a8018..d1ff36c 100644 --- a/src/tools/TeamCreateTool/TeamCreateTool.ts +++ b/src/tools/TeamCreateTool/TeamCreateTool.ts @@ -1,5 +1,4 @@ import { z } from 'zod/v4' -import { getSessionId } from '../../bootstrap/state.js' import { logEvent } from '../../services/analytics/index.js' import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/metadata.js' import type { Tool } from '../../Tool.js' @@ -159,7 +158,6 @@ export const TeamCreateTool: Tool = buildTool({ description: _description, createdAt: Date.now(), leadAgentId, - leadSessionId: getSessionId(), // Store actual session ID for team discovery members: [ { agentId: leadAgentId, @@ -169,7 +167,6 @@ export const TeamCreateTool: Tool = buildTool({ joinedAt: Date.now(), tmuxPaneId: '', cwd: getCwd(), - subscriptions: [], }, ], } diff --git a/src/tools/shared/spawnMultiAgent.ts b/src/tools/shared/spawnMultiAgent.ts index dc7c8dd..ba69e86 100644 --- a/src/tools/shared/spawnMultiAgent.ts +++ b/src/tools/shared/spawnMultiAgent.ts @@ -497,13 +497,11 @@ async function handleSpawnSplitPane( name: sanitizedName, agentType: agent_type, model, - prompt, color: teammateColor, planModeRequired: plan_mode_required, joinedAt: Date.now(), tmuxPaneId: paneId, cwd: workingDir, - subscriptions: [], backendType: detectionResult.backend.type, }) await writeTeamFileAsync(teamName, teamFile) @@ -711,13 +709,11 @@ async function handleSpawnSeparateWindow( name: sanitizedName, agentType: agent_type, model, - prompt, color: teammateColor, planModeRequired: plan_mode_required, joinedAt: Date.now(), tmuxPaneId: paneId, cwd: workingDir, - subscriptions: [], backendType: 'tmux', // This handler always uses tmux directly }) await writeTeamFileAsync(teamName, teamFile) @@ -997,13 +993,11 @@ async function handleSpawnInProcess( name: sanitizedName, agentType: agent_type, model, - prompt, color: teammateColor, planModeRequired: plan_mode_required, joinedAt: Date.now(), tmuxPaneId: 'in-process', cwd: getCwd(), - subscriptions: [], backendType: 'in-process', }) await writeTeamFileAsync(teamName, teamFile) diff --git a/src/utils/swarm/teamHelpers.ts b/src/utils/swarm/teamHelpers.ts index 66508fd..d7d4514 100644 --- a/src/utils/swarm/teamHelpers.ts +++ b/src/utils/swarm/teamHelpers.ts @@ -66,7 +66,7 @@ export type TeamFile = { description?: string createdAt: number leadAgentId: string - leadSessionId?: string // Actual session UUID of the leader (for discovery) + leadSessionId?: string // Legacy field; stripped from persisted configs hiddenPaneIds?: string[] // Pane IDs that are currently hidden from the UI teamAllowedPaths?: TeamAllowedPath[] // Paths all teammates can edit without asking members: Array<{ @@ -74,15 +74,15 @@ export type TeamFile = { name: string agentType?: string model?: string - prompt?: string + prompt?: string // Legacy field; stripped from persisted configs color?: string planModeRequired?: boolean joinedAt: number tmuxPaneId: string cwd: string worktreePath?: string - sessionId?: string - subscriptions: string[] + sessionId?: string // Legacy field; stripped from persisted configs + subscriptions?: string[] // Legacy field; stripped from persisted configs backendType?: BackendType isActive?: boolean // false when idle, undefined/true when active mode?: PermissionMode // Current permission mode for this teammate @@ -123,6 +123,42 @@ export function getTeamFilePath(teamName: string): string { return join(getTeamDir(teamName), 'config.json') } +function sanitizeTeamFileForPersistence(teamFile: TeamFile): TeamFile { + return { + name: teamFile.name, + ...(teamFile.description ? { description: teamFile.description } : {}), + createdAt: teamFile.createdAt, + leadAgentId: teamFile.leadAgentId, + ...(teamFile.hiddenPaneIds && teamFile.hiddenPaneIds.length > 0 + ? { hiddenPaneIds: [...teamFile.hiddenPaneIds] } + : {}), + ...(teamFile.teamAllowedPaths && teamFile.teamAllowedPaths.length > 0 + ? { + teamAllowedPaths: teamFile.teamAllowedPaths.map(path => ({ + ...path, + })), + } + : {}), + members: teamFile.members.map(member => ({ + agentId: member.agentId, + name: member.name, + ...(member.agentType ? { agentType: member.agentType } : {}), + ...(member.model ? { model: member.model } : {}), + ...(member.color ? { color: member.color } : {}), + ...(member.planModeRequired !== undefined + ? { planModeRequired: member.planModeRequired } + : {}), + joinedAt: member.joinedAt, + tmuxPaneId: member.tmuxPaneId, + cwd: member.cwd, + ...(member.worktreePath ? { worktreePath: member.worktreePath } : {}), + ...(member.backendType ? { backendType: member.backendType } : {}), + ...(member.isActive !== undefined ? { isActive: member.isActive } : {}), + ...(member.mode ? { mode: member.mode } : {}), + })), + } +} + /** * Reads a team file by name (sync — for sync contexts like React render paths) * @internal Exported for team discovery UI @@ -131,7 +167,7 @@ export function getTeamFilePath(teamName: string): string { export function readTeamFile(teamName: string): TeamFile | null { try { const content = readFileSync(getTeamFilePath(teamName), 'utf-8') - return jsonParse(content) as TeamFile + return sanitizeTeamFileForPersistence(jsonParse(content) as TeamFile) } catch (e) { if (getErrnoCode(e) === 'ENOENT') return null logForDebugging( @@ -149,7 +185,7 @@ export async function readTeamFileAsync( ): Promise { try { const content = await readFile(getTeamFilePath(teamName), 'utf-8') - return jsonParse(content) as TeamFile + return sanitizeTeamFileForPersistence(jsonParse(content) as TeamFile) } catch (e) { if (getErrnoCode(e) === 'ENOENT') return null logForDebugging( @@ -166,7 +202,10 @@ export async function readTeamFileAsync( function writeTeamFile(teamName: string, teamFile: TeamFile): void { const teamDir = getTeamDir(teamName) mkdirSync(teamDir, { recursive: true }) - writeFileSync(getTeamFilePath(teamName), jsonStringify(teamFile, null, 2)) + writeFileSync( + getTeamFilePath(teamName), + jsonStringify(sanitizeTeamFileForPersistence(teamFile), null, 2), + ) } /** @@ -178,7 +217,10 @@ export async function writeTeamFileAsync( ): Promise { const teamDir = getTeamDir(teamName) await mkdir(teamDir, { recursive: true }) - await writeFile(getTeamFilePath(teamName), jsonStringify(teamFile, null, 2)) + await writeFile( + getTeamFilePath(teamName), + jsonStringify(sanitizeTeamFileForPersistence(teamFile), null, 2), + ) } /** diff --git a/src/utils/teammateMailbox.ts b/src/utils/teammateMailbox.ts index d49b06b..227de15 100644 --- a/src/utils/teammateMailbox.ts +++ b/src/utils/teammateMailbox.ts @@ -15,7 +15,6 @@ import { PermissionModeSchema } from '../entrypoints/sdk/coreSchemas.js' import { SEND_MESSAGE_TOOL_NAME } from '../tools/SendMessageTool/constants.js' import type { Message } from '../types/message.js' import { generateRequestId } from './agentId.js' -import { count } from './array.js' import { logForDebugging } from './debug.js' import { getTeamsDir } from './envUtils.js' import { getErrnoCode } from './errors.js' @@ -192,8 +191,8 @@ export async function writeToMailbox( } /** - * Mark a specific message in a teammate's inbox as read by index - * Uses file locking to prevent race conditions + * Remove a specific processed message from a teammate's inbox by index. + * Uses file locking to prevent race conditions. * @param agentName - The agent name to mark message as read for * @param teamName - Optional team name * @param messageIndex - Index of the message to mark as read @@ -242,11 +241,17 @@ export async function markMessageAsReadByIndex( return } - messages[messageIndex] = { ...message, read: true } + const updatedMessages = messages.filter( + (currentMessage, index) => index !== messageIndex && !currentMessage.read, + ) - await writeFile(inboxPath, jsonStringify(messages, null, 2), 'utf-8') + await writeFile( + inboxPath, + jsonStringify(updatedMessages, null, 2), + 'utf-8', + ) logForDebugging( - `[TeammateMailbox] markMessageAsReadByIndex: marked message at index ${messageIndex} as read`, + `[TeammateMailbox] markMessageAsReadByIndex: removed message at index ${messageIndex} from inbox`, ) } catch (error) { const code = getErrnoCode(error) @@ -270,77 +275,6 @@ export async function markMessageAsReadByIndex( } } -/** - * Mark all messages in a teammate's inbox as read - * Uses file locking to prevent race conditions - * @param agentName - The agent name to mark messages as read for - * @param teamName - Optional team name - */ -export async function markMessagesAsRead( - agentName: string, - teamName?: string, -): Promise { - const inboxPath = getInboxPath(agentName, teamName) - logForDebugging( - `[TeammateMailbox] markMessagesAsRead called: agentName=${agentName}, teamName=${teamName}, path=${inboxPath}`, - ) - - const lockFilePath = `${inboxPath}.lock` - - let release: (() => Promise) | undefined - try { - logForDebugging(`[TeammateMailbox] markMessagesAsRead: acquiring lock...`) - release = await lockfile.lock(inboxPath, { - lockfilePath: lockFilePath, - ...LOCK_OPTIONS, - }) - logForDebugging(`[TeammateMailbox] markMessagesAsRead: lock acquired`) - - // Re-read messages after acquiring lock to get the latest state - const messages = await readMailbox(agentName, teamName) - logForDebugging( - `[TeammateMailbox] markMessagesAsRead: read ${messages.length} messages after lock`, - ) - - if (messages.length === 0) { - logForDebugging( - `[TeammateMailbox] markMessagesAsRead: no messages to mark`, - ) - return - } - - const unreadCount = count(messages, m => !m.read) - logForDebugging( - `[TeammateMailbox] markMessagesAsRead: ${unreadCount} unread of ${messages.length} total`, - ) - - // messages comes from jsonParse — fresh, unshared objects safe to mutate - for (const m of messages) m.read = true - - await writeFile(inboxPath, jsonStringify(messages, null, 2), 'utf-8') - logForDebugging( - `[TeammateMailbox] markMessagesAsRead: WROTE ${unreadCount} message(s) as read to ${inboxPath}`, - ) - } catch (error) { - const code = getErrnoCode(error) - if (code === 'ENOENT') { - logForDebugging( - `[TeammateMailbox] markMessagesAsRead: file does not exist at ${inboxPath}`, - ) - return - } - logForDebugging( - `[TeammateMailbox] markMessagesAsRead FAILED for ${agentName}: ${error}`, - ) - logError(error) - } finally { - if (release) { - await release() - logForDebugging(`[TeammateMailbox] markMessagesAsRead: lock released`) - } - } -} - /** * Clear a teammate's inbox (delete all messages) * @param agentName - The agent name to clear inbox for @@ -1095,8 +1029,8 @@ export function isStructuredProtocolMessage(messageText: string): boolean { } /** - * Marks only messages matching a predicate as read, leaving others unread. - * Uses the same file-locking mechanism as markMessagesAsRead. + * Removes only messages matching a predicate, leaving the rest unread. + * Uses the same file-locking mechanism as the other mailbox update helpers. */ export async function markMessagesAsReadByPredicate( agentName: string, @@ -1119,8 +1053,8 @@ export async function markMessagesAsReadByPredicate( return } - const updatedMessages = messages.map(m => - !m.read && predicate(m) ? { ...m, read: true } : m, + const updatedMessages = messages.filter( + m => !m.read && !predicate(m), ) await writeFile(inboxPath, jsonStringify(updatedMessages, null, 2), 'utf-8')