Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 8 additions & 9 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -598,24 +598,23 @@ wss.on('connection', (ws: WebSocket) => {

// Subscribe to all events and forward to this client
const unsubscribe = events.subscribe((type, payload) => {
logger.info('Event received:', {
// Use debug level for high-frequency events to avoid log spam
// that causes progressive memory growth and server slowdown
const isHighFrequency =
type === 'dev-server:output' || type === 'test-runner:output' || type === 'feature:progress';
const log = isHighFrequency ? logger.debug.bind(logger) : logger.info.bind(logger);

log('Event received:', {
type,
hasPayload: !!payload,
payloadKeys: payload ? Object.keys(payload) : [],
wsReadyState: ws.readyState,
wsOpen: ws.readyState === WebSocket.OPEN,
});

if (ws.readyState === WebSocket.OPEN) {
const message = JSON.stringify({ type, payload });
logger.info('Sending event to client:', {
type,
messageLength: message.length,
sessionId: (payload as Record<string, unknown>)?.sessionId,
});
ws.send(message);
} else {
logger.info('WARNING: Cannot send event, WebSocket not open. ReadyState:', ws.readyState);
logger.warn('Cannot send event, WebSocket not open. ReadyState:', ws.readyState);
}
});

Expand Down
10 changes: 7 additions & 3 deletions apps/server/src/services/dev-server-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,13 @@ const PORT_PATTERNS: Array<{ pattern: RegExp; description: string }> = [
},
];

// Throttle output to prevent overwhelming WebSocket under heavy load
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive feedback
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency
// Throttle output to prevent overwhelming WebSocket under heavy load.
// 100ms (~10fps) is sufficient for readable log streaming while keeping
// WebSocket traffic manageable. The previous 4ms rate (~250fps) generated
// up to 250 events/sec which caused progressive browser slowdown from
// accumulated console logs, JSON serialization overhead, and React re-renders.
const OUTPUT_THROTTLE_MS = 100; // ~10fps max update rate
const OUTPUT_BATCH_SIZE = 8192; // Larger batches to compensate for lower frequency

export interface DevServerInfo {
worktreePath: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ describe('DevServerService Event Types', () => {

// 2. Output & URL Detected
mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:5173/\n'));
// Throttled output needs a bit of time
await new Promise((resolve) => setTimeout(resolve, 100));
// Throttled output needs a bit of time (OUTPUT_THROTTLE_MS is 100ms)
await new Promise((resolve) => setTimeout(resolve, 250));
expect(emittedEvents['dev-server:output'].length).toBeGreaterThanOrEqual(1);
expect(emittedEvents['dev-server:url-detected'].length).toBe(1);
expect(emittedEvents['dev-server:url-detected'][0].url).toBe('http://localhost:5173/');
Expand Down
83 changes: 31 additions & 52 deletions apps/ui/src/components/views/board-view/hooks/use-board-actions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
// @ts-nocheck - feature update logic with partial updates and image/file handling
import { useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
Feature,
FeatureImage,
Expand All @@ -18,7 +17,10 @@ import { useVerifyFeature, useResumeFeature } from '@/hooks/mutations';
import { truncateDescription } from '@/lib/utils';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { createLogger } from '@automaker/utils/logger';
import { queryKeys } from '@/lib/query-keys';
import {
markFeatureTransitioning,
unmarkFeatureTransitioning,
} from '@/lib/feature-transition-state';

const logger = createLogger('BoardActions');

Expand Down Expand Up @@ -116,16 +118,13 @@ export function useBoardActions({
currentWorktreeBranch,
stopFeature,
}: UseBoardActionsProps) {
const queryClient = useQueryClient();

// IMPORTANT: Use individual selectors instead of bare useAppStore() to prevent
// subscribing to the entire store. Bare useAppStore() causes the host component
// (BoardView) to re-render on EVERY store change, which cascades through effects
// and triggers React error #185 (maximum update depth exceeded).
const addFeature = useAppStore((s) => s.addFeature);
const updateFeature = useAppStore((s) => s.updateFeature);
const removeFeature = useAppStore((s) => s.removeFeature);
const moveFeature = useAppStore((s) => s.moveFeature);
const worktreesEnabled = useAppStore((s) => s.useWorktrees);
const enableDependencyBlocking = useAppStore((s) => s.enableDependencyBlocking);
const skipVerificationInAutoMode = useAppStore((s) => s.skipVerificationInAutoMode);
Expand Down Expand Up @@ -707,8 +706,7 @@ export function useBoardActions({
try {
const result = await verifyFeatureMutation.mutateAsync(feature.id);
if (result.passes) {
// Immediately move card to verified column (optimistic update)
moveFeature(feature.id, 'verified');
// persistFeatureUpdate handles the optimistic RQ cache update internally
persistFeatureUpdate(feature.id, {
status: 'verified',
justFinishedAt: undefined,
Expand All @@ -725,7 +723,7 @@ export function useBoardActions({
// Error toast is already shown by the mutation's onError handler
}
},
[currentProject, verifyFeatureMutation, moveFeature, persistFeatureUpdate]
[currentProject, verifyFeatureMutation, persistFeatureUpdate]
);

const handleResumeFeature = useCallback(
Expand All @@ -742,7 +740,6 @@ export function useBoardActions({

const handleManualVerify = useCallback(
(feature: Feature) => {
moveFeature(feature.id, 'verified');
persistFeatureUpdate(feature.id, {
status: 'verified',
justFinishedAt: undefined,
Expand All @@ -751,7 +748,7 @@ export function useBoardActions({
description: `Marked as verified: ${truncateDescription(feature.description)}`,
});
},
[moveFeature, persistFeatureUpdate]
[persistFeatureUpdate]
);

const handleMoveBackToInProgress = useCallback(
Expand All @@ -760,13 +757,12 @@ export function useBoardActions({
status: 'in_progress' as const,
startedAt: new Date().toISOString(),
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
toast.info('Feature moved back', {
description: `Moved back to In Progress: ${truncateDescription(feature.description)}`,
});
},
[updateFeature, persistFeatureUpdate]
[persistFeatureUpdate]
);

const handleOpenFollowUp = useCallback(
Expand Down Expand Up @@ -885,7 +881,6 @@ export function useBoardActions({
);

if (result.success) {
moveFeature(feature.id, 'verified');
persistFeatureUpdate(feature.id, { status: 'verified' });
toast.success('Feature committed', {
description: `Committed and verified: ${truncateDescription(feature.description)}`,
Expand All @@ -907,7 +902,7 @@ export function useBoardActions({
await loadFeatures();
}
},
[currentProject, moveFeature, persistFeatureUpdate, loadFeatures, onWorktreeCreated]
[currentProject, persistFeatureUpdate, loadFeatures, onWorktreeCreated]
);

const handleMergeFeature = useCallback(
Expand Down Expand Up @@ -951,17 +946,12 @@ export function useBoardActions({

const handleCompleteFeature = useCallback(
(feature: Feature) => {
const updates = {
status: 'completed' as const,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);

persistFeatureUpdate(feature.id, { status: 'completed' as const });
toast.success('Feature completed', {
description: `Archived: ${truncateDescription(feature.description)}`,
});
},
[updateFeature, persistFeatureUpdate]
[persistFeatureUpdate]
Comment on lines +949 to +954
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Success toasts can be incorrect when persistence fails.

handleCompleteFeature and handleUnarchiveFeature show success immediately after calling persistFeatureUpdate, but that persistence path can rollback on API failure. This can leave users seeing a success toast even when the status change did not stick. Gate success notifications on confirmed persistence outcome (e.g., make persistFeatureUpdate return a success flag/result and await it here).

Also applies to: 971-983

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ui/src/components/views/board-view/hooks/use-board-actions.ts` around
lines 949 - 954, The success toasts in handleCompleteFeature and
handleUnarchiveFeature are shown immediately without confirming persistence;
change these handlers to await the result from persistFeatureUpdate (update
persistFeatureUpdate to return a success boolean or result if it doesn't
already), and only call toast.success (using truncateDescription for the
message) when the awaited result indicates success; if persistence fails, show a
toast.error or appropriate failure feedback instead and handle any rollback
logic. Ensure you update both handleCompleteFeature and handleUnarchiveFeature
to await persistFeatureUpdate and gate toast.success on the returned success
flag.

);

const handleUnarchiveFeature = useCallback(
Expand All @@ -978,11 +968,7 @@ export function useBoardActions({
(projectPath ? isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch) : true)
: featureBranch === currentWorktreeBranch;

const updates: Partial<Feature> = {
status: 'verified' as const,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
persistFeatureUpdate(feature.id, { status: 'verified' as const });

if (willBeVisibleOnCurrentView) {
toast.success('Feature restored', {
Expand All @@ -994,13 +980,7 @@ export function useBoardActions({
});
}
},
[
updateFeature,
persistFeatureUpdate,
currentWorktreeBranch,
projectPath,
isPrimaryWorktreeBranch,
]
[persistFeatureUpdate, currentWorktreeBranch, projectPath, isPrimaryWorktreeBranch]
);

const handleViewOutput = useCallback(
Expand Down Expand Up @@ -1031,6 +1011,13 @@ export function useBoardActions({

const handleForceStopFeature = useCallback(
async (feature: Feature) => {
// Mark this feature as transitioning so WebSocket-driven query invalidation
// (useAutoModeQueryInvalidation) skips redundant cache invalidations while
// persistFeatureUpdate is handling the optimistic update. Without this guard,
// auto_mode_error / auto_mode_stopped WS events race with the optimistic
// update and cause cache flip-flops that cascade through useBoardColumnFeatures,
// triggering React error #185 on mobile.
markFeatureTransitioning(feature.id);
try {
await stopFeature(feature.id);

Expand All @@ -1048,25 +1035,11 @@ export function useBoardActions({
removeRunningTaskFromAllWorktrees(currentProject.id, feature.id);
}

// Optimistically update the React Query features cache so the board
// moves the card immediately. Without this, the card stays in
// "in_progress" until the next poll cycle (30s) because the async
// refetch races with the persistFeatureUpdate write.
if (currentProject) {
queryClient.setQueryData(
queryKeys.features.all(currentProject.path),
(oldFeatures: Feature[] | undefined) => {
if (!oldFeatures) return oldFeatures;
return oldFeatures.map((f) =>
f.id === feature.id ? { ...f, status: targetStatus } : f
);
}
);
}

if (targetStatus !== feature.status) {
moveFeature(feature.id, targetStatus);
// Must await to ensure file is written before user can restart
// persistFeatureUpdate handles the optimistic RQ cache update, the
// Zustand store update (on server response), and the final cache
// invalidation internally — no need for separate queryClient.setQueryData
// or moveFeature calls which would cause redundant re-renders.
await persistFeatureUpdate(feature.id, { status: targetStatus });
}

Expand All @@ -1083,9 +1056,15 @@ export function useBoardActions({
toast.error('Failed to stop agent', {
description: error instanceof Error ? error.message : 'An error occurred',
});
} finally {
// Delay unmarking so the refetch triggered by persistFeatureUpdate's
// invalidateQueries() has time to settle before WS-driven invalidations
// are allowed through again. Without this, a WS event arriving during
// the refetch window would trigger a conflicting invalidation.
setTimeout(() => unmarkFeatureTransitioning(feature.id), 500);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The 500ms delay is a magic number. To improve readability and maintainability, consider extracting it into a named constant at the top of the file, for example: const UNMARK_TRANSITION_DELAY_MS = 500;. This makes the purpose of the delay clearer and simplifies future adjustments.

}
},
[stopFeature, moveFeature, persistFeatureUpdate, currentProject, queryClient]
[stopFeature, persistFeatureUpdate, currentProject]
);

const handleStartNextFeatures = useCallback(async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// @ts-nocheck - column filtering logic with dependency resolution and status mapping
import { useMemo, useCallback, useEffect, useRef } from 'react';
import { useMemo, useCallback, useEffect } from 'react';
import { Feature, useAppStore } from '@/store/app-store';
import {
createFeatureMap,
Expand Down Expand Up @@ -177,9 +177,6 @@ export function useBoardColumnFeatures({
(state) => state.clearRecentlyCompletedFeatures
);

// Track previous feature IDs to detect when features list has been refreshed
const prevFeatureIdsRef = useRef<Set<string>>(new Set());

// Clear recently completed features when the cache refreshes with updated statuses.
//
// RACE CONDITION SCENARIO THIS PREVENTS:
Expand All @@ -193,22 +190,24 @@ export function useBoardColumnFeatures({
//
// When the refetch completes with fresh data (status='verified'/'completed'),
// this effect clears the recentlyCompletedFeatures set since it's no longer needed.
// Clear recently completed features when the cache refreshes with updated statuses.
// IMPORTANT: Only depend on `features` (not `recentlyCompletedFeatures`) to avoid a
// re-trigger loop where clearing the set creates a new reference that re-fires this effect.
// Read recentlyCompletedFeatures from the store directly to get the latest value without
// subscribing to it as a dependency.
useEffect(() => {
const currentIds = new Set(features.map((f) => f.id));
const currentRecentlyCompleted = useAppStore.getState().recentlyCompletedFeatures;
if (currentRecentlyCompleted.size === 0) return;

// Check if any recently completed features now have terminal statuses in the new data
// If so, we can clear the tracking since the cache is now fresh
const hasUpdatedStatus = Array.from(recentlyCompletedFeatures).some((featureId) => {
const hasUpdatedStatus = Array.from(currentRecentlyCompleted).some((featureId) => {
const feature = features.find((f) => f.id === featureId);
return feature && (feature.status === 'verified' || feature.status === 'completed');
});

if (hasUpdatedStatus) {
clearRecentlyCompletedFeatures();
}

prevFeatureIdsRef.current = currentIds;
}, [features, recentlyCompletedFeatures, clearRecentlyCompletedFeatures]);
}, [features, clearRecentlyCompletedFeatures]);

// Memoize column features to prevent unnecessary re-renders
const columnFeaturesMap = useMemo(() => {
Expand Down
Loading
Loading