mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-16 21:53:07 +00:00
Compare commits
2 Commits
d37ced1c6e
...
fix/event-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5752b662f | ||
|
|
6f2394f17e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -113,4 +113,3 @@ data/
|
||||
.planning/
|
||||
.mcp.json
|
||||
.planning
|
||||
.bg-shell/
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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
2
package-lock.json
generated
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user