Merge origin/main into feature/assign-archetypes-to-tasks-ui

Resolved conflicts:
- .gitignore: kept both bd.sock.startlock and .beadboard/ entries
- package.json: kept feature branch test script (explicit enumeration)
- API routes: kept dynamic export + isValidProjectRoot from main
- globals.css: kept HEAD slideInFromRight animation
- use-beads-subscription.ts: kept HEAD onopen handler
- realtime.ts: kept main console.log in emit()
- snapshot-differ.ts: kept main type-aware dependency diff

Blue colors preserved from feature branch.
This commit is contained in:
openhands 2026-02-26 18:50:18 +00:00
commit a8079813b8
28 changed files with 931 additions and 70 deletions

View file

@ -1,11 +1,40 @@
import { NextResponse } from 'next/server';
import path from 'node:path';
import { activityEventBus } from '../../../lib/realtime';
export const dynamic = 'force-dynamic';
function isValidProjectRoot(root: string): boolean {
try {
const resolved = path.resolve(root);
if (!path.isAbsolute(resolved)) {
return false;
}
// Prevent path traversal by ensuring resolved path stays within the project root
const allowedBase = process.cwd();
const relative = path.relative(allowedBase, resolved);
// If "resolved" is outside "allowedBase", "relative" will start with ".."
if (relative.startsWith('..') || path.isAbsolute(relative)) {
return false;
}
return true;
} catch {
return false;
}
}
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const projectRoot = url.searchParams.get('projectRoot') || undefined;
const projectRootParam = url.searchParams.get('projectRoot');
if (projectRootParam && !isValidProjectRoot(projectRootParam)) {
return NextResponse.json(
{ error: 'Invalid projectRoot path' },
{ status: 400 }
);
}
const projectRoot = projectRootParam || undefined;
const history = activityEventBus.getHistory(projectRoot);
return Response.json(history);

View file

@ -1,17 +1,42 @@
import { NextResponse } from 'next/server';
import path from 'node:path';
import { readIssuesFromDisk } from '../../../../../lib/read-issues';
import { activityEventBus } from '../../../../../lib/realtime';
import { getAgentMetrics } from '../../../../../lib/agent-sessions';
export const dynamic = 'force-dynamic';
function isValidProjectRoot(root: string): boolean {
try {
const resolved = path.resolve(root);
if (!path.isAbsolute(resolved)) {
return false;
}
// Prevent path traversal by ensuring resolved path stays within the project root
const allowedBase = process.cwd();
const relative = path.relative(allowedBase, resolved);
// If "resolved" is outside "allowedBase", "relative" will start with ".."
if (relative.startsWith('..') || path.isAbsolute(relative)) {
return false;
}
return true;
} catch {
return false;
}
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ agentId: string }> }
): Promise<Response> {
const { agentId } = await params;
const url = new URL(request.url);
const projectRoot = url.searchParams.get('projectRoot') ?? process.cwd();
const projectRootParam = url.searchParams.get('projectRoot');
const projectRoot = projectRootParam ?? process.cwd();
if (projectRootParam && !isValidProjectRoot(projectRootParam)) {
return NextResponse.json({ ok: false, error: 'Invalid projectRoot path' }, { status: 400 });
}
try {
const issues = await readIssuesFromDisk({ projectRoot, preferBd: true });

View file

@ -1,23 +1,51 @@
import { NextResponse } from 'next/server';
import path from 'node:path';
import { readIssuesFromDisk } from '../../../../lib/read-issues';
export const dynamic = 'force-dynamic';
function isValidProjectRoot(root: string): boolean {
try {
const resolved = path.resolve(root);
if (!path.isAbsolute(resolved)) {
return false;
}
// Prevent path traversal by ensuring resolved path stays within the project root
const allowedBase = process.cwd();
const relative = path.relative(allowedBase, resolved);
// If "resolved" is outside "allowedBase", "relative" will start with ".."
if (relative.startsWith('..') || path.isAbsolute(relative)) {
return false;
}
return true;
} catch {
return false;
}
}
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const projectRoot = url.searchParams.get('projectRoot') ?? process.cwd();
const projectRootParam = url.searchParams.get('projectRoot');
const projectRoot = projectRootParam ?? process.cwd();
if (projectRootParam && !isValidProjectRoot(projectRootParam)) {
return NextResponse.json(
{ ok: false, error: { classification: 'validation', message: 'Invalid projectRoot path' } },
{ status: 400 }
);
}
try {
const issues = await readIssuesFromDisk({ projectRoot, preferBd: true });
return NextResponse.json({ ok: true, issues });
} catch (error) {
console.error('[API/BeadsRead] Failed to read issues:', error);
return NextResponse.json(
{
ok: false,
error: {
classification: 'unknown',
message: error instanceof Error ? error.message : 'Failed to read issues.',
classification: 'internal_error',
message: 'An internal error occurred while reading issues.',
},
},
{ status: 500 },

View file

@ -17,6 +17,8 @@ async function readLastTouchedVersion(filePath: string): Promise<number | null>
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
// Log non-ENOENT errors but don't swallow them silently
console.error('[Events] Failed to read last-touched version:', error);
return null;
}
}
@ -84,18 +86,27 @@ export async function GET(request: Request): Promise<Response> {
const lastTouchedPath = path.join(projectRoot, '.beads', 'last-touched');
let lastTouchedVersion: number | null = null;
let isPolling = false;
const pollLastTouched = async () => {
const nextVersion = await readLastTouchedVersion(lastTouchedPath);
if (nextVersion === null) {
if (isPolling) {
return;
}
if (lastTouchedVersion === null) {
lastTouchedVersion = nextVersion;
return;
}
if (nextVersion !== lastTouchedVersion) {
lastTouchedVersion = nextVersion;
write(toSseFrame(issuesEventBus.emit(projectRoot, lastTouchedPath, 'telemetry')));
isPolling = true;
try {
const nextVersion = await readLastTouchedVersion(lastTouchedPath);
if (nextVersion === null) {
return;
}
if (lastTouchedVersion === null) {
lastTouchedVersion = nextVersion;
return;
}
if (nextVersion !== lastTouchedVersion) {
lastTouchedVersion = nextVersion;
write(toSseFrame(issuesEventBus.emit(projectRoot, lastTouchedPath, 'telemetry')));
}
} finally {
isPolling = false;
}
};

View file

@ -1,14 +1,42 @@
import { NextResponse } from 'next/server';
import path from 'node:path';
import { readIssuesFromDisk } from '../../../lib/read-issues';
import { activityEventBus } from '../../../lib/realtime';
import { buildSessionTaskFeed, getCommunicationSummary, getAgentLivenessMap, calculateIncursions } from '../../../lib/agent-sessions';
import { listAgents } from '../../../lib/agent-registry';
function isValidProjectRoot(root: string): boolean {
try {
const resolved = path.resolve(root);
if (!path.isAbsolute(resolved)) {
return false;
}
// Prevent path traversal by ensuring resolved path stays within the project root
const allowedBase = process.cwd();
const relative = path.relative(allowedBase, resolved);
// If "resolved" is outside "allowedBase", "relative" will start with ".."
if (relative.startsWith('..') || path.isAbsolute(relative)) {
return false;
}
return true;
} catch {
return false;
}
}
export const dynamic = 'force-dynamic';
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const projectRoot = url.searchParams.get('projectRoot') ?? process.cwd();
const projectRootParam = url.searchParams.get('projectRoot');
const projectRoot = projectRootParam ?? process.cwd();
if (projectRootParam && !isValidProjectRoot(projectRoot)) {
return NextResponse.json(
{ ok: false, error: { classification: 'validation', message: 'Invalid projectRoot path' } },
{ status: 400 }
);
}
try {
const issues = await readIssuesFromDisk({ projectRoot, preferBd: true });
@ -16,7 +44,7 @@ export async function GET(request: Request): Promise<Response> {
const communication = await getCommunicationSummary();
const livenessMap = await getAgentLivenessMap(projectRoot, activity);
const incursions = await calculateIncursions();
const agentsResult = await listAgents({});
const agentsResult = await listAgents({}, { projectRoot });
const feed = buildSessionTaskFeed(issues, activity, communication, livenessMap);
@ -33,8 +61,8 @@ export async function GET(request: Request): Promise<Response> {
{
ok: false,
error: {
classification: 'unknown',
message: error instanceof Error ? error.message : 'Failed to load session feed.',
classification: 'internal_error',
message: 'An internal error occurred while loading the session feed.',
},
},
{ status: 500 },

View file

@ -392,4 +392,4 @@ body {
transform: translateX(0);
opacity: 1;
}
}
}

View file

@ -1,5 +1,6 @@
'use client';
import { useMemo } from 'react';
import { motion } from 'framer-motion';
import type { KanbanFilterOptions, KanbanStats } from '../../lib/kanban';
@ -12,6 +13,7 @@ interface KanbanControlsProps {
filters: KanbanFilterOptions;
stats: KanbanStats;
epics: BeadIssue[];
issues: BeadIssue[];
onFiltersChange: (filters: KanbanFilterOptions) => void;
onNextActionable: () => void;
nextActionableFeedback?: string | null;
@ -21,6 +23,7 @@ export function KanbanControls({
filters,
stats,
epics,
issues,
onFiltersChange,
onNextActionable,
nextActionableFeedback = null,
@ -29,12 +32,24 @@ export function KanbanControls({
'ui-field rounded-xl px-3 py-2.5 text-sm outline-none transition';
// Build bead counts map for EpicChipStrip
const beadCounts = new Map<string, number>();
for (const epic of epics) {
// Count non-epic issues that belong to this epic
const count = epic.dependencies?.filter(d => d.type === 'parent' && d.target === epic.id).length ?? 0;
beadCounts.set(epic.id, count);
}
// Count non-epic issues that have this epic as their parent
const beadCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const epic of epics) {
let count = 0;
for (const issue of issues) {
if (issue.issue_type === 'epic') continue;
const parentDep = issue.dependencies.find(d => d.type === 'parent');
const inferredParent = issue.id.includes('.') ? issue.id.split('.')[0] : null;
const parentEpicId = parentDep?.target ?? inferredParent;
if (parentEpicId === epic.id) {
count++;
}
}
counts.set(epic.id, count);
}
return counts;
}, [epics, issues]);
return (
<section className="grid gap-3">

View file

@ -230,6 +230,7 @@ export function KanbanPage({
filters={filters}
stats={stats}
epics={localIssues.filter((issue) => issue.issue_type === 'epic')}
issues={localIssues}
onFiltersChange={setFilters}
onNextActionable={handleNextActionable}
nextActionableFeedback={nextActionableFeedback}

View file

@ -1,7 +1,7 @@
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { BeadStatus } from '@/lib/types';
type BeadStatus = 'ready' | 'in_progress' | 'blocked' | 'closed';
type BadgeSize = 'sm' | 'md';
interface StatusBadgeProps {
@ -9,11 +9,14 @@ interface StatusBadgeProps {
size?: BadgeSize;
}
const STATUS_CLASSES: Record<BeadStatus, string> = {
ready: 'border-teal-500/30 bg-teal-500/15 text-teal-200',
const STATUS_CLASSES: Partial<Record<BeadStatus, string>> = {
open: 'border-teal-500/30 bg-teal-500/15 text-teal-200',
in_progress: 'border-green-500/30 bg-green-500/15 text-green-200',
blocked: 'border-amber-500/30 bg-amber-500/15 text-amber-200',
deferred: 'border-slate-500/30 bg-slate-500/15 text-slate-300',
closed: 'border-slate-500/30 bg-slate-500/15 text-slate-300',
pinned: 'border-purple-500/30 bg-purple-500/15 text-purple-200',
hooked: 'border-cyan-500/30 bg-cyan-500/15 text-cyan-200',
};
const SIZE_CLASSES: Record<BadgeSize, string> = {
@ -21,24 +24,30 @@ const SIZE_CLASSES: Record<BadgeSize, string> = {
md: 'text-xs px-2.5 py-0.5',
};
const STATUS_LABELS: Record<BeadStatus, string> = {
ready: 'Ready',
const STATUS_LABELS: Partial<Record<BeadStatus, string>> = {
open: 'Open',
in_progress: 'In Progress',
blocked: 'Blocked',
deferred: 'Deferred',
closed: 'Closed',
pinned: 'Pinned',
hooked: 'Hooked',
};
export function StatusBadge({ status, size = 'md' }: StatusBadgeProps) {
const statusClass = STATUS_CLASSES[status] || 'border-slate-500/30 bg-slate-500/15 text-slate-300';
const statusLabel = STATUS_LABELS[status] || status;
return (
<Badge
variant="outline"
className={cn(
'rounded-md border font-semibold',
STATUS_CLASSES[status],
statusClass,
SIZE_CLASSES[size]
)}
>
{STATUS_LABELS[status]}
{statusLabel}
</Badge>
);
}

View file

@ -66,7 +66,6 @@ export function useBeadsSubscription(
}, [projectRoot, onUpdate]);
useEffect(() => {
console.log('[SSE] Connecting to event source for:', projectRoot);
const source = new EventSource(`/api/events?projectRoot=${encodeURIComponent(projectRoot)}`);
source.onopen = () => {
@ -76,7 +75,7 @@ export function useBeadsSubscription(
source.onerror = (err) => {
console.error('[SSE] Connection error:', err);
};
const onIssues = (event: MessageEvent) => {
console.log('🚨 SSE ISSUES RECEIVED:', event.data);
onUpdate?.('issues');
@ -100,12 +99,12 @@ export function useBeadsSubscription(
source.addEventListener('activity', onActivity as EventListener);
return () => {
console.log('[SSE] Closing connection');
source.removeEventListener('issues', onIssues as EventListener);
source.removeEventListener('telemetry', onTelemetry as EventListener);
source.removeEventListener('activity', onActivity as EventListener);
source.close();
};
// onUpdate is intentionally excluded from deps to avoid re-subscribing on parent re-renders
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectRoot, refresh]);

View file

@ -98,6 +98,12 @@ function trimOrEmpty(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function isValidMessageId(value: string): boolean {
// Message IDs must be alphanumeric with underscores, hyphens, and colons
// This prevents path traversal attacks
return /^[a-zA-Z0-9_\-:]+$/.test(value);
}
function success<T>(command: MailCommandName, data: T): MailCommandResponse<T> {
return {
ok: true,
@ -352,6 +358,10 @@ export async function readAgentMessage(
return invalid(command, 'MESSAGE_NOT_FOUND', 'Message id is required.');
}
if (!isValidMessageId(messageId)) {
return invalid(command, 'INVALID_MESSAGE_ID', 'Message id contains invalid characters.');
}
try {
const existing = await readMessageIndex(messageId);
if (!existing) {
@ -396,6 +406,10 @@ export async function ackAgentMessage(
return invalid(command, 'MESSAGE_NOT_FOUND', 'Message id is required.');
}
if (!isValidMessageId(messageId)) {
return invalid(command, 'INVALID_MESSAGE_ID', 'Message id contains invalid characters.');
}
try {
const existing = await readMessageIndex(messageId);
if (!existing) {

View file

@ -123,7 +123,7 @@ function trimOrEmpty(value: unknown): string {
async function callBdAgentShow(beadId: string, projectRoot: string): Promise<AgentRecord | null> {
const showResult = await runBdCommand({
projectRoot,
args: ['show', beadId, '--json'],
args: ['agent', 'show', beadId, '--json'],
});
if (!showResult.success) {

View file

@ -215,6 +215,49 @@ async function readActiveReservations(): Promise<AgentReservation[]> {
}
}
// Simple mutex-based locking using a shared lock file to prevent race conditions
const LOCK_FILE_PATH = path.join(reservationsRoot(), '.lock');
async function lockActiveReservations(): Promise<void> {
// Ensure the directory exists
await fs.mkdir(path.dirname(LOCK_FILE_PATH), { recursive: true });
// Use a simple file-based mutex - create file exclusively, fail if exists
let attempts = 0;
const maxAttempts = 100;
while (attempts < maxAttempts) {
try {
await fs.writeFile(LOCK_FILE_PATH, String(process.pid), { flag: 'wx' });
return;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
// Lock file exists, wait and retry
await new Promise(resolve => setTimeout(resolve, 50));
attempts++;
continue;
}
throw error;
}
}
throw new Error('Failed to acquire lock after maximum attempts');
}
async function unlockActiveReservations(): Promise<void> {
try {
const content = await fs.readFile(LOCK_FILE_PATH, 'utf8');
// Only release if we own the lock
if (content.trim() === String(process.pid)) {
await fs.unlink(LOCK_FILE_PATH);
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
// Lock file doesn't exist, ignore
}
}
async function atomicWriteJson(filePath: string, payload: string): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
@ -317,6 +360,9 @@ export async function reserveAgentScope(
}
try {
// Acquire exclusive lock to prevent race conditions
await lockActiveReservations();
const now = deps.now ? deps.now() : new Date().toISOString();
const reservations = await readActiveReservations();
const normalizedScope = normalizePath(scope);
@ -384,6 +430,8 @@ export async function reserveAgentScope(
return success(command, created);
} catch (error) {
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to reserve scope.');
} finally {
await unlockActiveReservations();
}
}
@ -405,6 +453,9 @@ export async function releaseAgentReservation(
}
try {
// Acquire exclusive lock to prevent race conditions
await lockActiveReservations();
const now = deps.now ? deps.now() : new Date().toISOString();
const reservations = await readActiveReservations();
const normalizedScope = normalizePath(scope);
@ -436,6 +487,8 @@ export async function releaseAgentReservation(
return success(command, released);
} catch (error) {
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to release reservation.');
} finally {
await unlockActiveReservations();
}
}

View file

@ -77,7 +77,14 @@ function asNonEmptyString(value: unknown, field: string): string {
if (typeof value !== 'string' || !value.trim()) {
throw new MutationValidationError(`"${field}" is required.`);
}
return value.trim();
const trimmed = value.trim();
// Remove control characters that could cause issues in command execution
// Preserve backslashes for Windows paths and punctuation for user text
const sanitized = trimmed.replace(/[\x00-\x1f\x7f]/g, '');
if (!sanitized) {
throw new MutationValidationError(`"${field}" contains only invalid characters.`);
}
return sanitized;
}
function asOptionalString(value: unknown): string | undefined {

View file

@ -84,7 +84,7 @@ export function parseIssuesJsonl(text: string, options: ParseIssuesOptions = {})
}
// Exclude agent identities from standard mission lists
if (normalized.labels.includes('gt:agent')) {
if (!options.skipAgentFilter && normalized.labels.includes('gt:agent')) {
continue;
}

View file

@ -1,3 +1,4 @@
import path from 'node:path';
import { canonicalizeWindowsPath, windowsPathKey } from './pathing';
import type { ActivityEvent } from './activity';
@ -38,6 +39,7 @@ export class IssuesEventBus {
private nextSubscriberId = 1;
emit(projectRoot: string, changedPath?: string, kind: IssuesChangeKind = 'changed'): IssuesChangedEvent {
console.log(`[IssuesBus] Emitting event: ${kind} for project (${changedPath ? path.basename(changedPath) : 'unknown'})`);
const canonicalProjectRoot = canonicalizeWindowsPath(projectRoot);
const projectKey = windowsPathKey(canonicalProjectRoot);
console.log(`[IssuesBus] Emitting event: ${kind} for ${projectKey} (path: ${changedPath}, subscribers: ${this.subscribers.size})`);
@ -97,6 +99,7 @@ export class ActivityEventBus {
private readonly history: ActivityEvent[] = [];
private readonly MAX_HISTORY = 100;
private initialized = false;
private savePromise: Promise<void> | null = null;
private nextSubscriberId = 1;
@ -118,14 +121,30 @@ export class ActivityEventBus {
};
this.nextEventId += 1;
// Capture history snapshot BEFORE modification for persistence
const historySnapshot = [...this.history];
// Buffer history
this.history.unshift(activity);
if (this.history.length > this.MAX_HISTORY) {
this.history.pop();
}
// Persist async
void saveActivityHistory(this.history);
// Persist async with deduplication - wait for any pending save to complete
const persist = async () => {
try {
await saveActivityHistory(historySnapshot);
} catch (error) {
console.error('[ActivityEventBus] Failed to save history:', error);
}
};
if (this.savePromise === null) {
this.savePromise = persist();
} else {
// Chain to existing promise to prevent concurrent writes
this.savePromise = this.savePromise.then(persist);
}
for (const subscriber of this.subscribers.values()) {
if (!subscriber.projectKey || subscriber.projectKey === projectKey) {

View file

@ -78,7 +78,7 @@ export function diffSnapshots(
// 5. Collection Changes (Dependencies)
diffDependencies(prev.dependencies, curr.dependencies).forEach(kindAndTarget => {
events.push(createEvent(kindAndTarget.kind, curr, now, { to: kindAndTarget.target }));
events.push(createEvent(kindAndTarget.kind, curr, now, { to: kindAndTarget.target, field: kindAndTarget.type }));
});
});
@ -129,25 +129,28 @@ function areArraysEqual(a: string[], b: string[]): boolean {
/**
* Detects added and removed dependencies.
* Uses composite key `${type}:${target}` to detect type changes as well.
*/
function diffDependencies(
prev: BeadDependency[],
curr: BeadDependency[]
): { kind: 'dependency_added' | 'dependency_removed', target: string }[] {
const changes: { kind: 'dependency_added' | 'dependency_removed', target: string }[] = [];
const prevTargets = new Set(prev.map(d => d.target));
const currTargets = new Set(curr.map(d => d.target));
): { kind: 'dependency_added' | 'dependency_removed', target: string, type: string }[] {
const changes: { kind: 'dependency_added' | 'dependency_removed', target: string, type: string }[] = [];
const prevKeys = new Set(prev.map(d => `${d.type}:${d.target}`));
const currKeys = new Set(curr.map(d => `${d.type}:${d.target}`));
curr.forEach(d => {
if (!prevTargets.has(d.target)) {
changes.push({ kind: 'dependency_added', target: d.target });
const key = `${d.type}:${d.target}`;
if (!prevKeys.has(key)) {
changes.push({ kind: 'dependency_added', target: d.target, type: d.type });
}
});
prev.forEach(d => {
if (!currTargets.has(d.target)) {
changes.push({ kind: 'dependency_removed', target: d.target });
const key = `${d.type}:${d.target}`;
if (!currKeys.has(key)) {
changes.push({ kind: 'dependency_removed', target: d.target, type: d.type });
}
});

View file

@ -19,6 +19,11 @@ function getGlobalAgentMessagesPath(): string {
interface WatchRegistration {
projectRoot: string;
watcher: FSWatcher;
handlers?: {
onAdd: (changedPath: string) => void;
onChange: (changedPath: string) => void;
onUnlink: (changedPath: string) => void;
};
}
export interface WatchManagerOptions {
@ -152,13 +157,19 @@ export class IssuesWatchManager {
this.queueCoalescedEvent(projectRoot, changedPath, kind);
};
watcher.on('add', (changedPath) => onFileEvent('add', changedPath));
watcher.on('change', (changedPath) => onFileEvent('change', changedPath));
watcher.on('unlink', (changedPath) => onFileEvent('unlink', changedPath));
// Store references to event handlers for proper cleanup
const onAdd = (changedPath: string) => onFileEvent('add', changedPath);
const onChange = (changedPath: string) => onFileEvent('change', changedPath);
const onUnlink = (changedPath: string) => onFileEvent('unlink', changedPath);
watcher.on('add', onAdd);
watcher.on('change', onChange);
watcher.on('unlink', onUnlink);
this.registrations.set(projectKey, {
projectRoot,
watcher,
handlers: { onAdd, onChange, onUnlink },
});
}
@ -170,6 +181,14 @@ export class IssuesWatchManager {
}
this.coalescer.cancel(projectRoot);
// Explicitly remove event listeners before closing to prevent memory leaks
if (registration.handlers) {
registration.watcher.removeListener('add', registration.handlers.onAdd);
registration.watcher.removeListener('change', registration.handlers.onChange);
registration.watcher.removeListener('unlink', registration.handlers.onUnlink);
}
this.registrations.delete(projectKey);
await registration.watcher.close();
}
@ -178,6 +197,12 @@ export class IssuesWatchManager {
const closeOps: Promise<void>[] = [];
for (const registration of this.registrations.values()) {
// Explicitly remove event listeners before closing to prevent memory leaks
if (registration.handlers) {
registration.watcher.removeListener('add', registration.handlers.onAdd);
registration.watcher.removeListener('change', registration.handlers.onChange);
registration.watcher.removeListener('unlink', registration.handlers.onUnlink);
}
closeOps.push(registration.watcher.close());
}