2 Commits

Author SHA1 Message Date
gsxdsm
f5752b662f fix: Allow empty eventHooks/ntfyEndpoints to reconcile from server
Remove the `length > 0` guards in fast-hydrate reconciliation that
prevented intentional empty-array clears from syncing across clients.
Server-side wipe protection (`__allowEmpty*` escape hatches) already
ensures empty arrays in the server are intentional.

Addresses PR #831 review feedback from CodeRabbit and Gemini.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:08:07 -08:00
gsxdsm
6f2394f17e Changes from fix/event-hook-endpoint 2026-03-03 19:59:55 -08:00
16 changed files with 71 additions and 452 deletions

1
.gitignore vendored
View File

@@ -113,4 +113,3 @@ data/
.planning/
.mcp.json
.planning
.bg-shell/

View File

@@ -32,7 +32,7 @@
"@automaker/prompts": "1.0.0",
"@automaker/types": "1.0.0",
"@automaker/utils": "1.0.0",
"@github/copilot-sdk": "0.1.16",
"@github/copilot-sdk": "^0.1.16",
"@modelcontextprotocol/sdk": "1.25.2",
"@openai/codex-sdk": "^0.98.0",
"cookie-parser": "1.4.7",

View File

@@ -2,7 +2,7 @@
* Version utility - Reads version from package.json
*/
import { readFileSync, existsSync } from 'fs';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { createLogger } from '@automaker/utils';
@@ -24,20 +24,7 @@ export function getVersion(): string {
}
try {
const candidatePaths = [
// Development via tsx: src/lib -> project root
join(__dirname, '..', '..', 'package.json'),
// Packaged/build output: lib -> server bundle root
join(__dirname, '..', 'package.json'),
];
const packageJsonPath = candidatePaths.find((candidate) => existsSync(candidate));
if (!packageJsonPath) {
throw new Error(
`package.json not found in any expected location: ${candidatePaths.join(', ')}`
);
}
const packageJsonPath = join(__dirname, '..', '..', 'package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
const version = packageJson.version || '0.0.0';
cachedVersion = version;

View File

@@ -595,12 +595,12 @@ export class EventHookService {
if (clickUrl && context.projectPath) {
try {
const url = new URL(clickUrl);
url.pathname = '/board';
// Add projectPath so the UI can switch to the correct project
url.searchParams.set('projectPath', context.projectPath);
// Add featureId as query param for deep linking to board with feature output modal
if (context.featureId) {
url.pathname = '/board';
url.searchParams.set('featureId', context.featureId);
} else {
url.pathname = '/board';
}
clickUrl = url.toString();
} catch (error) {

View File

@@ -179,7 +179,6 @@ ${feature.spec}
const abortController = tempRunningFeature.abortController;
if (isAutoMode) await this.saveExecutionStateFn(projectPath);
let feature: Feature | null = null;
let pipelineCompleted = false;
try {
validateWorkingDirectory(projectPath);
@@ -432,7 +431,6 @@ Please continue from where you left off and complete all remaining tasks. Use th
testAttempts: 0,
maxTestAttempts: 5,
});
pipelineCompleted = true;
// Check if pipeline set a terminal status (e.g., merge_conflict) — don't overwrite it
const refreshed = await this.loadFeatureFn(projectPath, featureId);
if (refreshed?.status === 'merge_conflict') {
@@ -543,30 +541,7 @@ Please continue from where you left off and complete all remaining tasks. Use th
}
} else {
logger.error(`Feature ${featureId} failed:`, error);
// If pipeline steps completed successfully, don't send the feature back to backlog.
// The pipeline work is done — set to waiting_approval so the user can review.
const fallbackStatus = pipelineCompleted ? 'waiting_approval' : 'backlog';
if (pipelineCompleted) {
logger.info(
`[executeFeature] Feature ${featureId} failed after pipeline completed. ` +
`Setting status to waiting_approval instead of backlog to preserve pipeline work.`
);
}
// Don't overwrite terminal states like 'merge_conflict' that were set during pipeline execution
let currentStatus: string | undefined;
try {
const currentFeature = await this.loadFeatureFn(projectPath, featureId);
currentStatus = currentFeature?.status;
} catch (loadErr) {
// If loading fails, log it and proceed with the status update anyway
logger.warn(
`[executeFeature] Failed to reload feature ${featureId} for status check:`,
loadErr
);
}
if (currentStatus !== 'merge_conflict') {
await this.updateFeatureStatusFn(projectPath, featureId, fallbackStatus);
}
await this.updateFeatureStatusFn(projectPath, featureId, 'backlog');
this.eventBus.emitAutoModeEvent('auto_mode_error', {
featureId,
featureName: feature?.title,

View File

@@ -350,7 +350,6 @@ export class PipelineOrchestrator {
});
const abortController = runningEntry.abortController;
runningEntry.branchName = feature.branchName ?? null;
let pipelineCompleted = false;
try {
validateWorkingDirectory(projectPath);
@@ -404,7 +403,6 @@ export class PipelineOrchestrator {
};
await this.executePipeline(context);
pipelineCompleted = true;
// Re-fetch feature to check if executePipeline set a terminal status (e.g., merge_conflict)
const reloadedFeature = await this.featureStateManager.loadFeature(projectPath, featureId);
@@ -441,21 +439,8 @@ export class PipelineOrchestrator {
});
}
} else {
// If pipeline steps completed successfully, don't send the feature back to backlog.
// The pipeline work is done — set to waiting_approval so the user can review.
const fallbackStatus = pipelineCompleted ? 'waiting_approval' : 'backlog';
if (pipelineCompleted) {
logger.info(
`[resumeFromStep] Feature ${featureId} failed after pipeline completed. ` +
`Setting status to waiting_approval instead of backlog to preserve pipeline work.`
);
}
logger.error(`Pipeline resume failed for ${featureId}:`, error);
// Don't overwrite terminal states like 'merge_conflict' that were set during pipeline execution
const currentFeature = await this.featureStateManager.loadFeature(projectPath, featureId);
if (currentFeature?.status !== 'merge_conflict') {
await this.updateFeatureStatusFn(projectPath, featureId, fallbackStatus);
}
await this.updateFeatureStatusFn(projectPath, featureId, 'backlog');
this.eventBus.emitAutoModeEvent('auto_mode_error', {
featureId,
featureName: feature.title,

View File

@@ -2000,60 +2000,5 @@ describe('execution-service.ts', () => {
// The only non-in_progress status call should be absent since merge_conflict returns early
expect(statusCalls.length).toBe(0);
});
it('sets waiting_approval instead of backlog when error occurs after pipeline completes', async () => {
// Set up pipeline with steps
vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue({
version: 1,
steps: [{ id: 'step-1', name: 'Step 1', order: 1, instructions: 'Do step 1' }] as any,
});
// Pipeline succeeds, but reading agent output throws after pipeline completes
mockExecutePipelineFn = vi.fn().mockResolvedValue(undefined);
// Simulate an error after pipeline completes by making loadFeature throw
// on the post-pipeline refresh call
let loadCallCount = 0;
mockLoadFeatureFn = vi.fn().mockImplementation(() => {
loadCallCount++;
if (loadCallCount === 1) return testFeature; // initial load
// Second call is the task-retry check, third is the pipeline refresh
if (loadCallCount <= 2) return testFeature;
throw new Error('Unexpected post-pipeline error');
});
const svc = createServiceWithMocks();
await svc.executeFeature('/test/project', 'feature-1');
// Should set to waiting_approval, NOT backlog, since pipeline completed
const backlogCalls = vi
.mocked(mockUpdateFeatureStatusFn)
.mock.calls.filter((call) => call[2] === 'backlog');
expect(backlogCalls.length).toBe(0);
const waitingCalls = vi
.mocked(mockUpdateFeatureStatusFn)
.mock.calls.filter((call) => call[2] === 'waiting_approval');
expect(waitingCalls.length).toBeGreaterThan(0);
});
it('still sets backlog when error occurs before pipeline completes', async () => {
// Set up pipeline with steps
vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue({
version: 1,
steps: [{ id: 'step-1', name: 'Step 1', order: 1, instructions: 'Do step 1' }] as any,
});
// Pipeline itself throws (e.g., agent error during pipeline step)
mockExecutePipelineFn = vi.fn().mockRejectedValue(new Error('Agent execution failed'));
const svc = createServiceWithMocks();
await svc.executeFeature('/test/project', 'feature-1');
// Should still set to backlog since pipeline did NOT complete
const backlogCalls = vi
.mocked(mockUpdateFeatureStatusFn)
.mock.calls.filter((call) => call[2] === 'backlog');
expect(backlogCalls.length).toBe(1);
});
});
});

View File

@@ -68,13 +68,7 @@ export function NotificationBell({ projectPath }: NotificationBellProps) {
// Navigate to the relevant view based on notification type
if (notification.featureId) {
navigate({
to: '/board',
search: {
featureId: notification.featureId,
projectPath: notification.projectPath || undefined,
},
});
navigate({ to: '/board', search: { featureId: notification.featureId } });
}
},
[handleMarkAsRead, setPopoverOpen, navigate]

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback, useMemo, useRef, startTransition } from 'react';
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import type { PointerEvent as ReactPointerEvent } from 'react';
import {
@@ -37,7 +37,6 @@ import type {
ReasoningEffort,
} from '@automaker/types';
import { pathsEqual } from '@/lib/utils';
import { initializeProject } from '@/lib/project-init';
import { toast } from 'sonner';
import {
BoardBackgroundModal,
@@ -118,11 +117,9 @@ const logger = createLogger('Board');
interface BoardViewProps {
/** Feature ID from URL parameter - if provided, opens output modal for this feature on load */
initialFeatureId?: string;
/** Project path from URL parameter - if provided, switches to this project before handling deep link */
initialProjectPath?: string;
}
export function BoardView({ initialFeatureId, initialProjectPath }: BoardViewProps) {
export function BoardView({ initialFeatureId }: BoardViewProps) {
const {
currentProject,
defaultSkipTests,
@@ -142,7 +139,6 @@ export function BoardView({ initialFeatureId, initialProjectPath }: BoardViewPro
setPipelineConfig,
featureTemplates,
defaultSortNewestCardOnTop,
upsertAndSetCurrentProject,
} = useAppStore(
useShallow((state) => ({
currentProject: state.currentProject,
@@ -163,7 +159,6 @@ export function BoardView({ initialFeatureId, initialProjectPath }: BoardViewPro
setPipelineConfig: state.setPipelineConfig,
featureTemplates: state.featureTemplates,
defaultSortNewestCardOnTop: state.defaultSortNewestCardOnTop,
upsertAndSetCurrentProject: state.upsertAndSetCurrentProject,
}))
);
// Also get keyboard shortcuts for the add feature shortcut
@@ -310,53 +305,6 @@ export function BoardView({ initialFeatureId, initialProjectPath }: BoardViewPro
setFeaturesWithContext,
});
// Handle deep link project switching - if URL includes a projectPath that differs from
// the current project, switch to the target project first. The feature/worktree deep link
// effect below will fire naturally once the project switch triggers a features reload.
const handledProjectPathRef = useRef<string | undefined>(undefined);
useEffect(() => {
if (!initialProjectPath || handledProjectPathRef.current === initialProjectPath) {
return;
}
// Check if we're already on the correct project
if (currentProject?.path && pathsEqual(currentProject.path, initialProjectPath)) {
handledProjectPathRef.current = initialProjectPath;
return;
}
handledProjectPathRef.current = initialProjectPath;
const switchProject = async () => {
try {
const initResult = await initializeProject(initialProjectPath);
if (!initResult.success) {
logger.warn(
`Deep link: failed to initialize project "${initialProjectPath}":`,
initResult.error
);
toast.error('Failed to open project from link', {
description: initResult.error || 'Unknown error',
});
return;
}
// Derive project name from path basename
const projectName =
initialProjectPath.split(/[/\\]/).filter(Boolean).pop() || initialProjectPath;
logger.info(`Deep link: switching to project "${projectName}" at ${initialProjectPath}`);
upsertAndSetCurrentProject(initialProjectPath, projectName);
} catch (error) {
logger.error('Deep link: project switch failed:', error);
toast.error('Failed to switch project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
}
};
switchProject();
}, [initialProjectPath, currentProject?.path, upsertAndSetCurrentProject]);
// Handle initial feature ID from URL - switch to the correct worktree and open output modal
// Uses a ref to track which featureId has been handled to prevent re-opening
// when the component re-renders but initialFeatureId hasn't changed.
@@ -377,17 +325,6 @@ export function BoardView({ initialFeatureId, initialProjectPath }: BoardViewPro
[currentProject?.path]
)
);
// Track how many render cycles we've waited for worktrees during a deep link.
// If the Zustand store never gets populated (e.g., WorktreePanel hasn't mounted,
// useWorktrees setting is off, or the worktree query failed), we stop waiting
// after a threshold and open the modal without switching worktree.
const deepLinkRetryCountRef = useRef(0);
// Reset retry count when the feature ID changes
useEffect(() => {
deepLinkRetryCountRef.current = 0;
}, [initialFeatureId]);
useEffect(() => {
if (
!initialFeatureId ||
@@ -402,43 +339,14 @@ export function BoardView({ initialFeatureId, initialProjectPath }: BoardViewPro
const feature = hookFeatures.find((f) => f.id === initialFeatureId);
if (!feature) return;
// Resolve worktrees: prefer the Zustand store (reactive), but fall back to
// the React Query cache if the store hasn't been populated yet. The store is
// only synced by the WorktreePanel's useWorktrees hook, which may not have
// rendered yet during a deep link cold start. Reading the query cache directly
// avoids an indefinite wait that hangs the app on the loading screen.
let resolvedWorktrees = deepLinkWorktrees;
if (resolvedWorktrees.length === 0 && currentProject.path) {
const cachedData = queryClient.getQueryData(queryKeys.worktrees.all(currentProject.path)) as
| { worktrees?: WorktreeInfo[] }
| undefined;
if (cachedData?.worktrees && cachedData.worktrees.length > 0) {
resolvedWorktrees = cachedData.worktrees as typeof deepLinkWorktrees;
}
// If the feature has a branch, wait for worktrees to load so we can switch
if (feature.branchName && deepLinkWorktrees.length === 0) {
return; // Worktrees not loaded yet - effect will re-run when they load
}
// If the feature has a branch and worktrees aren't available yet, wait briefly.
// After enough retries, proceed without switching worktree to avoid hanging.
const MAX_DEEP_LINK_RETRIES = 10;
if (feature.branchName && resolvedWorktrees.length === 0) {
deepLinkRetryCountRef.current++;
if (deepLinkRetryCountRef.current < MAX_DEEP_LINK_RETRIES) {
return; // Worktrees not loaded yet - effect will re-run when they load
}
// Exceeded retry limit — proceed without worktree switch to avoid hanging
logger.warn(
`Deep link: worktrees not available after ${MAX_DEEP_LINK_RETRIES} retries, ` +
`opening feature ${initialFeatureId} without switching worktree`
);
}
// Switch to the correct worktree based on the feature's branchName.
// IMPORTANT: Wrap in startTransition to batch the Zustand store update with
// any concurrent React state updates. Without this, the synchronous store
// mutation cascades through useAutoMode → refreshStatus → setAutoModeRunning,
// which can trigger React error #185 on mobile Safari/PWA crash loops.
if (feature.branchName && resolvedWorktrees.length > 0) {
const targetWorktree = resolvedWorktrees.find((w) => w.branch === feature.branchName);
// Switch to the correct worktree based on the feature's branchName
if (feature.branchName && deepLinkWorktrees.length > 0) {
const targetWorktree = deepLinkWorktrees.find((w) => w.branch === feature.branchName);
if (targetWorktree) {
const currentWt = useAppStore.getState().getCurrentWorktree(currentProject.path);
const isAlreadySelected = targetWorktree.isMain
@@ -448,27 +356,23 @@ export function BoardView({ initialFeatureId, initialProjectPath }: BoardViewPro
logger.info(
`Deep link: switching to worktree "${targetWorktree.branch}" for feature ${initialFeatureId}`
);
startTransition(() => {
setCurrentWorktree(
currentProject.path,
targetWorktree.isMain ? null : targetWorktree.path,
targetWorktree.branch
);
});
setCurrentWorktree(
currentProject.path,
targetWorktree.isMain ? null : targetWorktree.path,
targetWorktree.branch
);
}
}
} else if (!feature.branchName && resolvedWorktrees.length > 0) {
} else if (!feature.branchName && deepLinkWorktrees.length > 0) {
// Feature has no branch - should be on the main worktree
const currentWt = useAppStore.getState().getCurrentWorktree(currentProject.path);
if (currentWt?.path !== null && currentWt !== null) {
const mainWorktree = resolvedWorktrees.find((w) => w.isMain);
const mainWorktree = deepLinkWorktrees.find((w) => w.isMain);
if (mainWorktree) {
logger.info(
`Deep link: switching to main worktree for unassigned feature ${initialFeatureId}`
);
startTransition(() => {
setCurrentWorktree(currentProject.path, null, mainWorktree.branch);
});
setCurrentWorktree(currentProject.path, null, mainWorktree.branch);
}
}
}
@@ -483,7 +387,6 @@ export function BoardView({ initialFeatureId, initialProjectPath }: BoardViewPro
hookFeatures,
currentProject?.path,
deepLinkWorktrees,
queryClient,
setCurrentWorktree,
setOutputFeature,
setShowOutputModal,
@@ -861,15 +764,11 @@ export function BoardView({ initialFeatureId, initialProjectPath }: BoardViewPro
// Recovery handler for BoardErrorBoundary: reset worktree selection to main
// so the board can re-render without the stale worktree state that caused the crash.
// Wrapped in startTransition to batch with concurrent React updates and avoid
// triggering another cascade during recovery.
const handleBoardRecover = useCallback(() => {
if (!currentProject) return;
const mainWorktree = worktrees.find((w) => w.isMain);
const mainBranch = mainWorktree?.branch || 'main';
startTransition(() => {
setCurrentWorktree(currentProject.path, null, mainBranch);
});
setCurrentWorktree(currentProject.path, null, mainBranch);
}, [currentProject, worktrees, setCurrentWorktree]);
// Helper function to add and select a worktree

View File

@@ -14,63 +14,6 @@ import { useFeature, useAgentOutput } from '@/hooks/queries';
import { queryKeys } from '@/lib/query-keys';
import { getFirstNonEmptySummary } from '@/lib/summary-selection';
import { useAppStore } from '@/store/app-store';
import { isMobileDevice } from '@/lib/mobile-detect';
// Global concurrency control for mobile mount staggering.
// When many AgentInfoPanel instances mount simultaneously (e.g., worktree switch
// with 50+ cards), we spread queries over a wider window and cap how many
// panels can be querying concurrently to prevent mobile Safari crashes.
//
// The mechanism works in two layers:
// 1. Random delay (0-6s) - spreads mount times so not all panels try to query at once
// 2. Concurrency slots (max 4) - even after the delay, only N panels can query simultaneously
//
// Instance tracking ensures the queue resets if all panels unmount (e.g., navigation).
const MOBILE_MAX_CONCURRENT_QUERIES = 4;
const MOBILE_STAGGER_WINDOW_MS = 6000; // 6s window (vs previous 2s)
let activeMobileQueryCount = 0;
let pendingMobileQueue: Array<() => void> = [];
let mountedPanelCount = 0;
function acquireMobileQuerySlot(): Promise<void> {
if (!isMobileDevice) return Promise.resolve();
if (activeMobileQueryCount < MOBILE_MAX_CONCURRENT_QUERIES) {
activeMobileQueryCount++;
return Promise.resolve();
}
return new Promise<void>((resolve) => {
pendingMobileQueue.push(() => {
activeMobileQueryCount++;
resolve();
});
});
}
function releaseMobileQuerySlot(): void {
if (!isMobileDevice) return;
activeMobileQueryCount = Math.max(0, activeMobileQueryCount - 1);
const next = pendingMobileQueue.shift();
if (next) next();
}
function trackPanelMount(): void {
if (!isMobileDevice) return;
mountedPanelCount++;
}
function trackPanelUnmount(): void {
if (!isMobileDevice) return;
mountedPanelCount = Math.max(0, mountedPanelCount - 1);
// If all panels unmounted (e.g., navigated away from board or worktree switch),
// reset the queue to prevent stale state from blocking future mounts.
if (mountedPanelCount === 0) {
activeMobileQueryCount = 0;
// Drain any pending callbacks so their Promises resolve (components already unmounted)
const pending = pendingMobileQueue;
pendingMobileQueue = [];
for (const cb of pending) cb();
}
}
/**
* Formats thinking level for compact display
@@ -123,12 +66,6 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
// Track mounted panel count for global queue reset on full unmount
useEffect(() => {
trackPanelMount();
return () => trackPanelUnmount();
}, []);
// Get providers from store for provider-aware model name display
// This allows formatModelName to show provider-specific model names (e.g., "GLM 4.7" instead of "Sonnet 4.5")
// when a feature was executed using a Claude-compatible provider
@@ -155,41 +92,6 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
// Determine if we should poll for updates
const shouldFetchData = feature.status !== 'backlog' && feature.status !== 'merge_conflict';
// On mobile, stagger initial per-card queries to prevent a mount storm.
// When a worktree loads with many cards, all AgentInfoPanel instances mount
// simultaneously. Without staggering, each card fires useFeature + useAgentOutput
// queries at the same time, creating 60-100+ concurrent API calls that crash
// mobile Safari. Actively running cards fetch immediately (priority data);
// other cards defer by a random delay AND wait for a concurrency slot.
// The stagger window is 6s (vs previous 2s) to spread load for worktrees
// with 50+ features. The concurrency limiter caps active queries to 4 at a time,
// preventing the burst that overwhelms mobile Safari's connection handling.
const [mountReady, setMountReady] = useState(!isMobileDevice || !!isActivelyRunning);
useEffect(() => {
if (mountReady) return;
let cancelled = false;
const delay = Math.random() * MOBILE_STAGGER_WINDOW_MS;
const timer = setTimeout(() => {
// After the random delay, also wait for a concurrency slot
acquireMobileQuerySlot().then(() => {
if (!cancelled) {
setMountReady(true);
// Release the slot after a brief window to let the initial queries fire
// and return, preventing all slots from being held indefinitely
setTimeout(releaseMobileQuerySlot, 3000);
} else {
releaseMobileQuerySlot();
}
});
}, delay);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [mountReady]);
const queryEnabled = shouldFetchData && mountReady;
// Track whether we're receiving WebSocket events (within threshold)
// Use a state to trigger re-renders when the WebSocket connection becomes stale
const [isReceivingWsEvents, setIsReceivingWsEvents] = useState(false);
@@ -240,72 +142,34 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
// Fetch fresh feature data for planSpec (store data can be stale for task progress)
const { data: freshFeature } = useFeature(projectPath, feature.id, {
enabled: queryEnabled && !contextContent,
enabled: shouldFetchData && !contextContent,
pollingInterval,
});
// Fetch agent output for parsing
const { data: agentOutputContent } = useAgentOutput(projectPath, feature.id, {
enabled: queryEnabled && !contextContent,
enabled: shouldFetchData && !contextContent,
pollingInterval,
});
// On mount, ensure feature and agent output queries are fresh.
// This handles the worktree switch scenario where cards unmount when filtered out
// and remount when the user switches back. Without this, the React Query cache
// may serve stale data for the individual feature query, causing the todo list
// to appear empty until the next polling cycle.
//
// IMPORTANT: Only invalidate if the cached data EXISTS and is STALE.
// During worktree switches, ALL cards in the new worktree remount simultaneously.
// If every card fires invalidateQueries(), it creates a query storm (40-100+
// concurrent invalidations) that overwhelms React's rendering pipeline on mobile
// Safari/PWA, causing crashes. The key insight: if a query has NEVER been fetched
// (no dataUpdatedAt), there's nothing stale to invalidate — the useFeature/
// useAgentOutput hooks will fetch fresh data when their `enabled` flag is true.
// We only need to invalidate when cached data exists but is outdated.
//
// On mobile, skip mount-time invalidation entirely. The staggered useFeature/
// useAgentOutput queries already fetch fresh data — invalidation is redundant
// and creates the exact query storm we're trying to prevent. The stale threshold
// is also higher on mobile (30s vs 10s) to further reduce unnecessary refetches
// during the settling period after a worktree switch.
// may serve stale data (or no data) for the individual feature query, causing
// the todo list to appear empty until the next polling cycle.
useEffect(() => {
if (queryEnabled && projectPath && feature.id && !contextContent) {
// On mobile, skip mount-time invalidation — the useFeature/useAgentOutput
// hooks will handle the initial fetch after the stagger delay.
if (isMobileDevice) return;
const MOUNT_STALE_THRESHOLD = 10_000; // 10s — skip invalidation if data is fresh
const now = Date.now();
const featureQuery = queryClient.getQueryState(
queryKeys.features.single(projectPath, feature.id)
);
const agentOutputQuery = queryClient.getQueryState(
queryKeys.features.agentOutput(projectPath, feature.id)
);
// Only invalidate queries that have cached data AND are stale.
// Skip if the query has never been fetched (dataUpdatedAt is undefined) —
// the useFeature/useAgentOutput hooks will handle the initial fetch.
if (featureQuery?.dataUpdatedAt && now - featureQuery.dataUpdatedAt > MOUNT_STALE_THRESHOLD) {
queryClient.invalidateQueries({
queryKey: queryKeys.features.single(projectPath, feature.id),
});
}
if (
agentOutputQuery?.dataUpdatedAt &&
now - agentOutputQuery.dataUpdatedAt > MOUNT_STALE_THRESHOLD
) {
queryClient.invalidateQueries({
queryKey: queryKeys.features.agentOutput(projectPath, feature.id),
});
}
if (shouldFetchData && projectPath && feature.id && !contextContent) {
// Invalidate both the single feature and agent output queries to trigger immediate refetch
queryClient.invalidateQueries({
queryKey: queryKeys.features.single(projectPath, feature.id),
});
queryClient.invalidateQueries({
queryKey: queryKeys.features.agentOutput(projectPath, feature.id),
});
}
// Runs when mount staggering completes (queryEnabled becomes true) or on initial mount
// Only run on mount (feature.id and projectPath identify this specific card instance)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [queryEnabled, feature.id, projectPath]);
}, [feature.id, projectPath]);
// Parse agent output into agentInfo
const agentInfo = useMemo(() => {

View File

@@ -94,14 +94,8 @@ export function NotificationsView() {
// Navigate to the relevant view based on notification type
if (notification.featureId) {
// Navigate to board view with feature ID and project path to show output
navigate({
to: '/board',
search: {
featureId: notification.featureId,
projectPath: notification.projectPath || undefined,
},
});
// Navigate to board view with feature ID to show output
navigate({ to: '/board', search: { featureId: notification.featureId } });
}
},
[handleMarkAsRead, navigate]

View File

@@ -136,14 +136,8 @@ export function RecentActivityFeed({ activities, maxItems = 10 }: RecentActivity
upsertAndSetCurrentProject(projectPath, projectName);
if (activity.featureId) {
// Navigate to the specific feature with project path for deep link handling
navigate({
to: '/board',
search: {
featureId: activity.featureId,
projectPath: projectPath || undefined,
},
});
// Navigate to the specific feature
navigate({ to: '/board', search: { featureId: activity.featureId } });
} else {
navigate({ to: '/board' });
}

View File

@@ -28,46 +28,33 @@ const serverLogger = createLogger('Server');
export async function startServer(): Promise<void> {
const isDev = !app.isPackaged;
let command: string;
let commandSource: string;
// Find Node.js executable (handles desktop launcher scenarios)
const nodeResult = findNodeExecutable({
skipSearch: isDev,
logger: (msg: string) => logger.info(msg),
});
const command = nodeResult.nodePath;
// Validate that the found Node executable actually exists
// systemPathExists is used because node-finder returns system paths
if (command !== 'node') {
let exists: boolean;
try {
exists = systemPathExists(command);
} catch (error) {
const originalError = error instanceof Error ? error.message : String(error);
throw new Error(
`Failed to verify Node.js executable at: ${command} (source: ${nodeResult.source}). Reason: ${originalError}`
);
}
if (!exists) {
throw new Error(`Node.js executable not found at: ${command} (source: ${nodeResult.source})`);
}
}
let args: string[];
let serverPath: string;
if (isDev) {
// In development, run the TypeScript server via the user's Node.js.
const nodeResult = findNodeExecutable({
skipSearch: true,
logger: (msg: string) => logger.info(msg),
});
command = nodeResult.nodePath;
commandSource = nodeResult.source;
// Validate that the found Node executable actually exists
// systemPathExists is used because node-finder returns system paths
if (command !== 'node') {
let exists: boolean;
try {
exists = systemPathExists(command);
} catch (error) {
const originalError = error instanceof Error ? error.message : String(error);
throw new Error(
`Failed to verify Node.js executable at: ${command} (source: ${nodeResult.source}). Reason: ${originalError}`
);
}
if (!exists) {
throw new Error(
`Node.js executable not found at: ${command} (source: ${nodeResult.source})`
);
}
}
} else {
// In packaged builds, use Electron's bundled Node runtime instead of a system Node.
// This makes the desktop app self-contained and avoids incompatibilities with whatever
// Node version the user happens to have installed globally.
command = process.execPath;
commandSource = 'electron';
}
// __dirname is apps/ui/dist-electron (Vite bundles all into single file)
if (isDev) {
serverPath = path.join(__dirname, '../../server/src/index.ts');
@@ -146,8 +133,6 @@ export async function startServer(): Promise<void> {
PORT: state.serverPort.toString(),
DATA_DIR: dataDir,
NODE_PATH: serverNodeModules,
// Run packaged backend with Electron's embedded Node runtime.
...(app.isPackaged && { ELECTRON_RUN_AS_NODE: '1' }),
// Pass API key to server for CSRF protection
AUTOMAKER_API_KEY: state.apiKey!,
// Only set ALLOWED_ROOT_DIRECTORY if explicitly provided in environment
@@ -161,7 +146,6 @@ export async function startServer(): Promise<void> {
logger.info('[DATA_DIR_SPAWN] env.DATA_DIR=', env.DATA_DIR);
logger.info('Starting backend server...');
logger.info('Runtime command:', command, `(source: ${commandSource})`);
logger.info('Server path:', serverPath);
logger.info('Server root (cwd):', serverRoot);
logger.info('NODE_PATH:', serverNodeModules);

View File

@@ -6,6 +6,6 @@ export const Route = createLazyFileRoute('/board')({
});
function BoardRouteComponent() {
const { featureId, projectPath } = useSearch({ from: '/board' });
return <BoardView initialFeatureId={featureId} initialProjectPath={projectPath} />;
const { featureId } = useSearch({ from: '/board' });
return <BoardView initialFeatureId={featureId} />;
}

View File

@@ -4,7 +4,6 @@ import { z } from 'zod';
// Search params schema for board route
const boardSearchSchema = z.object({
featureId: z.string().optional(),
projectPath: z.string().optional(),
});
// Component is lazy-loaded via board.lazy.tsx for code splitting.

2
package-lock.json generated
View File

@@ -44,7 +44,7 @@
"@automaker/prompts": "1.0.0",
"@automaker/types": "1.0.0",
"@automaker/utils": "1.0.0",
"@github/copilot-sdk": "0.1.16",
"@github/copilot-sdk": "^0.1.16",
"@modelcontextprotocol/sdk": "1.25.2",
"@openai/codex-sdk": "^0.98.0",
"cookie-parser": "1.4.7",