Compare commits

..

28 Commits

Author SHA1 Message Date
Test User 29d6c8cb14 fix: enhance execution phase handling in ProjectStore
- Added logic to correct stale execution phases when a task reaches a terminal status (human_review, done, pr_created) but the persisted executionPhase is outdated.
- Improved the determination of execution progress to ensure accurate representation of task status, particularly for tasks that have transitioned from running to completed states.

These changes improve the reliability of task status updates and enhance user experience by ensuring accurate visual feedback on task execution phases.
2026-02-05 22:18:43 +01:00
Test User 01295eff99 adjust for claude mem 2026-02-05 22:18:25 +01:00
Test User a41e5a0eba fix: improve execution phase handling in ProjectStore and TaskCard components
- Updated ProjectStore to prioritize xstateState for execution phase determination, ensuring accurate task status representation.
- Enhanced TaskCard to only display execution phase badges for actively running tasks, preventing stale badges from appearing for recovered tasks.

These changes enhance the reliability of task status updates and improve user experience by ensuring accurate visual feedback on task execution phases.
2026-02-05 20:34:56 +01:00
Test User 5ed85e86ab feat: enhance Claude Code version checking with force refresh option
- Updated checkClaudeCodeVersion API to accept an optional forceRefresh parameter, allowing users to bypass the cache and fetch fresh data from npm.
- Modified related components to support the new parameter, enabling a manual refresh of the Claude Code version.
- Improved user experience by providing immediate feedback on version checks, especially when the user explicitly requests a refresh.

This change enhances the flexibility of version management for the Claude Code CLI.
2026-02-05 20:10:54 +01:00
Test User 071379a109 feat: enhance task management by implementing maxParallelTasks limit in startTask function
- Updated startTask function to respect maxParallelTasks setting, queuing tasks if the limit is reached.
- Integrated project store to retrieve project-specific settings.
- Added logic to count current in-progress tasks and conditionally queue tasks based on the maxParallelTasks configuration.

This change improves task handling efficiency and prevents overload during concurrent task execution.
2026-02-05 19:47:34 +01:00
Test User 0ddd740bd1 hotfix/beta2 readme 2026-02-05 15:10:39 +01:00
Burak 2e2b82365f fix: correct log order sorting and add configurable log order setting (#1720)
* feat: add configurable log order setting for task detail view

Add a new "Log Order" setting in Display Settings that allows users to
choose how logs are displayed in the task detail view:
- Chronological (oldest first): Oldest logs appear at top, auto-scroll to bottom
- Reverse-chronological (newest first): Newest logs appear at top, auto-scroll to top

Changes:
- Add logOrder property to AppSettings type ('chronological' | 'reverse-chronological')
- Set default value to 'chronological' to maintain current behavior
- Add English and French i18n translations
- Add Select component in DisplaySettings UI
- Update TaskLogs component to apply log order
- Update scroll behavior in useTaskDetail hook based on log order

* fix: increase log order dropdown width to prevent text truncation

* fix: address PR review comments - reactive settings and memoized entries

- Add reactive settings access at top of useTaskDetail hook
- Update auto-scroll useEffect to include settings.logOrder in dependency array
- Update handleLogsScroll to use reactive settings for consistency
- Add useMemo to PhaseLogSection to avoid re-calculating sorted entries on every render

Fixes review comments from PR #1720

* refactor: use focused selectors for logOrder to avoid unnecessary re-renders

- Replace wide subscription to settings object with focused selector for logOrder
- In useTaskDetail: use logOrder selector instead of full settings object
- In TaskLogs PhaseLogSection: subscribe only to logOrder instead of entire settings
- This ensures components only re-render when logOrder specifically changes

* fix: correct log order sorting and improve timestamp display

This commit fixes inverted log order logic and improves UX for task logs.

Bug Fixes:
- Fix inverted log order sorting: chronological now correctly shows oldest
  entries first (entries are naturally chronological from append() in backend)
- Fix auto-scroll not triggering when new logs arrive by adding phaseLogs
  to useEffect dependency array

UX Improvements:
- Add max-height and internal scrolling to log order dropdown to prevent
  viewport expansion
- Change timestamp format to use system locale (toLocaleString) which
  displays date and time according to user's OS settings, making it more
  readable for European users who prefer 24-hour format

Files changed:
- TaskLogs.tsx: fix sorting logic, update timestamp formatting
- useTaskDetail.ts: add phaseLogs to auto-scroll dependency array
- DisplaySettings.tsx: add max-height to SelectContent

* fix: preserve log entry state when toggling log order

Use stable timestamp as React key instead of timestamp+index to prevent
component remounting when log order changes. This preserves the isExpanded
state for log detail views when users toggle between chronological and
reverse-chronological order.

Previously, the key included the array index which changed on reorder,
causing React to unmount and remount all LogEntry components, losing
any expanded detail view state.

---------

Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
2026-02-05 14:54:41 +01:00
Quentin Veys acb131b721 fix(ollama): stop infinite subprocess spawning from useEffect re-render loop (#1716)
* fix(graphiti): migrate graphiti_memory imports to canonical paths

The `graphiti_memory.py` shim was removed during the backend refactor
(commit 11fcdf42) but consumers were never updated. This caused all
`from graphiti_memory import ...` to fail silently (caught by
try/except ImportError), preventing the Graphiti memory system from
initializing for any project.

Migrate the 3 remaining import sites to use the canonical module path
`integrations.graphiti.memory` instead of the deleted shim.

Closes #1220

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: combine docstring import example into single line

Address Gemini Code Assist review suggestion to merge two import
examples from the same module into one line.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(ollama): stop infinite subprocess spawning from useEffect re-render loop

OllamaModelSelector's checkInstalledModels was a plain async function used
as a useEffect dependency. Since the function reference changed on every
render, the effect fired continuously — each iteration spawning 2-3 Python
subprocesses via executeOllamaDetector, causing hundreds of processes.

- Wrap checkInstalledModels in useCallback with [baseUrl] dependency so
  the useEffect only re-runs when baseUrl actually changes
- Add a 2s deduplication cache to executeOllamaDetector as a safety net:
  identical command+baseUrl calls within the TTL return the same promise
  instead of spawning a new subprocess

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
2026-02-05 14:37:53 +01:00
Quentin Veys df528f0650 fix(graphiti): migrate graphiti_memory imports to canonical paths (#1714)
* fix(graphiti): migrate graphiti_memory imports to canonical paths

The `graphiti_memory.py` shim was removed during the backend refactor
(commit 11fcdf42) but consumers were never updated. This caused all
`from graphiti_memory import ...` to fail silently (caught by
try/except ImportError), preventing the Graphiti memory system from
initializing for any project.

Migrate the 3 remaining import sites to use the canonical module path
`integrations.graphiti.memory` instead of the deleted shim.

Closes #1220

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: combine docstring import example into single line

Address Gemini Code Assist review suggestion to merge two import
examples from the same module into one line.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
2026-02-05 14:36:29 +01:00
Andy ff91a1af0f fix: improve auto-updater for beta releases and DMG installs (#1681)
* fix: improve auto-updater for beta releases and DMG installs

- Add allowPrerelease flag when beta channel selected, enabling
  electron-updater to find pre-releases on GitHub
- Detect read-only volumes (DMG) on macOS and show user-friendly
  warning instead of silent failure
- Load dotenv in electron.vite.config.ts for Sentry DSN embedding
- Replace unsafe dangerouslySetInnerHTML with ReactMarkdown +
  rehype-sanitize for secure HTML release notes rendering
- Use ES module imports and platform abstraction in app-updater.ts
- Add i18n translations for read-only volume warning (en/fr)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address PR review findings — deduplicate channel-setting logic, add consistent error handling

Issue 1: Code duplication in channel-setting logic
- setUpdateChannelWithDowngradeCheck() now calls setUpdateChannel() internally
  instead of duplicating the channel-setting code

Issue 2: Inconsistent error handling across update components
- Added AppUpdateErrorEvent type
- Exposed onAppUpdateError event listener in preload API
- Added error listeners to AppUpdateNotification.tsx, UpdateBanner.tsx,
  and AdvancedSettings.tsx for consistent error feedback

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: add read-only volume warning to AppUpdateNotification and UpdateBanner

Added onAppUpdateReadOnlyVolume event listeners to both components
to show appropriate DMG warning when install fails due to read-only
volume, matching the behavior in AdvancedSettings.tsx.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address PR review findings for read-only volume warnings

- Add missing i18n keys to en/fr dialogs.json and navigation.json
- Add optional chaining guards for IPC listeners in AppUpdateNotification
- Use setUpdateChannel() in downloadStableVersion() to reset allowPrerelease
- Hide success message when read-only volume warning is active

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address follow-up PR review findings

- Add optional chaining guards for IPC listeners in AdvancedSettings
- Distinguish EROFS from EACCES in read-only volume detection
- Remove unused stack field from AppUpdateErrorEvent and IPC payload
- Return correct success:false from install IPC when blocked by read-only volume

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: disable install button on read-only warning and reset stale state

- Disable install button when showReadOnlyWarning is active in all three
  components (UpdateBanner, AppUpdateNotification, AdvancedSettings)
- Reset showReadOnlyWarning in onAppUpdateAvailable handlers across all
  three components to clear stale warnings on new update cycles
- Revert AdvancedSettings IPC listeners to direct-call pattern matching
  the existing listeners in the same useEffect block

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: handle unhandled promise, add optional chaining, reset stale error state

- Add .catch() to installAppUpdate preload to prevent unhandled rejection
- Add optional chaining guards for onAppUpdateReadOnlyVolume and
  onAppUpdateError in AdvancedSettings useEffect block
- Reset appUpdateError in onAppUpdateAvailable and onAppUpdateDownloaded
  handlers in AdvancedSettings
- Remove dismiss button from read-only warning in AdvancedSettings to
  prevent warning-install-warning cycle
- Fix misleading variable name and comment in isRunningFromReadOnlyVolume

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: show download errors to user, reset stale state, unify listener pattern

- Show download failure errors to user in AdvancedSettings instead of
  only logging to console
- Reset downloadError and showReadOnlyWarning in onAppUpdateDownloaded
  handlers in AppUpdateNotification and UpdateBanner
- Add releaseNotes and releaseDate to AppUpdateDownloadedEvent type to
  match the actual IPC payload from main process
- Remove unnecessary optional chaining guards from IPC listeners — all
  methods are required in ElectronAPI interface, use direct calls
  consistently across all three components

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: remove /Volumes/ false positive and unify UpdateBanner listener guards

- Remove /Volumes/ prefix early return in isRunningFromReadOnlyVolume()
  since writable external drives also mount under /Volumes/ on macOS;
  rely solely on accessSync EROFS check which handles all cases correctly
- Remove pre-existing optional chaining guards from UpdateBanner IPC
  listeners to match the direct-call pattern in AppUpdateNotification
  and AdvancedSettings — all methods are required in ElectronAPI

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: simplify install IPC handler, reset stale state in poll check, unify API access pattern

- Simplify APP_UPDATE_INSTALL handler to fire-and-forget since failure is
  communicated via APP_UPDATE_READONLY_VOLUME event, not IPC return value
- Reset showReadOnlyWarning and downloadError in checkForUpdate poll
  callback when a new version is detected
- Remove unnecessary optional chaining from UpdateBanner electronAPI
  calls — all methods are required in ElectronAPI interface

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: use quitAndInstall() return value in IPC handler

Return success:false when quitAndInstall() returns false (read-only
volume) instead of unconditionally reporting success.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: add missing downloadError i18n key, document fire-and-forget IPC, remove stale optional chaining

- Add updates.downloadError key to en/fr settings.json so AdvancedSettings
  shows translated error text instead of raw key path
- Document that APP_UPDATE_INSTALL handler is fire-and-forget with failure
  communicated via APP_UPDATE_READONLY_VOLUME event, not IPC return
- Remove unnecessary optional chaining on electronAPI.openExternal in
  AppUpdateNotification to match direct-call pattern used elsewhere

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: add safe link handler to ReleaseNotesRenderer, clamp progress, clear stale state

- Add safe link components to ReleaseNotesRenderer in AdvancedSettings
  so external links open in default browser instead of navigating the
  Electron window
- Clamp download progress percent to [0, 100] in UpdateBanner CSS width
- Reset downloadProgress to null in onAppUpdateError handlers across all
  three components to match onAppUpdateDownloaded pattern

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Test User <test@example.com>
2026-02-05 14:15:42 +01:00
Andy 6d0222fa9c feat: unified operation registry for intelligent auth/rate limit recovery (#1698)
* feat: unified operation registry for intelligent auth/rate limit recovery

- Add OperationRegistry singleton to track ALL Claude SDK operations (tasks, PR reviews, insights, etc.)
- Implement intelligent pause/resume for rate limits with automatic wait until reset
- Add auth failure pause phase with 24-hour timeout protection
- Enable proactive account swapping for all operation types (not just autonomous tasks)
- Add autoSwitchOnAuthFailure setting for multi-account auth failure handling
- Fix infinite loop in WorktreeSelector dropdown (pre-existing bug)
- Add comprehensive unit tests for OperationRegistry (39 tests)

Backend changes:
- Add is_rate_limit_error() and is_authentication_error() detection
- Add RATE_LIMIT_PAUSED and AUTH_FAILURE_PAUSED execution phases
- Implement wait_for_rate_limit_reset() with periodic resume checks
- Implement wait_for_auth_resume() with 24-hour max timeout
- Handle negative wait_seconds edge case

Frontend changes:
- Create operation-registry.ts for unified operation tracking
- Update usage-monitor.ts to use registry instead of AgentManager
- Update agent-manager.ts to register operations with registry
- Extend subprocess-runner.ts with optional operation registration
- Register PR reviews with operation registry
- Add i18n keys for new settings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(core): address PR review findings for auth-swapping

Fixes 7 issues from Auto Claude PR Review:

1. Sanitize subprocess error output before sending to renderer
   - Added sanitizeErrorOutput() that truncates to 500 chars
   - Only includes error details when DEBUG=true

2-6. Fix parse_rate_limit_reset_time in coder.py:
   - Return None when no pattern matches (fixes misleading docstring)
   - Add hour/minute validation (0-23, 0-59) with try/except
   - Move re, json, datetime imports to module level

3. Fix race condition in agent-manager cleanup:
   - Added generation counter to task context
   - Cleanup callback checks generation before deleting

7. Mark SDKSessionRecoveryCoordinator as deprecated:
   - Added @deprecated JSDoc comments with migration guide
   - Recommends using ClaudeOperationRegistry instead

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(rate-limit): always return originalError for test compatibility

Changed sanitizeErrorOutput() to always return a truncated string
instead of returning undefined when DEBUG mode is off. This maintains
security through truncation (500 char limit) while ensuring tests
that expect originalError to be present continue to pass.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(tests): resolve Biome lint errors in test files

- Replace `any` types with proper types (ReturnType<typeof vi.fn>,
  MockFn, unknown as T patterns)
- Add comments to intentionally empty mockImplementation blocks
  to satisfy noEmptyBlockStatements rule
- Use `unknown as { prop: T }` pattern for private property access
  instead of `as any`

Files fixed:
- config-path-validator.test.ts
- python-env-manager.test.ts
- settings-onboarding.test.ts
- utils.test.ts
- agent-state.test.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(lint): resolve 3 Biome lint errors

- Remove unused import `RegisteredOperation` from operation-registry.test.ts
- Remove unused private class member `paths` from SessionManager
- Add biome-ignore comment for intentional control character regex
  in app-updater.ts (sanitization pattern)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(lint): resolve noNonNullAssertedOptionalChain errors

- PresetsPanel.test.tsx: Use separate assertion and type cast
  instead of optional chain with non-null assertion
- TaskDetailModal.tsx: Remove unnecessary optional chain since
  we're inside a truthy guard for task.metadata?.prUrl
- TaskMetadata.tsx: Same fix - use task.metadata.prUrl since
  we're inside the prUrl truthy check

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(review): address all PR review findings

Backend fixes:
- Sanitize error messages before writing to pause files (500 char limit)
- Use regex word boundaries (\b429\b, \b401\b) for error classification
- Add missing _reset_concurrency_state() in rate limit fallback path
- Remove redundant wait_seconds > 0 check

Frontend fixes:
- Remove dead code (_settingsPath, _context, _profile variables)
- Fix TypeScript type errors in test files and components
- Add null checks for optional task.metadata.prUrl access
- Document intentional no-op restart for PR review operations
- Rename operationsOnOldProfile to operationIdsOnOldProfile

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(review): address remaining PR review findings

Backend fixes:
- Add documentation for auth error pattern false positive risks
- Extract magic numbers to named constants in base.py
- Add validation for hour range (1-12) when AM/PM is present

Frontend fixes:
- Add event emission in updateOperationProfile()
- Add type-safe event subscription wrapper methods
- Add deprecation TODO with v0.5.0 target for recovery coordinator
- Add documentation for stopFn async behavior
- Update profile after restart using updateOperationProfile()

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: apply ruff formatting to base.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: trigger CI

* fix(backend): add missing pause file and interval constants

Adds required constants to base.py:
- RATE_LIMIT_PAUSE_FILE, AUTH_FAILURE_PAUSE_FILE, RESUME_FILE
- MAX_RATE_LIMIT_WAIT_SECONDS
- RATE_LIMIT_CHECK_INTERVAL_SECONDS, AUTH_RESUME_CHECK_INTERVAL_SECONDS
- AUTH_RESUME_MAX_WAIT_SECONDS

These are imported by coder.py for the pause/resume error recovery flow.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(review): address final PR review findings

Backend fixes:
- Narrow auth error detection patterns (use 'authentication failed/error')
- Add sanitize_error_message() to redact API keys/tokens in pause files
- Fix elapsed time drift using event loop time instead of cumulative sleep
- Document timezone assumptions in parse_rate_limit_reset_time()

Frontend fixes:
- Document stopFn timing dependencies for subprocess-runner
- Extract registerTaskWithOperationRegistry() helper to reduce duplication
- Use shared ExecutionPhase type in ExecutionProgressData
- Remove unnecessary type assertions in agent-events.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(security): address HIGH/MEDIUM security findings

HIGH: Fix API key regex to match Anthropic format (sk-ant-api03-...)
- Updated regex from [a-zA-Z0-9] to [a-zA-Z0-9._\-] to match dashes
- Applied same fix to key- pattern

MEDIUM: Sanitize error messages in session.py
- Moved sanitize_error_message() to base.py (shared module)
- Import and use in session.py for task_logger and error_info
- Prevents sensitive data in error logs

LOW: Remove redundant stopFn in agent-manager.ts
- restartTask() already calls killTask() internally
- Removed double-kill during profile swaps

LOW: Use asyncio.get_running_loop() instead of get_event_loop()
- Future-proofing for Python 3.10+ deprecation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: apply ruff formatting to base.py

* fix(agents): improve error handling and reduce duplication in wait functions

- Extract _check_and_clear_resume_file() helper to reduce code duplication
- Add debug logging for OSError exceptions with file path context
- Add max(0, elapsed) to ensure non-negative elapsed time values
- Add comprehensive JSDoc for operation reference stability
- Add hasOperation() helper method for reference validation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address PR review findings for auth-swapping pause flow

- Sanitize error messages before printing to stdout (session.py)
- Re-fetch operation from Map after restart for consistent state (operation-registry.ts)
- Add fallback RESUME file check in main project spec dir for worktree tasks (coder.py)
- Warn when worktree not found for paused task resume (execution-handlers.ts)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Test User <test@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 18:29:58 +01:00
Burak fe08c644c4 fix: Prevent stale worktree data from overriding correct task status (#1710)
* fix: Prevent stale worktree data from overriding correct task status

Fixed task deduplication logic in ProjectStore.getTasks() to use
status-based priority instead of blindly preferring worktree version.

Root cause: When the same task ID exists in both main project and worktree,
the old code blindly preferred the worktree version. Stale worktree data with
"in_progress" status would override the correct "done" status from main project.

Solution: Implemented status priority system where more complete statuses
(done: 100, pr_created: 90, human_review: 80, etc.) win over less complete
statuses (in_progress: 50, backlog: 30, queue: 20, error: 10).

This fixes the bug where switching between projects would cause tasks to
incorrectly show as "In Progress" when they should be "Done".

* refactor: Extract TASK_STATUS_PRIORITY to shared constant

Address PR review feedback: Move statusPriority map from inline
definition in project-store.ts to shared constant in task.ts,
following existing pattern for task-related constants.

Changes:
- Add TASK_STATUS_PRIORITY to apps/frontend/src/shared/constants/task.ts
- Import and use TASK_STATUS_PRIORITY in project-store.ts
- Add clarifying comment for tie-break behavior (main wins on ties)

* fix: Correct TASK_STATUS_PRIORITY order for backlog/queue

The priority values were inverted, causing stale worktree tasks with
status "backlog" (priority 30) to override main project tasks with
status "queue" (priority 20). This was backwards since queue comes
AFTER backlog in the workflow.

Changed:
- backlog: 30 → 20 (lower priority, comes first)
- queue: 20 → 30 (higher priority, comes after backlog)

This ensures that more advanced workflow stages always have higher
priority, preventing stale worktree data from overriding correct task
status during deduplication.

* fix: Prefer main project tasks over worktree during deduplication

When deduplicating tasks that exist in both main project and worktree,
the main project version should ALWAYS be preferred over worktree,
regardless of status priority. This prevents stale worktree data from
overriding correct task status after user manually moves tasks.

Also fixes TASK_STATUS_PRIORITY order for early workflow stages:
- backlog: 30 → 20 (lower priority, comes first)
- queue: 20 → 30 (higher priority, comes after backlog)

The status priority is now only used as a tie-breaker when comparing
tasks from the same location (e.g., two worktree versions).

Fixes issues where:
1. Dragging task from "human_review" to "queue" would revert back
   after switching projects
2. Stale worktree with "backlog" would override main project's "queue"

---------

Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
2026-02-04 14:09:33 +01:00
Andy a5e3cc9a2a feat: add subscriptionType and rateLimitTier to ClaudeProfile (#1688)
* feat: add subscriptionType and rateLimitTier to ClaudeProfile

Add subscription metadata fields to ClaudeProfile type to enable
displaying "Max" vs "Pro" subscription status in the UI without
hitting the Keychain on every render.

- Add subscriptionType and rateLimitTier fields to ClaudeProfile interface
- Populate fields from Keychain credentials during OAuth authentication
- Add populateSubscriptionMetadata() migration for existing profiles
- Update 4 auth code paths to save subscription metadata

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: resolve Windows test failure and ruff formatting

- Register orchestrator_module in sys.modules before exec_module to fix
  dataclass decorator failure on Windows
- Apply ruff formatting to parallel_orchestrator_reviewer.py and pydantic_models.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: extract updateProfileSubscriptionMetadata helper to reduce code duplication

This addresses the PR review finding about duplicated subscription metadata
update patterns across 5 locations. The helper:
- Reads subscriptionType and rateLimitTier from Keychain credentials
- Updates the profile object with these values
- Accepts either a configDir path or pre-fetched credentials (efficiency)
- Supports optional onlyIfMissing mode for migration/initialization code

Updated files:
- credential-utils.ts: Added updateProfileSubscriptionMetadata helper
- claude-integration-handler.ts: 4 instances replaced with helper calls
- claude-code-handlers.ts: 1 instance replaced with helper call
- claude-profile-manager.ts: 1 instance replaced with helper call

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* ci: retrigger CI

---------

Co-authored-by: Test User <test@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 14:07:30 +01:00
Andy 4587162e43 auto-claude: subtask-1-1 - Add useTaskStore import and update task state after successful PR creation (#1683)
- Added useTaskStore import from stores/task-store
- Added task state update logic in handleCreatePRs after successful PR creation
- Tasks now transition from human_review to done status when PR is created
- Task metadata is updated with prUrl from successful PR result
- Only updates tasks that had successful PR creation (not skipped, error, or alreadyExists)
- Follows exact pattern from TaskDetailModal.tsx for consistency

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 14:07:13 +01:00
Andy b4e6b2fe43 auto-claude: 182-implement-pagination-and-filtering-for-github-pr-l (#1654)
* auto-claude: subtask-1-1 - Add GITHUB_PR_LIST_MORE IPC channel constant

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-1-2 - Add listMorePRs IPC handler for pagination

- Add GITHUB_PR_LIST_MORE handler that accepts cursor parameter
- Update PRListResult interface to include endCursor field
- Update existing GITHUB_PR_LIST handler to also return endCursor
- Both handlers use GraphQL pagination with cursor-based navigation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-1-3 - Update PRListResult type to include endCursor field

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-1-4 - Add listMorePRs method to GitHubAPI interface

Add listMorePRs method for cursor-based pagination to the GitHubAPI
interface and createGitHubAPI implementation in preload. Also update
browser-mock.ts to include the new method for type consistency.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-2-1 - Add sortBy option to PRFilterState interface and DEFAULT_FILTERS

- Added PRSortOption type with 'newest' | 'oldest' | 'largest' options
- Added sortBy field to PRFilterState interface
- Set default sortBy to 'newest' in DEFAULT_FILTERS

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-2-2 - Add sorting logic to filteredPRs useMemo in usePRF

* auto-claude: subtask-2-4 - Add loadMore function, endCursor state, and isLoadingMore state

- Add isLoadingMore state to track pagination loading
- Add endCursor state to track pagination cursor
- Add loadMore function for cursor-based pagination
- Update UseGitHubPRsResult interface with new properties
- Store endCursor from fetchPRs API response
- Reset endCursor when project changes
- Batch preload review results for newly loaded PRs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-3-1 - Add sort dropdown to PRFilterBar using FilterDropdown

- Add SortDropdown component with single-select behavior for sorting PRs
- Add SORT_OPTIONS constant with newest/oldest/largest options
- Add onSortChange prop to PRFilterBarProps interface
- Import ArrowUpDown, Clock, FileCode icons from lucide-react
- Import PRSortOption type from usePRFiltering hook
- Update GitHubPRs.tsx to pass setSortBy as onSortChange prop
- Add i18n translations for sort labels (en/fr)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-3-2 - Add onLoadMore and isLoadingMore props to PRList component

Add pagination props to PRListProps interface:
- onLoadMore: Optional callback to load more PRs when hasMore is true
- isLoadingMore: Optional boolean to track loading state for pagination

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-3-3 - Replace status indicator text with Load More button

- Add Load More button component to PRList when hasMore is true
- Show loading spinner with "Loading..." text when isLoadingMore is true
- Keep "All PRs loaded" text when all PRs are displayed
- Add prReview.loadMore and prReview.loadingMore translation keys
- Import Button and Loader2 components

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-3-4 - Wire up new props in GitHubPRs.tsx parent component

- Add loadMore and isLoadingMore to useGitHubPRs destructuring
- Pass loadMore as onLoadMore prop to PRList component
- Pass isLoadingMore to PRList component for loading state
- setSortBy already wired to PRFilterBar's onSortChange

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-4-1 - Add English translation keys for sortBy, sortNewest, sortOldest, sortLargest, loadMore, loadingMore

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-4-2 - Add French translation keys for sortBy, sortNewest

Adds French translations for pagination and sorting UI elements:
- sort.sortBy: "Trier par"
- sort.sortNewest: "Plus récent"
- sort.sortOldest: "Plus ancien"
- sort.sortLargest: "Plus grand"
- pagination.loadMore: "Charger plus"
- pagination.loadingMore: "Chargement..."

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix ruff formatting and Windows dataclass import error

- Apply ruff formatting to parallel_orchestrator_reviewer.py (3 long lines)
- Apply ruff formatting to pydantic_models.py (Field on single line)
- Fix Windows test collection error: register module in sys.modules before
  exec_module so dataclass decorator can find it

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix PR review findings: dedup mapping, race conditions, unused i18n keys

- Extract shared mapGraphQLPRToData helper to eliminate duplicated PR
  mapping logic between listPRs and listMorePRs handlers
- Add staleness checks to loadMore using generation counter and
  projectId ref to prevent race conditions with refresh and project
  switching
- Reset isLoadingMore on project change to prevent stuck loading state
- Remove unused common.sort.* and common.pagination.* translation keys
  from en/fr locale files (code uses prReview.* keys instead)
- Align endCursor type to string | null in preload PRListResult
- Preserve sortBy preference when clearing filters for consistency
  with hasActiveFilters

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix PR review findings: dedup PR handlers, stable sort order

- Extract fetchPRsFromGraphQL helper to deduplicate listPRs/listMorePRs handlers
- Add secondary sort key (createdAt) to 'largest' sort for stable ordering

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix PR review findings: deduplication and keyboard navigation

- Add deduplication by PR number when appending paginated PRs to prevent
  duplicates if a PR shifts position between pagination requests
- Add keyboard navigation to SortDropdown (ArrowUp/Down, Enter, Space, Escape)
  matching the pattern used in FilterDropdown for accessibility consistency

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix follow-up review findings: pagination, sort, keyboard UX

- Preserve pagination state on failure response to allow retry
- Pre-compute timestamps before sorting to avoid Date object creation
- Add scrollIntoView for keyboard-focused items in FilterDropdown
- Focus current selection when SortDropdown opens for better keyboard UX

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Test User <test@example.com>
2026-02-04 14:06:49 +01:00
Andy d9cd300fee auto-claude: 181-add-expand-button-for-long-task-descriptions (#1653)
* auto-claude: subtask-1-1 - Add expandable description container with toggle button

- Add isExpanded and hasOverflow state for expand/collapse functionality
- Add useLayoutEffect to detect content overflow (scrollHeight > clientHeight)
- Apply max-h-[200px] with overflow-hidden when collapsed
- Add gradient overlay at bottom when content is truncated
- Add centered ghost button with ChevronDown/ChevronUp icons
- Add i18n translations for showMore/showLess in en and fr

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: remove unrelated changes from branch (qa-requested)

Reset files that were not related to the expand button feature back to
their develop branch state:
- .gitignore
- apps/backend/agents/ (base.py, coder.py, planner.py, session.py)
- apps/backend/core/ (client.py, simple_client.py)
- apps/frontend/src/renderer/App.tsx
- apps/frontend/src/renderer/components/AuthStatusIndicator.tsx
- apps/frontend/src/renderer/components/KanbanBoard.tsx
- apps/frontend/src/renderer/stores/task-store.ts
- apps/frontend/src/shared/i18n/locales/*/common.json
- tests/test_auth.py
- tests/test_issue_884_plan_schema.py

The expand button feature implementation remains intact.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: resolve CI failures in Python tests and lint

- Fix test_integration_phase4.py: Register module in sys.modules before
  exec_module to allow dataclass decorator to find module by name
- Fix ruff format issues in parallel_orchestrator_reviewer.py: Break
  long f-string lines for logger.error and RuntimeError calls
- Fix ruff format issues in pydantic_models.py: Combine Field description
  on single line

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: resolve PR review findings - reset expand state, fix test mocks, remove dead code

- Reset isExpanded when switching tasks to prevent stale expanded state leaking between tasks
- Fix all remaining get_token_from_keychain mock signatures to accept _config_dir parameter
- Remove disabled old orchestrator code block in parallel_orchestrator_reviewer.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: resolve PR review critical and medium issues

- Restore missing constants in base.py that coder.py imports
  (MAX_CONCURRENCY_RETRIES, INITIAL_RETRY_DELAY_SECONDS, MAX_RETRY_DELAY_SECONDS)
- Fix test_issue_884_plan_schema.py mock return types to match
  run_agent_session 3-tuple signature (str, str, dict)
- Add accessibility attributes to expand/collapse button in TaskMetadata.tsx
  (aria-expanded, aria-controls, aria-hidden on icons, id on content)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: restore loadClaudeProfiles() and add trailing newline to .gitignore

- Restore loadClaudeProfiles() call in App.tsx initial load useEffect
  to fix onboarding detection for OAuth-only users
- Add trailing newline to .gitignore per POSIX convention

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Test User <test@example.com>
2026-02-04 14:06:40 +01:00
VDT-91 f5a7e26d99 fix(terminal): resolve text alignment issues on expand/minimize (#1650)
* fix(terminal): force PTY resize on mount and add auto-correction

Root cause: Architecture mismatch between PTY lifecycle (persists in main
process) and xterm lifecycle (destroyed/recreated on expand/minimize).
When terminal remounts, PTY keeps old dimensions but new xterm assumes
they match.

Changes:
- Force PTY resize on terminal mount/creation to ensure PTY matches xterm
- Add auto-correction to checkDimensionMismatch() with cooldown to fix
  any detected mismatches automatically
- Add validation and error handling to resizePty() in pty-manager
- Revert console.log to debugLog for production readiness

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(terminal): add IPC acknowledgment, platform-specific timing, and correction monitoring

- Change resizeTerminal from fire-and-forget to invoke/handle pattern
  so renderer gets confirmation of resize success/failure
- Add platform detection via contextBridge (isWindows, isMacOS, isLinux, isUnix)
- Use shorter grace periods on Unix (100ms) vs Windows (500ms) since
  Unix PTY resize is much faster than Windows ConPTY
- Track auto-correction frequency and log warning if >5 corrections
  occur per minute, indicating potential deeper sync issues
- Update all 4 resizeTerminal call sites to handle Promise and log failures

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(terminal): prevent stale closures in dimension handling

- Read xterm dimensions from ref instead of React state to avoid stale closures
- Fix onCreated callback using potentially outdated ptyDimensions
- Fix expansion effect using old cols/rows after fit()
- Fix post-PTY creation timeout using stale dimensions
- Change warning threshold from > to >= for consistency

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(terminal): only update lastPtyDimensionsRef after successful resize

Previously, lastPtyDimensionsRef was updated optimistically before the
async resizeTerminal() call completed. If the resize failed, the ref
would hold incorrect dimensions, causing future resize attempts with
the same target dimensions to be incorrectly skipped.

Now the ref is only updated after resizeTerminal() succeeds, and
reverted to previous dimensions on failure. This ensures dimension
mismatches aren't masked by failed resize operations.

Fixed in 4 locations:
- Auto-correction path
- onResize callback
- onCreated (PTY creation)
- performFit (expansion)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: resolve CI failures from outdated develop branch

- Fix Ruff formatting in parallel_orchestrator_reviewer.py and pydantic_models.py
- Fix test_integration_phase4.py to register module in sys.modules before
  exec_module for Python 3.12+ dataclass compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address PR review feedback - race condition and platform detection

- Fix race condition in concurrent resize calls using sequence numbers
  to prevent stale dimension corruption when calls complete out-of-order
- Use consistent platform detection via os-detection.ts module instead
  of window.platform for codebase consistency
- Extract duplicated resizeTerminal promise handling to helper function
  resizePtyWithTracking() reducing code from 4 occurrences to 1
- Capture setTimeout ID for post-creation timeout to enable cleanup
- Add proper cleanup in unmount effect for the timeout ref

Addresses all blocking issues from Auto Claude PR Review:
- NEW-001 [HIGH]: Race condition in concurrent resize calls
- 47ffdb7e4a98 [HIGH]: Inconsistent platform detection
- eabaccf549e4 [MEDIUM]: Duplicated resizeTerminal promise handling
- 7821024a350c [LOW]: Uncleaned timeout in onCreated callback

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
2026-02-04 12:18:15 +01:00
VDT-91 5f63daa3cc fix(windows): use full path to where.exe for reliable executable lookup (#1659)
* fix(windows): use full path to where.exe for reliable executable lookup

Use full path to where.exe (C:\Windows\System32\where.exe) instead of
relying on it being in PATH. This fixes issues in restricted environments
or when Electron doesn't inherit the full system PATH.

Changes:
- Export getWhereExePath() from windows-paths.ts as single source of truth
- Update getWhichCommand() in paths.ts to use the shared helper
- Fix shell injection vulnerability in release-handlers.ts by using
  execFileSync with array arguments instead of string template
- Standardize SystemRoot env var fallback (check both SystemRoot and
  SYSTEMROOT variants) for consistency across all usages

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: resolve CI failures from outdated develop branch

- Fix Ruff formatting in parallel_orchestrator_reviewer.py and pydantic_models.py
- Fix test_integration_phase4.py to register module in sys.modules before
  exec_module for Python 3.12+ dataclass compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
2026-02-04 12:18:02 +01:00
VDT-91 e6e8da17c8 fix: resolve ideation stuck at 3/6 types bug (#1660)
* fix: resolve ideation stuck at 3/6 types bug

Root causes identified and fixed:

1. Missing agent_type="ideation" in generator.py
   - Was defaulting to "coder" which loads MCP servers
   - MCP servers caused 60-second timeout delays per ideation type
   - Added agent_type="ideation" to both create_client() calls

2. No timeout protection on asyncio.gather() in runner.py
   - One stuck task could block forever
   - Added 5-minute timeout with proper error handling

3. Hardcoded totalTypes=7 in agent-queue.ts
   - There are exactly 6 ideation types, not 7
   - Progress calculation was always wrong (3/7 vs 3/6)

4. Log buffer limited to 100 lines in ideation-store.ts
   - Error messages were being truncated
   - Increased to 500 lines for better debugging

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: properly cancel asyncio tasks on ideation timeout

Prevents resource leaks by explicitly creating tasks with
asyncio.create_task() and cancelling them when the 5-minute
timeout is reached. This ensures orphaned tasks don't continue
consuming API calls or writing files after timeout.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: resolve CI failures and address PR review feedback

- Fix timeout handling in ideation runner to preserve completed results
  instead of discarding all results on timeout (HIGH priority feedback)
- Extract IDEATION_TIMEOUT_SECONDS constant to replace magic number
- Derive totalTypes from --types argument instead of hardcoding 6
- Extract MAX_LOG_ENTRIES constant from magic number 500
- Fix Ruff formatting in parallel_orchestrator_reviewer.py and pydantic_models.py
- Fix test_integration_phase4.py to register module in sys.modules before
  exec_module for Python 3.12+ dataclass compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 12:17:36 +01:00
Andy 9317148b6b Clarify Local and Origin Branch Distinction (#1652)
* auto-claude: subtask-1-1 - Add BranchInfo type to shared types for structured branch data

- Add BranchType union type ('local' | 'remote') for branch classification
- Add BranchInfo interface with name, type, displayName, and optional isCurrent
- Add getGitBranchesWithInfo API method to ElectronAPI interface
- Keep existing getGitBranches for backward compatibility during migration
- Add mock implementation for browser testing

* auto-claude: subtask-2-1 - Update getGitBranches() to return structured BranchInfo[]

- Add new getGitBranchesWithInfo() function that returns BranchInfo[] with type indicators (local/remote)
- Keep both local and remote versions when a branch exists in both places (no deduplication)
- Add isCurrent indicator for the currently checked out branch
- Register new IPC handler GIT_GET_BRANCHES_WITH_INFO for the new function
- Keep existing getGitBranches() for backward compatibility (marked deprecated)
- Update preload API to expose the new method to renderer

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-2-2 - Add useLocalBranch option to worktree creation

Added useLocalBranch?: boolean option to CreateTerminalWorktreeRequest type.
When true, the worktree creation logic skips auto-switching from local branch
to origin/branch, allowing users to preserve gitignored files (.env, configs)
that may not exist on remote branches.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-3-1 - Add English translations for branch type labels

Added i18n keys for branch type labels and group headers:
- terminal.json: worktree.branchGroups.local/remote, worktree.branchType.local/remote
- tasks.json: wizard.gitOptions.branchGroups.local/remote, wizard.gitOptions.branchType.local/remote

These translations will be used to display visual indicators distinguishing
local branches from remote branches in branch selection dropdowns.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-3-2 - Add French translations for branch type labels and group headers

Added French translations for:
- terminal.json: worktree.branchGroups.local/remote, worktree.branchType.local/remote
- tasks.json: wizard.gitOptions.branchGroups.local/remote, wizard.gitOptions.branchType.local/remote

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-4-1 - Extend Combobox component to support option groups

- Add 'group' property to ComboboxOption for grouping options by category
- Add 'icon' property for displaying icons before the label (e.g., GitBranch)
- Add 'badge' property for displaying badges after the label
- Render group headers with visual separation when consecutive options have different groups
- Display icon and badge in trigger button when option is selected
- Maintain keyboard navigation across groups

This enables branch selection dropdowns to group by Local/Remote branches
with visual type indicators.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-4-2 - Update CreateWorktreeDialog to use grouped branch options

- Switch from getGitBranches to getGitBranchesWithInfo for structured branch data
- Group branches by type (Local Branches, Remote Branches) in dropdown
- Add icons (GitBranch for local, Cloud for remote) to branch options
- Add colored badges (green for local, blue for remote) as type indicators
- Pass useLocalBranch flag when creating worktree from a local branch
- This preserves gitignored files (.env, configs) when using local branches

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-4-3 - Update TaskCreationWizard to use grouped branch options

- Switch from getGitBranches to getGitBranchesWithInfo for structured BranchInfo[] data
- Group branches by type (Local Branches, Remote Branches) with visual headers
- Add icons (GitBranch for local, Cloud for remote) to each branch option
- Add colored badges (green for local, blue for remote) as type indicators
- Add isSelectedBranchLocal memo to track if selected branch is local
- Pass useLocalBranch: true when creating task from local branch to preserve gitignored files
- Add useLocalBranch field to TaskMetadata type

* auto-claude: subtask-5 - Consolidate branch selection with shared utility

- Create buildBranchOptions() utility in branch-utils.tsx for consistent
  branch display across all branch selectors
- Refactor TaskCreationWizard to use shared utility (~75 lines removed)
- Refactor CreateWorktreeDialog to use shared utility (~75 lines removed)
- Update GitHubIntegration to use Combobox with shared utility instead of
  custom BranchSelector component (~140 lines removed)
- Add translations for GitHub settings branch selector (en/fr)
- Fix: Local default branch now appears in dropdown (was filtered out)
- Fix: "Use project default" option now shows Local/Remote badge

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: update README to v2.7.6-beta.1 [skip ci]

* fix: address all PR review findings for branch distinction feature

- Remove unused exports (createBranchTypeBadge, getBranchIcon) from branch-utils
- Extract badge styling constants (BADGE_BASE_CLASSES, LOCAL/REMOTE_BADGE_CLASSES)
- Thread useLocalBranch flag from frontend through backend worktree creation
- Consolidate branch group/type i18n keys into common.json namespace
- Rename BranchType → GitBranchType, BranchInfo → GitBranchDetail for consistency
- Fix incorrect GitBranchInfo → GitBranchDetail rename in changelog API type

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: trailing commas in JSON and unsorted Python imports

- Remove trailing commas in settings.json and tasks.json (en/fr) that
  were left after removing branchGroups/branchType sections
- Fix ruff I001 import sorting in build_commands.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: apply ruff format to setup.py and worktree.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Test User <test@example.com>
2026-02-04 11:21:35 +01:00
Andy 4730206214 auto-claude: 186-set-default-dark-mode-on-startup (#1656)
* auto-claude: subtask-1-1 - Update DEFAULT_APP_SETTINGS.theme to dark

Change default theme from 'system' to 'dark' so the app starts in dark mode
by default on new installations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: update test to expect dark as default theme

Update the settings:get handler test to expect 'dark' as the
default theme instead of 'system' to match the new default.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 11:20:11 +01:00
Andy ae703be9f3 auto-claude: subtask-1-1 - Add min-h-0 to enable scrolling in Roadmap tabs (#1655)
Fix scrolling in Roadmap Phases tab and other tabs by adding min-h-0
to flex containers. This is a classic flexbox fix where flex items
won't shrink below their content's minimum height without min-h-0.

Changes:
- Roadmap.tsx: Add min-h-0 to content wrapper div
- RoadmapTabs.tsx: Add min-h-0 to all TabsContent elements (kanban,
  phases, features, priorities)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 11:19:47 +01:00
kaigler 5293fb3996 fix: XState status lifecycle & cross-project contamination fixes (#1647)
* fix: XState status lifecycle & cross-project contamination fixes (#1646)

Fixes 7 interrelated bugs from the XState task state machine migration (PR #1575):

1. Cross-project task contamination: Added projectId to all agent event
   signatures, threaded through the entire pipeline from execution-handlers
   through agent-process to event handlers. findTaskAndProject now scopes
   search by projectId.

2. "Incomplete" badge on plan review tasks: Added PLANNING_COMPLETE to
   TERMINAL_EVENTS set and fixed handleProcessExited to only mark
   unexpected for non-zero exit codes.

3. Backend qa.py racing with XState: Removed direct status writes from
   qa.py - XState is now the sole owner of status transitions.

4. Plan file overwrite by planner agent: Added re-stamp mechanism in file
   watcher to re-persist XState state when backend overwrites plan file.

5. QA tasks in wrong column after project switch: Fixed persistPlanPhaseSync
   phase-to-status mapping (qa_review/qa_fixing -> ai_review).

6. updateTaskStatus not applying reviewReason: Added reviewReason to task
   spread and updated skip condition to check both status and reviewReason.

7. Task stuck in "In Progress" after planning with requireReviewBeforeCoding:
   Added XState settled-state guard in execution-progress handler to prevent
   persistPlanPhaseSync from overwriting XState's status on process exit.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address code review findings — deduplicate constants, remove fallback, debug logging

- HIGH: findTaskAndProject no longer falls back to all-project search when
  projectId is explicitly provided (prevents cross-project contamination)
- MEDIUM: Extract XSTATE_TO_PHASE, XSTATE_SETTLED_STATES, and mapStateToLegacy
  into shared task-state-utils.ts module (eliminates duplicate constants)
- MEDIUM: Use typed TaskStateName const array derived from machine states
- MEDIUM: Add tests for non-existent projectId and warning log assertion
- LOW: Add console.warn when provided projectId not found in projects list
- LOW: Convert verbose console.log statements to console.debug in
  agent-events-handlers.ts and task-state-manager.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address self-review findings — clarify error in settled states, add guard tests

- HIGH: Added clarifying comment explaining why `error` is correctly in
  XSTATE_SETTLED_STATES (USER_RESUMED transitions synchronously to `coding`
  before new agent events arrive, so the guard no longer blocks)
- MEDIUM: Added 15 tests for settled state guard logic covering all state
  combinations, XSTATE_TO_PHASE completeness, and guard behavior

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
2026-02-02 20:34:05 +01:00
Test User 8030c59f25 hotfix: fix test_integration_phase4 dataclass import error
Register parallel_orchestrator_reviewer module in sys.modules before
exec_module() to fix Python dataclass decorator resolution failure.

The @dataclass decorator added in a2c3507d6 requires the module to be
in sys.modules during execution. Also applies ruff formatting fixes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 19:51:46 +01:00
Test User ab91f7baf8 fix: restore version 2.7.6-beta.2 after accidental revert
The hotfix commit a2c3507d6 accidentally reverted the version back to
2.7.5. This restores the correct 2.7.6-beta.2 version and associated
package.json changes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 10:41:52 +01:00
Test User a2c3507d61 hotfix/pr-review-bug 2026-02-02 10:28:14 +01:00
AndyMik90 26134c289c chore: bump version to 2.7.6-beta.2 2026-01-30 22:13:30 +01:00
StillKnotKnown 303b3781a4 fix: bundle xstate in main process for packaged Electron app (#1637)
Add xstate to the externalizeDepsPlugin exclude list in
electron.vite.config.ts to ensure it's bundled into the main
process during build. This fixes ERR_MODULE_NOT_FOUND crashes
in packaged apps (AppImage, deb, dmg, Windows exe) where xstate
is not available in node_modules at runtime.

Resolves issue where the Auto-Claude desktop app crashes on
startup after the XState v5 migration (PR #1575).

Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
2026-01-30 21:58:21 +01:00
249 changed files with 6697 additions and 1444 deletions
-1
View File
@@ -174,4 +174,3 @@ OPUS_ANALYSIS_AND_IDEAS.md
/shared_docs
logs/security/
Agents.md
packages/
+11
View File
@@ -40,6 +40,17 @@ Auto Claude is a desktop application (+ CLI) where users describe a goal and AI
**PR target** — Always target the `develop` branch for PRs to AndyMik90/Auto-Claude, NOT `main`.
## Memory (claude-mem)
This project uses claude-mem for persistent memory across sessions. MCP search tools are available — use them proactively:
- **Before modifying code** — Search for past work on the same files/features: `search(query="<file or feature>")`. Check if there are known bugs, decisions, or patterns to follow.
- **When debugging** — Search for prior encounters with the same error or symptom: `search(query="<error message>", type="bugfix")`.
- **When making architectural decisions** — Check for past decisions: `search(query="<topic>", type="decision")`.
- **When resuming work** — Use `timeline(anchor=<recent_id>)` to understand where things left off.
Follow the 3-layer workflow: `search` (cheap index) → `timeline` (context) → `get_observations` (full details only for relevant IDs). Never fetch full details without filtering first.
## Project Structure
```
+7 -7
View File
@@ -35,18 +35,18 @@
> ⚠️ Beta releases may contain bugs and breaking changes. [View all releases](https://github.com/AndyMik90/Auto-Claude/releases)
<!-- BETA_VERSION_BADGE -->
[![Beta](https://img.shields.io/badge/beta-2.7.6--beta.1-orange?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6-beta.1)
[![Beta](https://img.shields.io/badge/beta-2.7.6--beta.2-orange?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6-beta.2)
<!-- BETA_VERSION_BADGE_END -->
<!-- BETA_DOWNLOADS -->
| Platform | Download |
|----------|----------|
| **Windows** | [Auto-Claude-2.7.6-beta.1-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-beta.1-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-darwin-arm64.dmg) |
| **macOS (Intel)** | [Auto-Claude-2.7.6-beta.1-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-darwin-x64.dmg) |
| **Linux** | [Auto-Claude-2.7.6-beta.1-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Auto-Claude-2.7.6-beta.1-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-linux-amd64.deb) |
| **Linux (Flatpak)** | [Auto-Claude-2.7.6-beta.1-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-linux-x86_64.flatpak) |
| **Windows** | [Auto-Claude-2.7.6-beta.2-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-beta.2-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-darwin-arm64.dmg) |
| **macOS (Intel)** | [Auto-Claude-2.7.6-beta.2-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-darwin-x64.dmg) |
| **Linux** | [Auto-Claude-2.7.6-beta.2-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Auto-Claude-2.7.6-beta.2-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-linux-amd64.deb) |
| **Linux (Flatpak)** | [Auto-Claude-2.7.6-beta.2-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-linux-x86_64.flatpak) |
<!-- BETA_DOWNLOADS_END -->
> All releases include SHA256 checksums and VirusTotal scan results for security verification.
+1 -1
View File
@@ -19,5 +19,5 @@ Quick Start:
See README.md for full documentation.
"""
__version__ = "2.7.6-beta.1"
__version__ = "2.7.6-beta.2"
__author__ = "Auto Claude Team"
+74
View File
@@ -6,6 +6,7 @@ Shared imports, types, and constants used across agent modules.
"""
import logging
import re
# Configure logging
logger = logging.getLogger(__name__)
@@ -20,3 +21,76 @@ INITIAL_RETRY_DELAY_SECONDS = (
2 # Initial retry delay (doubles each retry: 2s, 4s, 8s, 16s, 32s)
)
MAX_RETRY_DELAY_SECONDS = 32 # Cap retry delay at 32 seconds
# Pause file constants for intelligent error recovery
# These files signal pause/resume between frontend and backend
RATE_LIMIT_PAUSE_FILE = "RATE_LIMIT_PAUSE" # Created when rate limited
AUTH_FAILURE_PAUSE_FILE = "AUTH_PAUSE" # Created when auth fails
RESUME_FILE = "RESUME" # Created by frontend to signal resume
# Maximum time to wait for rate limit reset (2 hours)
# If reset time is beyond this, task should fail rather than wait indefinitely
MAX_RATE_LIMIT_WAIT_SECONDS = 7200
# Wait intervals for pause/resume checking
RATE_LIMIT_CHECK_INTERVAL_SECONDS = (
30 # Check for RESUME file every 30 seconds during rate limit wait
)
AUTH_RESUME_CHECK_INTERVAL_SECONDS = 10 # Check for re-authentication every 10 seconds
AUTH_RESUME_MAX_WAIT_SECONDS = 86400 # Maximum wait for re-authentication (24 hours)
def sanitize_error_message(error_message: str, max_length: int = 500) -> str:
"""
Sanitize error messages to remove potentially sensitive information.
Redacts:
- API keys (sk-..., key-...)
- Bearer tokens
- Token/secret values
Args:
error_message: The raw error message to sanitize
max_length: Maximum length to truncate to (default 500)
Returns:
Sanitized and truncated error message
"""
if not error_message:
return ""
# Redact patterns that look like API keys or tokens
# Pattern: sk-... (OpenAI/Anthropic keys like sk-ant-api03-...)
sanitized = re.sub(
r"\bsk-[a-zA-Z0-9._\-]{20,}\b", "[REDACTED_API_KEY]", error_message
)
# Pattern: key-... (generic API keys)
sanitized = re.sub(r"\bkey-[a-zA-Z0-9._\-]{20,}\b", "[REDACTED_API_KEY]", sanitized)
# Pattern: Bearer ... (bearer tokens)
sanitized = re.sub(
r"\bBearer\s+[a-zA-Z0-9._\-]{20,}\b", "Bearer [REDACTED_TOKEN]", sanitized
)
# Pattern: token= or token: followed by long strings
sanitized = re.sub(
r"(token[=:]\s*)[a-zA-Z0-9._\-]{20,}\b",
r"\1[REDACTED_TOKEN]",
sanitized,
flags=re.IGNORECASE,
)
# Pattern: secret= or secret: followed by strings
sanitized = re.sub(
r"(secret[=:]\s*)[a-zA-Z0-9._\-]{20,}\b",
r"\1[REDACTED_SECRET]",
sanitized,
flags=re.IGNORECASE,
)
# Truncate to max length
if len(sanitized) > max_length:
sanitized = sanitized[:max_length] + "..."
return sanitized
+353
View File
@@ -6,8 +6,11 @@ Main autonomous agent loop that runs the coder agent to implement subtasks.
"""
import asyncio
import json
import logging
import os
import re
from datetime import datetime, timedelta
from pathlib import Path
from core.client import create_client
@@ -57,11 +60,19 @@ from ui import (
)
from .base import (
AUTH_FAILURE_PAUSE_FILE,
AUTH_RESUME_CHECK_INTERVAL_SECONDS,
AUTH_RESUME_MAX_WAIT_SECONDS,
AUTO_CONTINUE_DELAY_SECONDS,
HUMAN_INTERVENTION_FILE,
INITIAL_RETRY_DELAY_SECONDS,
MAX_CONCURRENCY_RETRIES,
MAX_RATE_LIMIT_WAIT_SECONDS,
MAX_RETRY_DELAY_SECONDS,
RATE_LIMIT_CHECK_INTERVAL_SECONDS,
RATE_LIMIT_PAUSE_FILE,
RESUME_FILE,
sanitize_error_message,
)
from .memory_manager import debug_memory_system_status, get_graphiti_context
from .session import post_session_processing, run_agent_session
@@ -76,6 +87,219 @@ from .utils import (
logger = logging.getLogger(__name__)
def _check_and_clear_resume_file(
resume_file: Path,
pause_file: Path,
fallback_resume_file: Path | None = None,
) -> bool:
"""
Check if resume file exists and clean up both resume and pause files.
Also checks a fallback location (main project spec dir) in case the frontend
couldn't find the worktree and only wrote the RESUME file there.
Args:
resume_file: Path to RESUME file
pause_file: Path to pause file (RATE_LIMIT_PAUSE or AUTH_PAUSE)
fallback_resume_file: Optional fallback RESUME file path (e.g. main project spec dir)
Returns:
True if resume file existed (early resume), False otherwise
"""
found = resume_file.exists()
# Check fallback location if primary not found
if not found and fallback_resume_file and fallback_resume_file.exists():
found = True
try:
fallback_resume_file.unlink(missing_ok=True)
except OSError as e:
logger.debug(f"Error cleaning up fallback resume file: {e}")
if found:
try:
resume_file.unlink(missing_ok=True)
pause_file.unlink(missing_ok=True)
except OSError as e:
logger.debug(
f"Error cleaning up resume files: {e} (resume: {resume_file}, pause: {pause_file})"
)
return True
return False
async def wait_for_rate_limit_reset(
spec_dir: Path,
wait_seconds: float,
source_spec_dir: Path | None = None,
) -> bool:
"""
Wait for rate limit reset with periodic checks for resume/cancel.
Args:
spec_dir: Spec directory to check for RESUME file
wait_seconds: Maximum time to wait in seconds
source_spec_dir: Optional main project spec dir as fallback for RESUME file
Returns:
True if resumed early, False if waited full duration
"""
loop = asyncio.get_running_loop()
start_time = loop.time()
resume_file = spec_dir / RESUME_FILE
pause_file = spec_dir / RATE_LIMIT_PAUSE_FILE
fallback_resume = (source_spec_dir / RESUME_FILE) if source_spec_dir else None
while True:
# Check elapsed time using loop.time() to avoid drift
elapsed = max(0, loop.time() - start_time) # Ensure non-negative
if elapsed >= wait_seconds:
break
# Check if user requested resume
if _check_and_clear_resume_file(resume_file, pause_file, fallback_resume):
return True
# Wait for next check interval or remaining time
sleep_time = min(RATE_LIMIT_CHECK_INTERVAL_SECONDS, wait_seconds - elapsed)
await asyncio.sleep(sleep_time)
# Clean up pause file after wait completes
try:
pause_file.unlink(missing_ok=True)
except OSError as e:
logger.debug(f"Error cleaning up pause file {pause_file}: {e}")
return False
async def wait_for_auth_resume(
spec_dir: Path,
source_spec_dir: Path | None = None,
) -> None:
"""
Wait for user re-authentication signal.
Blocks until:
- RESUME file is created (user completed re-auth in UI)
- AUTH_PAUSE file is deleted (alternative resume signal)
- Maximum wait timeout is reached (24 hours)
Args:
spec_dir: Spec directory to monitor for signal files
source_spec_dir: Optional main project spec dir as fallback for RESUME file
"""
loop = asyncio.get_running_loop()
start_time = loop.time()
resume_file = spec_dir / RESUME_FILE
pause_file = spec_dir / AUTH_FAILURE_PAUSE_FILE
fallback_resume = (source_spec_dir / RESUME_FILE) if source_spec_dir else None
while True:
# Check elapsed time using loop.time() to avoid drift
elapsed = max(0, loop.time() - start_time) # Ensure non-negative
if elapsed >= AUTH_RESUME_MAX_WAIT_SECONDS:
break
# Check for resume signals
if (
_check_and_clear_resume_file(resume_file, pause_file, fallback_resume)
or not pause_file.exists()
):
# If pause file was deleted externally, still clean up resume file if it exists
if not pause_file.exists():
try:
resume_file.unlink(missing_ok=True)
except OSError as e:
logger.debug(f"Error cleaning up resume file {resume_file}: {e}")
return
await asyncio.sleep(AUTH_RESUME_CHECK_INTERVAL_SECONDS)
# Timeout reached - clean up and return
print_status(
"Authentication wait timeout reached (24 hours) - resuming with original credentials",
"warning",
)
try:
pause_file.unlink(missing_ok=True)
except OSError as e:
logger.debug(f"Error cleaning up pause file {pause_file} after timeout: {e}")
def parse_rate_limit_reset_time(error_info: dict | None) -> int | None:
"""
Parse rate limit reset time from error info.
Attempts to extract reset time from various formats in error messages.
TIMEZONE ASSUMPTIONS:
- "in X minutes/hours" patterns are timezone-safe (relative time)
- "at HH:MM" patterns assume LOCAL timezone, which is reasonable since:
1. The user sees timestamps in their local timezone
2. The wait calculation happens locally using datetime.now()
3. If the API returns UTC "at" times, this would need adjustment
(but Claude API typically returns relative times like "in X minutes")
Args:
error_info: Error info dict with 'message' key
Returns:
Unix timestamp of reset time, or None if not parseable
"""
if not error_info:
return None
message = error_info.get("message", "")
# Try to find patterns like "resets at 3:00 PM" or "in 5 minutes"
# Pattern: "in X minutes/hours" (timezone-safe - relative time)
in_time_match = re.search(r"in\s+(\d+)\s*(minute|hour|min|hr)s?", message, re.I)
if in_time_match:
amount = int(in_time_match.group(1))
unit = in_time_match.group(2).lower()
if unit.startswith("hour") or unit.startswith("hr"):
delta = timedelta(hours=amount)
else:
delta = timedelta(minutes=amount)
return int((datetime.now() + delta).timestamp())
# Pattern: "at HH:MM" (12 or 24 hour)
at_time_match = re.search(r"at\s+(\d{1,2}):(\d{2})(?:\s*(am|pm))?", message, re.I)
if at_time_match:
try:
hour = int(at_time_match.group(1))
minute = int(at_time_match.group(2))
meridiem = at_time_match.group(3)
# Validate hour range when meridiem is present
# Hours should be 1-12 for AM/PM format
if meridiem and not (1 <= hour <= 12):
return None
if meridiem:
if meridiem.lower() == "pm" and hour < 12:
hour += 12
elif meridiem.lower() == "am" and hour == 12:
hour = 0
# Validate hour and minute ranges
if not (0 <= hour <= 23 and 0 <= minute <= 59):
return None
now = datetime.now()
reset_time = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if reset_time <= now:
reset_time += timedelta(days=1)
return int(reset_time.timestamp())
except ValueError:
# Invalid time values - return None to fall back to standard retry
return None
# No pattern matched - return None to let caller decide retry behavior
return None
async def run_autonomous_agent(
project_dir: Path,
spec_dir: Path,
@@ -682,6 +906,135 @@ async def run_autonomous_agent(
current_retry_delay * 2, MAX_RETRY_DELAY_SECONDS
)
elif error_info and error_info.get("type") == "rate_limit":
# Rate limit error - intelligent wait for reset
_reset_concurrency_state()
reset_timestamp = parse_rate_limit_reset_time(error_info)
if reset_timestamp:
wait_seconds = reset_timestamp - datetime.now().timestamp()
# Handle negative wait_seconds (reset time in the past)
if wait_seconds <= 0:
print_status(
"Rate limit reset time already passed - retrying immediately",
"warning",
)
status_manager.update(state=BuildState.BUILDING)
await asyncio.sleep(2) # Brief delay before retry
continue
if wait_seconds > MAX_RATE_LIMIT_WAIT_SECONDS:
# Wait time too long - fail the task
print_status("Rate limit wait time too long", "error")
print(
f"Reset time would require waiting {wait_seconds / 3600:.1f} hours"
)
print(
f"Maximum wait is {MAX_RATE_LIMIT_WAIT_SECONDS / 3600:.1f} hours"
)
emit_phase(
ExecutionPhase.FAILED,
"Rate limit wait time exceeds maximum allowed",
)
status_manager.update(state=BuildState.ERROR)
break
# Emit pause phase with reset time for frontend
wait_minutes = wait_seconds / 60
emit_phase(
ExecutionPhase.RATE_LIMIT_PAUSED,
f"Rate limit - resuming in {wait_minutes:.0f} minutes",
reset_timestamp=reset_timestamp,
)
# Create pause file for frontend detection
# Sanitize error message to prevent exposing sensitive data
raw_error = error_info.get("message", "Rate limit reached")
sanitized_error = (
sanitize_error_message(raw_error, max_length=500)
or "Rate limit reached"
)
pause_data = {
"paused_at": datetime.now().isoformat(),
"reset_timestamp": reset_timestamp,
"error": sanitized_error,
}
pause_file = spec_dir / RATE_LIMIT_PAUSE_FILE
pause_file.write_text(json.dumps(pause_data), encoding="utf-8")
print_status(
f"Rate limited - waiting {wait_minutes:.0f} minutes for reset",
"warning",
)
status_manager.update(state=BuildState.PAUSED)
# Wait with periodic checks for resume signal
resumed_early = await wait_for_rate_limit_reset(
spec_dir, wait_seconds, source_spec_dir
)
if resumed_early:
print_status("Resumed early by user", "success")
# Resume execution
emit_phase(ExecutionPhase.CODING, "Resuming after rate limit")
status_manager.update(state=BuildState.BUILDING)
continue # Resume the loop
else:
# Couldn't parse reset time - fall back to standard retry
print_status("Rate limit hit (unknown reset time)", "warning")
print(muted("Will retry with a fresh session..."))
status_manager.update(state=BuildState.ERROR)
await asyncio.sleep(AUTO_CONTINUE_DELAY_SECONDS)
_reset_concurrency_state()
status_manager.update(state=BuildState.BUILDING)
continue
elif error_info and error_info.get("type") == "authentication":
# Authentication error - pause for user re-authentication
_reset_concurrency_state()
emit_phase(
ExecutionPhase.AUTH_FAILURE_PAUSED,
"Re-authentication required",
)
# Create pause file for frontend detection
# Sanitize error message to prevent exposing sensitive data
raw_error = error_info.get("message", "Authentication failed")
sanitized_error = (
sanitize_error_message(raw_error, max_length=500)
or "Authentication failed"
)
pause_data = {
"paused_at": datetime.now().isoformat(),
"error": sanitized_error,
"requires_action": "re-authenticate",
}
pause_file = spec_dir / AUTH_FAILURE_PAUSE_FILE
pause_file.write_text(json.dumps(pause_data), encoding="utf-8")
print()
print("=" * 70)
print(" AUTHENTICATION REQUIRED")
print("=" * 70)
print()
print("OAuth token is invalid or expired.")
print("Please re-authenticate in the Auto Claude settings.")
print()
print("The task will automatically resume once you re-authenticate.")
print()
status_manager.update(state=BuildState.PAUSED)
# Wait for user to complete re-authentication
await wait_for_auth_resume(spec_dir, source_spec_dir)
print_status("Authentication restored - resuming", "success")
emit_phase(ExecutionPhase.CODING, "Resuming after re-authentication")
status_manager.update(state=BuildState.BUILDING)
continue # Resume the loop
else:
# Other errors - use standard retry logic
print_status("Session encountered an error", "error")
+119 -7
View File
@@ -7,6 +7,7 @@ memory updates, recovery tracking, and Linear integration.
"""
import logging
import re
from pathlib import Path
from claude_agent_sdk import ClaudeSDKClient
@@ -34,6 +35,7 @@ from ui import (
print_status,
)
from .base import sanitize_error_message
from .memory_manager import save_session_memory
from .utils import (
find_subtask_in_plan,
@@ -68,6 +70,93 @@ def is_tool_concurrency_error(error: Exception) -> bool:
)
def is_rate_limit_error(error: Exception) -> bool:
"""
Check if an error is a rate limit error (429 or similar).
Rate limit errors occur when the API usage quota is exceeded,
either for session limits or weekly limits.
Args:
error: The exception to check
Returns:
True if this is a rate limit error, False otherwise
"""
error_str = str(error).lower()
# Check for HTTP 429 with word boundaries to avoid false positives
if re.search(r"\b429\b", error_str):
return True
# Check for other rate limit indicators
return any(
p in error_str
for p in [
"limit reached",
"rate limit",
"too many requests",
"usage limit",
"quota exceeded",
]
)
def is_authentication_error(error: Exception) -> bool:
"""
Check if an error is an authentication error (401, token expired, etc.).
Authentication errors occur when OAuth tokens are invalid, expired,
or have been revoked (e.g., after token refresh on another process).
Validation approach:
- HTTP 401 status code is checked with word boundaries to minimize false positives
- Additional string patterns are validated against lowercase error messages
- Patterns are designed to match known Claude API and OAuth error formats
Known false positive risks:
- Generic error messages containing "unauthorized" or "access denied" may match
even if not related to authentication (e.g., file permission errors)
- Error messages containing these keywords in user-provided content could match
- Mitigation: HTTP 401 check provides strong signal; string patterns are secondary
Real-world validation:
- Pattern matching has been tested against actual Claude API error responses
- False positive rate is acceptable given the recovery mechanism (prompt user to re-auth)
- If false positive occurs, user can simply resume without re-authenticating
Args:
error: The exception to check
Returns:
True if this is an authentication error, False otherwise
"""
error_str = str(error).lower()
# Check for HTTP 401 with word boundaries to avoid false positives
if re.search(r"\b401\b", error_str):
return True
# Check for other authentication indicators
# NOTE: "authentication failed" and "authentication error" are more specific patterns
# to reduce false positives from generic "authentication" mentions
return any(
p in error_str
for p in [
"authentication failed",
"authentication error",
"unauthorized",
"invalid token",
"token expired",
"authentication_error",
"invalid_token",
"token_expired",
"not authenticated",
"http 401",
]
)
async def post_session_processing(
spec_dir: Path,
project_dir: Path,
@@ -568,7 +657,18 @@ async def run_agent_session(
except Exception as e:
# Detect specific error types for better retry handling
is_concurrency = is_tool_concurrency_error(e)
error_type = "tool_concurrency" if is_concurrency else "other"
is_rate_limit = is_rate_limit_error(e)
is_auth = is_authentication_error(e)
# Classify error type for appropriate handling
if is_concurrency:
error_type = "tool_concurrency"
elif is_rate_limit:
error_type = "rate_limit"
elif is_auth:
error_type = "authentication"
else:
error_type = "other"
debug_error(
"session",
@@ -579,20 +679,32 @@ async def run_agent_session(
tool_count=tool_count,
)
# Log concurrency errors prominently
# Sanitize error message to remove potentially sensitive data
# Must happen BEFORE printing to stdout, since stdout is captured by the frontend
sanitized_error = sanitize_error_message(str(e))
# Log errors prominently based on type
if is_concurrency:
print("\n⚠️ Tool concurrency limit reached (400 error)")
print(" Claude API limits concurrent tool use in a single request")
print(f" Error: {str(e)[:200]}\n")
print(f" Error: {sanitized_error[:200]}\n")
elif is_rate_limit:
print("\n⚠️ Rate limit reached")
print(" API usage quota exceeded - waiting for reset")
print(f" Error: {sanitized_error[:200]}\n")
elif is_auth:
print("\n⚠️ Authentication error")
print(" OAuth token may be invalid or expired")
print(f" Error: {sanitized_error[:200]}\n")
else:
print(f"Error during agent session: {e}")
print(f"Error during agent session: {sanitized_error}")
if task_logger:
task_logger.log_error(f"Session error: {e}", phase)
task_logger.log_error(f"Session error: {sanitized_error}", phase)
error_info = {
"type": error_type,
"message": str(e),
"message": sanitized_error,
"exception_type": type(e).__name__,
}
return "error", str(e), error_info
return "error", sanitized_error, error_info
+4 -8
View File
@@ -56,14 +56,10 @@ def _apply_qa_update(
"ready_for_qa_revalidation": status == "fixes_applied",
}
# Update plan status to match QA result
# This ensures the UI shows the correct column after QA
if status == "approved":
plan["status"] = "human_review"
plan["planStatus"] = "review"
elif status == "rejected":
plan["status"] = "human_review"
plan["planStatus"] = "review"
# NOTE: Do NOT write plan["status"] or plan["planStatus"] here.
# The frontend XState task state machine owns status transitions.
# Writing status here races with XState's persistPlanStatusAndReasonSync()
# and can clobber the reviewReason field, causing tasks to appear "incomplete".
plan["last_updated"] = datetime.now(timezone.utc).isoformat()
+8 -1
View File
@@ -87,7 +87,10 @@ def handle_build_command(
debug_success,
)
from phase_config import get_phase_model
from prompts_pkg.prompts import get_base_branch_from_metadata
from prompts_pkg.prompts import (
get_base_branch_from_metadata,
get_use_local_branch_from_metadata,
)
from qa_loop import run_qa_validation_loop, should_run_qa
from .utils import print_banner, validate_environment
@@ -203,6 +206,9 @@ def handle_build_command(
base_branch = metadata_branch
debug("run.py", f"Using base branch from task metadata: {base_branch}")
# Check if user requested local branch (preserves gitignored files like .env)
use_local_branch = get_use_local_branch_from_metadata(spec_dir)
if workspace_mode == WorkspaceMode.ISOLATED:
# Keep reference to original spec directory for syncing progress back
source_spec_dir = spec_dir
@@ -213,6 +219,7 @@ def handle_build_command(
workspace_mode,
source_spec_dir=spec_dir,
base_branch=base_branch,
use_local_branch=use_local_branch,
)
# Use the localized spec directory (inside worktree) for AI access
if localized_spec_dir:
+21 -1
View File
@@ -23,6 +23,9 @@ class ExecutionPhase(str, Enum):
QA_FIXING = "qa_fixing"
COMPLETE = "complete"
FAILED = "failed"
# Pause states for intelligent error recovery
RATE_LIMIT_PAUSED = "rate_limit_paused"
AUTH_FAILURE_PAUSED = "auth_failure_paused"
def emit_phase(
@@ -31,8 +34,19 @@ def emit_phase(
*,
progress: int | None = None,
subtask: str | None = None,
reset_timestamp: int | None = None,
profile_id: str | None = None,
) -> None:
"""Emit structured phase event to stdout for frontend parsing."""
"""Emit structured phase event to stdout for frontend parsing.
Args:
phase: The execution phase (e.g., PLANNING, CODING, RATE_LIMIT_PAUSED)
message: Optional message describing the phase state
progress: Optional progress percentage (0-100)
subtask: Optional subtask identifier
reset_timestamp: Optional Unix timestamp for rate limit reset time
profile_id: Optional profile ID that triggered the pause
"""
phase_value = phase.value if isinstance(phase, ExecutionPhase) else phase
payload: dict[str, Any] = {
@@ -48,6 +62,12 @@ def emit_phase(
if subtask is not None:
payload["subtask"] = subtask
if reset_timestamp is not None:
payload["reset_timestamp"] = reset_timestamp
if profile_id is not None:
payload["profile_id"] = profile_id
try:
print(f"{PHASE_MARKER_PREFIX}{json.dumps(payload, default=str)}", flush=True)
except (OSError, UnicodeEncodeError) as e:
+5 -1
View File
@@ -325,6 +325,7 @@ def setup_workspace(
mode: WorkspaceMode,
source_spec_dir: Path | None = None,
base_branch: str | None = None,
use_local_branch: bool = False,
) -> tuple[Path, WorktreeManager | None, Path | None]:
"""
Set up the workspace based on user's choice.
@@ -337,6 +338,7 @@ def setup_workspace(
mode: The workspace mode to use
source_spec_dir: Optional source spec directory to copy to worktree
base_branch: Base branch for worktree creation (default: current branch)
use_local_branch: If True, use local branch directly instead of preferring origin/branch
Returns:
Tuple of (working_directory, worktree_manager or None, localized_spec_dir or None)
@@ -357,7 +359,9 @@ def setup_workspace(
# Ensure timeline tracking hook is installed (once per session)
ensure_timeline_hook_installed(project_dir)
manager = WorktreeManager(project_dir, base_branch=base_branch)
manager = WorktreeManager(
project_dir, base_branch=base_branch, use_local_branch=use_local_branch
)
manager.setup()
# Get or create worktree for THIS SPECIFIC SPEC
+21 -10
View File
@@ -186,9 +186,15 @@ class WorktreeManager:
CLI_TIMEOUT = 60 # 1 minute for CLI commands (gh/glab)
CLI_QUERY_TIMEOUT = 30 # 30 seconds for CLI queries (gh/glab)
def __init__(self, project_dir: Path, base_branch: str | None = None):
def __init__(
self,
project_dir: Path,
base_branch: str | None = None,
use_local_branch: bool = False,
):
self.project_dir = project_dir
self.base_branch = base_branch or self._detect_base_branch()
self.use_local_branch = use_local_branch
self.worktrees_dir = project_dir / ".auto-claude" / "worktrees" / "tasks"
self._merge_lock = asyncio.Lock()
@@ -695,18 +701,23 @@ class WorktreeManager:
else:
# Branch doesn't exist - create new branch from remote or local base
# Determine the start point for the worktree
remote_ref = f"origin/{self.base_branch}"
start_point = self.base_branch # Default to local branch
# Check if remote ref exists and use it as the source of truth
check_remote = self._run_git(["rev-parse", "--verify", remote_ref])
if check_remote.returncode == 0:
start_point = remote_ref
print(f"Creating worktree from remote: {remote_ref}")
if self.use_local_branch:
# User explicitly requested local branch - skip auto-switch to remote
# This preserves gitignored files (.env, configs) that may not exist on remote
print(f"Creating worktree from local branch: {self.base_branch}")
else:
print(
f"Remote ref {remote_ref} not found, using local branch: {self.base_branch}"
)
# Check if remote ref exists and use it as the source of truth
remote_ref = f"origin/{self.base_branch}"
check_remote = self._run_git(["rev-parse", "--verify", remote_ref])
if check_remote.returncode == 0:
start_point = remote_ref
print(f"Creating worktree from remote: {remote_ref}")
else:
print(
f"Remote ref {remote_ref} not found, using local branch: {self.base_branch}"
)
# Create worktree with new branch from the start point
result = self._run_git(
+5
View File
@@ -91,11 +91,14 @@ class IdeationGenerator:
prompt += f"\n{additional_context}\n"
# Create client with thinking budget
# Use agent_type="ideation" to avoid loading unnecessary MCP servers
# which can cause 60-second timeout delays
client = create_client(
self.project_dir,
self.output_dir,
resolve_model_id(self.model),
max_thinking_tokens=self.thinking_budget,
agent_type="ideation",
)
try:
@@ -184,11 +187,13 @@ Common fixes:
Write the fixed JSON to the file now.
"""
# Use agent_type="ideation" for recovery agent as well
client = create_client(
self.project_dir,
self.output_dir,
resolve_model_id(self.model),
max_thinking_tokens=self.thinking_budget,
agent_type="ideation",
)
try:
+36 -6
View File
@@ -28,6 +28,7 @@ from .types import IdeationPhaseResult
# Configuration
MAX_RETRIES = 3
IDEATION_TIMEOUT_SECONDS = 5 * 60 # 5 minutes max for all ideation types
class IdeationOrchestrator:
@@ -173,16 +174,45 @@ class IdeationOrchestrator:
"progress",
)
# Create tasks for all enabled types
ideation_tasks = [
self.output_streamer.stream_ideation_result(
ideation_type, self.phase_executor, MAX_RETRIES
# Create tasks explicitly so we can cancel them on timeout
ideation_task_objs = [
asyncio.create_task(
self.output_streamer.stream_ideation_result(
ideation_type, self.phase_executor, MAX_RETRIES
)
)
for ideation_type in self.enabled_types
]
# Run all ideation types concurrently
ideation_results = await asyncio.gather(*ideation_tasks, return_exceptions=True)
# Run all ideation types concurrently with timeout protection
# 5 minute timeout prevents infinite hangs if one type stalls
try:
ideation_results = await asyncio.wait_for(
asyncio.gather(*ideation_task_objs, return_exceptions=True),
timeout=IDEATION_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
print_status(
"Ideation generation timed out after 5 minutes",
"error",
)
# Cancel all pending tasks to prevent resource leaks
for task in ideation_task_objs:
if not task.done():
task.cancel()
# Wait for cancellation to complete and preserve results from completed tasks
# Tasks that finished before timeout will return their results;
# cancelled tasks will return CancelledError
results_after_cancel = await asyncio.gather(
*ideation_task_objs, return_exceptions=True
)
# Convert CancelledError to timeout exception, preserve completed results
ideation_results = [
Exception("Ideation timed out")
if isinstance(res, asyncio.CancelledError)
else res
for res in results_after_cancel
]
# Process results
for i, result in enumerate(ideation_results):
+2 -6
View File
@@ -12,12 +12,8 @@ The refactored code is now organized as:
- graphiti/search.py - Semantic search logic
- graphiti/schema.py - Graph schema definitions
This facade ensures existing imports continue to work:
from graphiti_memory import GraphitiMemory, is_graphiti_enabled
New code should prefer importing from the graphiti package:
from graphiti import GraphitiMemory
from graphiti.schema import GroupIdMode
Import from this module:
from integrations.graphiti.memory import GraphitiMemory, is_graphiti_enabled, GroupIdMode
For detailed documentation on the memory system architecture and usage,
see graphiti/graphiti.py.
@@ -62,7 +62,7 @@ async def get_graph_hints(
try:
from pathlib import Path
from graphiti_memory import GraphitiMemory, GroupIdMode
from integrations.graphiti.memory import GraphitiMemory, GroupIdMode
# Determine project directory from project_id or use current dir
project_dir = Path.cwd()
+2 -2
View File
@@ -17,7 +17,7 @@ from core.sentry import capture_exception
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from graphiti_memory import GraphitiMemory
from integrations.graphiti.memory import GraphitiMemory
def is_graphiti_memory_enabled() -> bool:
@@ -60,7 +60,7 @@ async def get_graphiti_memory(
return None
try:
from graphiti_memory import GraphitiMemory, GroupIdMode
from integrations.graphiti.memory import GraphitiMemory, GroupIdMode
if project_dir is None:
project_dir = spec_dir.parent.parent
+25
View File
@@ -81,6 +81,31 @@ def get_base_branch_from_metadata(spec_dir: Path) -> str | None:
return None
def get_use_local_branch_from_metadata(spec_dir: Path) -> bool:
"""
Read useLocalBranch from task_metadata.json if it exists.
When True, the worktree should be created from the local branch directly
instead of preferring origin/branch. This preserves gitignored files
(.env, configs) that may not exist on the remote.
Args:
spec_dir: Directory containing the spec files
Returns:
True if useLocalBranch is set in metadata, False otherwise
"""
metadata_path = spec_dir / "task_metadata.json"
if metadata_path.exists():
try:
with open(metadata_path, encoding="utf-8") as f:
metadata = json.load(f)
return bool(metadata.get("useLocalBranch", False))
except (json.JSONDecodeError, OSError):
pass
return False
# Alias for backwards compatibility (internal use)
_get_base_branch_from_metadata = get_base_branch_from_metadata
@@ -17,14 +17,19 @@ Key Design:
from __future__ import annotations
import asyncio
import hashlib
import logging
import os
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from claude_agent_sdk import AgentDefinition
# Note: AgentDefinition import kept for backwards compatibility but no longer used
# The Task tool's custom subagent_type feature is broken in Claude Code CLI
# See: https://github.com/anthropics/claude-code/issues/8697
from claude_agent_sdk import AgentDefinition # noqa: F401
try:
from ...core.client import create_client
@@ -48,6 +53,7 @@ try:
AgentAgreement,
FindingValidationResponse,
ParallelOrchestratorResponse,
SpecialistResponse,
)
from .sdk_utils import process_sdk_stream
except (ImportError, ValueError, SystemError):
@@ -72,10 +78,56 @@ except (ImportError, ValueError, SystemError):
AgentAgreement,
FindingValidationResponse,
ParallelOrchestratorResponse,
SpecialistResponse,
)
from services.sdk_utils import process_sdk_stream
# =============================================================================
# Specialist Configuration for Parallel SDK Sessions
# =============================================================================
@dataclass
class SpecialistConfig:
"""Configuration for a specialist agent in parallel SDK sessions."""
name: str
prompt_file: str
tools: list[str]
description: str
# Define specialist configurations
# Each specialist runs as its own SDK session with its own system prompt and tools
SPECIALIST_CONFIGS: list[SpecialistConfig] = [
SpecialistConfig(
name="security",
prompt_file="pr_security_agent.md",
tools=["Read", "Grep", "Glob"],
description="Security vulnerabilities, OWASP Top 10, auth issues, injection, XSS",
),
SpecialistConfig(
name="quality",
prompt_file="pr_quality_agent.md",
tools=["Read", "Grep", "Glob"],
description="Code quality, complexity, duplication, error handling, patterns",
),
SpecialistConfig(
name="logic",
prompt_file="pr_logic_agent.md",
tools=["Read", "Grep", "Glob"],
description="Logic correctness, edge cases, algorithms, race conditions",
),
SpecialistConfig(
name="codebase-fit",
prompt_file="pr_codebase_fit_agent.md",
tools=["Read", "Grep", "Glob"],
description="Naming conventions, ecosystem fit, architectural alignment",
),
]
logger = logging.getLogger(__name__)
# Check if debug mode is enabled
@@ -335,6 +387,304 @@ class ParallelOrchestratorReviewer:
),
}
# =========================================================================
# Parallel SDK Sessions Implementation
# =========================================================================
# This replaces the broken Task tool subagent approach.
# Each specialist runs as its own SDK session in parallel via asyncio.gather()
# See: https://github.com/anthropics/claude-code/issues/8697
def _build_specialist_prompt(
self,
config: SpecialistConfig,
context: PRContext,
project_root: Path,
) -> str:
"""Build the full prompt for a specialist agent.
Args:
config: Specialist configuration
context: PR context with files and patches
project_root: Working directory for the agent
Returns:
Full system prompt with context injected
"""
# Load base prompt from file
base_prompt = self._load_prompt(config.prompt_file)
if not base_prompt:
base_prompt = f"You are a {config.name} specialist for PR review."
# Inject working directory using the existing helper
with_working_dir = create_working_dir_injector(project_root)
prompt_with_cwd = with_working_dir(
base_prompt,
f"You are a {config.name} specialist. Find {config.description}.",
)
# Build file list
files_list = []
for file in context.changed_files:
files_list.append(
f"- `{file.path}` (+{file.additions}/-{file.deletions}) - {file.status}"
)
# Build diff content (limited to avoid context overflow)
patches = []
MAX_DIFF_CHARS = 150_000 # Smaller limit per specialist
for file in context.changed_files:
if file.patch:
patches.append(f"\n### File: {file.path}\n{file.patch}")
diff_content = "\n".join(patches)
if len(diff_content) > MAX_DIFF_CHARS:
diff_content = diff_content[:MAX_DIFF_CHARS] + "\n\n... (diff truncated)"
# Compose full prompt with PR context
pr_context = f"""
## PR Context
**PR #{context.pr_number}**: {context.title}
**Description:**
{context.description or "(No description provided)"}
### Changed Files ({len(context.changed_files)} files, +{context.total_additions}/-{context.total_deletions})
{chr(10).join(files_list)}
### Diff
{diff_content}
## Your Task
Analyze this PR for {config.description}.
Use the Read, Grep, and Glob tools to explore the codebase as needed.
Report findings with specific file paths, line numbers, and code evidence.
"""
return prompt_with_cwd + pr_context
async def _run_specialist_session(
self,
config: SpecialistConfig,
context: PRContext,
project_root: Path,
model: str,
thinking_budget: int | None,
) -> tuple[str, list[PRReviewFinding]]:
"""Run a single specialist as its own SDK session.
Args:
config: Specialist configuration
context: PR context
project_root: Working directory
model: Model to use
thinking_budget: Max thinking tokens
Returns:
Tuple of (specialist_name, findings)
"""
safe_print(
f"[Specialist:{config.name}] Starting analysis...",
flush=True,
)
# Build the specialist prompt with PR context
prompt = self._build_specialist_prompt(config, context, project_root)
try:
# Create SDK client for this specialist
# Note: Agent type uses the generic "pr_reviewer" since individual
# specialist types aren't registered in AGENT_CONFIGS. The specialist-specific
# system prompt handles differentiation.
client = create_client(
project_dir=project_root,
spec_dir=self.github_dir,
model=model,
agent_type="pr_reviewer",
max_thinking_tokens=thinking_budget,
output_format={
"type": "json_schema",
"schema": SpecialistResponse.model_json_schema(),
},
)
async with client:
await client.query(prompt)
# Process SDK stream
stream_result = await process_sdk_stream(
client=client,
context_name=f"Specialist:{config.name}",
model=model,
system_prompt=prompt,
agent_definitions={}, # No subagents for specialists
)
error = stream_result.get("error")
if error:
logger.error(
f"[Specialist:{config.name}] SDK stream failed: {error}"
)
safe_print(
f"[Specialist:{config.name}] Analysis failed: {error}",
flush=True,
)
return (config.name, [])
# Parse structured output
structured_output = stream_result.get("structured_output")
findings = self._parse_specialist_output(
config.name, structured_output, stream_result.get("result_text", "")
)
safe_print(
f"[Specialist:{config.name}] Complete: {len(findings)} findings",
flush=True,
)
return (config.name, findings)
except Exception as e:
logger.error(
f"[Specialist:{config.name}] Session failed: {e}",
exc_info=True,
)
safe_print(
f"[Specialist:{config.name}] Error: {e}",
flush=True,
)
return (config.name, [])
def _parse_specialist_output(
self,
specialist_name: str,
structured_output: dict[str, Any] | None,
result_text: str,
) -> list[PRReviewFinding]:
"""Parse findings from specialist output.
Args:
specialist_name: Name of the specialist
structured_output: Structured JSON output if available
result_text: Raw text output as fallback
Returns:
List of PRReviewFinding objects
"""
findings = []
if structured_output:
try:
result = SpecialistResponse.model_validate(structured_output)
for f in result.findings:
finding_id = hashlib.md5(
f"{f.file}:{f.line}:{f.title}".encode(),
usedforsecurity=False,
).hexdigest()[:12]
category = map_category(f.category)
try:
severity = ReviewSeverity(f.severity.lower())
except ValueError:
severity = ReviewSeverity.MEDIUM
finding = PRReviewFinding(
id=finding_id,
file=f.file,
line=f.line,
end_line=f.end_line,
title=f.title,
description=f.description,
category=category,
severity=severity,
suggested_fix=f.suggested_fix or "",
evidence=f.evidence,
source_agents=[specialist_name],
is_impact_finding=f.is_impact_finding,
)
findings.append(finding)
logger.info(
f"[Specialist:{specialist_name}] Parsed {len(findings)} findings from structured output"
)
except Exception as e:
logger.error(
f"[Specialist:{specialist_name}] Failed to parse structured output: {e}"
)
# Fall through to text parsing
if not findings and result_text:
# Fallback to text parsing
findings = self._parse_text_output(result_text)
for f in findings:
f.source_agents = [specialist_name]
return findings
async def _run_parallel_specialists(
self,
context: PRContext,
project_root: Path,
model: str,
thinking_budget: int | None,
) -> tuple[list[PRReviewFinding], list[str]]:
"""Run all specialists in parallel and collect findings.
Args:
context: PR context
project_root: Working directory
model: Model to use
thinking_budget: Max thinking tokens
Returns:
Tuple of (all_findings, agents_invoked)
"""
safe_print(
f"[ParallelOrchestrator] Launching {len(SPECIALIST_CONFIGS)} specialists in parallel...",
flush=True,
)
# Create tasks for all specialists
tasks = [
self._run_specialist_session(
config=config,
context=context,
project_root=project_root,
model=model,
thinking_budget=thinking_budget,
)
for config in SPECIALIST_CONFIGS
]
# Run all specialists in parallel
results = await asyncio.gather(*tasks, return_exceptions=True)
# Collect findings and track which agents ran
all_findings: list[PRReviewFinding] = []
agents_invoked: list[str] = []
for result in results:
if isinstance(result, Exception):
logger.error(f"[ParallelOrchestrator] Specialist task failed: {result}")
continue
specialist_name, findings = result
agents_invoked.append(specialist_name)
all_findings.extend(findings)
safe_print(
f"[ParallelOrchestrator] All specialists complete. "
f"Total findings: {len(all_findings)}",
flush=True,
)
return (all_findings, agents_invoked)
def _build_orchestrator_prompt(self, context: PRContext) -> str:
"""Build full prompt for orchestrator with PR context."""
# Load orchestrator prompt
@@ -720,11 +1070,6 @@ The SDK will run invoked agents in parallel automatically.
# LLM agents now discover relevant files themselves via Read, Grep, Glob tools
# No need to pre-scan the codebase programmatically
# Build orchestrator prompt AFTER worktree creation and related files rescan
prompt = self._build_orchestrator_prompt(context)
# Capture agent definitions for debug logging (with worktree path)
agent_defs = self._define_specialist_agents(project_root)
# Use model and thinking level from config (user settings)
# Resolve model shorthand via environment variable override if configured
model_shorthand = self.config.model or "sonnet"
@@ -737,122 +1082,39 @@ The SDK will run invoked agents in parallel automatically.
f"thinking_level={thinking_level}, thinking_budget={thinking_budget}"
)
# Create client with subagents defined
# SDK handles parallel execution when Claude invokes multiple Task tools
client = self._create_sdk_client(project_root, model, thinking_budget)
self._report_progress(
"orchestrating",
40,
"Orchestrator delegating to specialist agents...",
"Running specialist agents in parallel...",
pr_number=context.pr_number,
)
# Run orchestrator session using shared SDK stream processor
# Retry logic for tool use concurrency errors
MAX_RETRIES = 3
RETRY_DELAY = 2.0 # seconds between retries
# =================================================================
# PARALLEL SDK SESSIONS APPROACH
# =================================================================
# Instead of using broken Task tool subagents, we spawn each
# specialist as its own SDK session and run them in parallel.
# See: https://github.com/anthropics/claude-code/issues/8697
#
# This gives us:
# - True parallel execution via asyncio.gather()
# - Full control over each specialist's tools and prompts
# - No dependency on broken CLI features
# =================================================================
result_text = ""
structured_output = None
agents_invoked = []
msg_count = 0
last_error = None
# Run all specialists in parallel
findings, agents_invoked = await self._run_parallel_specialists(
context=context,
project_root=project_root,
model=model,
thinking_budget=thinking_budget,
)
for attempt in range(MAX_RETRIES):
if attempt > 0:
logger.info(
f"[ParallelOrchestrator] Retry attempt {attempt}/{MAX_RETRIES - 1} "
f"after tool concurrency error"
)
safe_print(
f"[ParallelOrchestrator] Retry {attempt}/{MAX_RETRIES - 1} "
f"(tool concurrency error detected)"
)
# Small delay before retry
import asyncio
await asyncio.sleep(RETRY_DELAY)
# Recreate client for retry (fresh session)
client = self._create_sdk_client(
project_root, model, thinking_budget
)
try:
async with client:
await client.query(prompt)
safe_print(
f"[ParallelOrchestrator] Running orchestrator ({model})...",
flush=True,
)
# Process SDK stream with shared utility
stream_result = await process_sdk_stream(
client=client,
context_name="ParallelOrchestrator",
model=model,
system_prompt=prompt,
agent_definitions=agent_defs,
)
error = stream_result.get("error")
# Check for tool concurrency error specifically
if (
error == "tool_use_concurrency_error"
and attempt < MAX_RETRIES - 1
):
logger.warning(
f"[ParallelOrchestrator] Tool concurrency error on attempt {attempt + 1}, "
f"will retry..."
)
last_error = error
continue # Retry
# Check for other stream processing errors
if error:
logger.error(
f"[ParallelOrchestrator] SDK stream failed: {error}"
)
raise RuntimeError(f"SDK stream processing failed: {error}")
# Success - extract results and break retry loop
result_text = stream_result["result_text"]
structured_output = stream_result["structured_output"]
agents_invoked = stream_result["agents_invoked"]
msg_count = stream_result["msg_count"]
break # Success, exit retry loop
except Exception as e:
# Check if this is a retryable error
error_str = str(e).lower()
is_retryable = (
"400" in error_str
or "concurrency" in error_str
or "tool_use" in error_str
)
if is_retryable and attempt < MAX_RETRIES - 1:
logger.warning(
f"[ParallelOrchestrator] Retryable error on attempt {attempt + 1}: {e}"
)
last_error = str(e)
continue # Retry
# Not retryable or out of retries - re-raise
raise
else:
# All retries exhausted
logger.error(
f"[ParallelOrchestrator] Failed after {MAX_RETRIES} attempts. "
f"Last error: {last_error}"
)
raise RuntimeError(
f"Orchestrator failed after {MAX_RETRIES} retry attempts. "
f"Last error: {last_error}"
)
# Log results
logger.info(
f"[ParallelOrchestrator] Parallel specialists complete: "
f"{len(findings)} findings from {len(agents_invoked)} agents"
)
self._report_progress(
"finalizing",
@@ -861,20 +1123,9 @@ The SDK will run invoked agents in parallel automatically.
pr_number=context.pr_number,
)
# Parse findings from output (structured output also returns agents)
findings, agents_from_structured = self._extract_structured_output(
structured_output, result_text
)
# Use agents from structured output (more reliable than streaming detection)
final_agents = (
agents_from_structured if agents_from_structured else agents_invoked
)
logger.info(
f"[ParallelOrchestrator] Session complete. Agents invoked: {final_agents}"
)
# Log completion with agent info
safe_print(
f"[ParallelOrchestrator] Complete. Agents invoked: {final_agents}",
f"[ParallelOrchestrator] Complete. Agents invoked: {agents_invoked}",
flush=True,
)
@@ -977,7 +1228,7 @@ The SDK will run invoked agents in parallel automatically.
verdict_reasoning=verdict_reasoning,
blockers=blockers,
findings=unique_findings,
agents_invoked=final_agents,
agents_invoked=agents_invoked,
)
# Map verdict to overall_status
@@ -537,6 +537,51 @@ class ValidationSummary(BaseModel):
)
class SpecialistFinding(BaseModel):
"""A finding from a specialist agent (used in parallel SDK sessions)."""
severity: Literal["critical", "high", "medium", "low"] = Field(
description="Issue severity level"
)
category: Literal[
"security", "quality", "logic", "performance", "pattern", "test", "docs"
] = Field(description="Issue category")
title: str = Field(description="Brief issue title (max 80 chars)")
description: str = Field(description="Detailed explanation of the issue")
file: str = Field(description="File path where issue was found")
line: int = Field(0, description="Line number of the issue")
end_line: int | None = Field(None, description="End line number if multi-line")
suggested_fix: str | None = Field(None, description="How to fix this issue")
evidence: str = Field(
min_length=1,
description="Actual code snippet examined that shows the issue. Required.",
)
is_impact_finding: bool = Field(
False,
description="True if this is about affected code outside the PR (callers, dependencies)",
)
class SpecialistResponse(BaseModel):
"""Response schema for individual specialist agent (parallel SDK sessions).
Used when each specialist runs as its own SDK session rather than via Task tool.
"""
specialist_name: str = Field(
description="Name of the specialist (security, quality, logic, codebase-fit)"
)
analysis_summary: str = Field(description="Brief summary of what was analyzed")
files_examined: list[str] = Field(
default_factory=list,
description="List of files that were examined",
)
findings: list[SpecialistFinding] = Field(
default_factory=list,
description="Issues found during analysis",
)
class ParallelOrchestratorResponse(BaseModel):
"""Complete response schema for parallel orchestrator PR review."""
+7 -1
View File
@@ -1,6 +1,10 @@
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
import { config as dotenvConfig } from 'dotenv';
// Load .env file for build-time constants (Sentry DSN, etc.)
dotenvConfig({ path: resolve(__dirname, '.env') });
/**
* Sentry configuration embedded at build time.
@@ -43,7 +47,9 @@ export default defineConfig({
'debug',
'ms',
// Minimatch for glob pattern matching in worktree handlers
'minimatch'
'minimatch',
// XState for task state machine
'xstate'
]
})],
build: {
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "auto-claude-ui",
"version": "2.7.6-beta.1",
"version": "2.7.6-beta.2",
"type": "module",
"description": "Desktop UI for Auto Claude autonomous coding framework",
"homepage": "https://github.com/AndyMik90/Auto-Claude",
@@ -96,6 +96,8 @@
"react-i18next": "^16.5.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.2.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"semver": "^7.7.3",
"tailwind-merge": "^3.4.0",
@@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdirSync, rmSync, existsSync, mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import path from 'path';
import type { ClaudeProfile, IPCResult, TerminalCreateOptions } from '../../shared/types';
import type { ClaudeProfile, IPCResult, } from '../../shared/types';
// Test directories - use secure temp directory with random suffix
let TEST_DIR: string;
@@ -297,7 +297,7 @@ describe('Subprocess Spawn Integration', () => {
// Simulate stdout data (must include newline for buffered output processing)
mockStdout.emit('data', Buffer.from('Test log output\n'));
expect(logHandler).toHaveBeenCalledWith('task-1', 'Test log output\n');
expect(logHandler).toHaveBeenCalledWith('task-1', 'Test log output\n', undefined);
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
it('should emit log events from stderr', async () => {
@@ -313,7 +313,7 @@ describe('Subprocess Spawn Integration', () => {
// Simulate stderr data (must include newline for buffered output processing)
mockStderr.emit('data', Buffer.from('Progress: 50%\n'));
expect(logHandler).toHaveBeenCalledWith('task-1', 'Progress: 50%\n');
expect(logHandler).toHaveBeenCalledWith('task-1', 'Progress: 50%\n', undefined);
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
it('should emit exit event when process exits', async () => {
@@ -329,8 +329,8 @@ describe('Subprocess Spawn Integration', () => {
// Simulate process exit
mockProcess.emit('exit', 0);
// Exit event includes taskId, exit code, and process type
expect(exitHandler).toHaveBeenCalledWith('task-1', 0, expect.any(String));
// Exit event includes taskId, exit code, process type, and optional projectId
expect(exitHandler).toHaveBeenCalledWith('task-1', 0, expect.any(String), undefined);
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
it('should emit error event when process errors', async () => {
@@ -346,7 +346,7 @@ describe('Subprocess Spawn Integration', () => {
// Simulate process error
mockProcess.emit('error', new Error('Spawn failed'));
expect(errorHandler).toHaveBeenCalledWith('task-1', 'Spawn failed');
expect(errorHandler).toHaveBeenCalledWith('task-1', 'Spawn failed', undefined);
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
it('should kill task and remove from tracking', async () => {
@@ -69,7 +69,7 @@ vi.mock('child_process', () => {
// so when tests call vi.mocked(execFileSync).mockReturnValue(), it affects execSync too
const sharedSyncMock = vi.fn();
const mockExecFile = vi.fn((cmd: unknown, args: unknown, options: unknown, callback: unknown) => {
const mockExecFile = vi.fn((_cmd: unknown, _args: unknown, _options: unknown, callback: unknown) => {
// Return a minimal ChildProcess-like object
const childProcess = {
stdout: { on: vi.fn() },
@@ -86,7 +86,7 @@ const mockExecFile = vi.fn((cmd: unknown, args: unknown, options: unknown, callb
return childProcess as unknown as import('child_process').ChildProcess;
});
const mockExec = vi.fn((cmd: unknown, options: unknown, callback: unknown) => {
const mockExec = vi.fn((_cmd: unknown, _options: unknown, callback: unknown) => {
// Return a minimal ChildProcess-like object
const childProcess = {
stdout: { on: vi.fn() },
@@ -61,15 +61,17 @@ import path from 'path';
import { isValidConfigDir } from '../utils/config-path-validator';
describe('isValidConfigDir - Security Validation', () => {
let originalHomedir: string;
let consoleWarnSpy: any;
let _originalHomedir: string;
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// Store original homedir for restoration
originalHomedir = os.homedir();
_originalHomedir = os.homedir();
// Spy on console.warn to suppress warning output during tests
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {
/* intentionally empty - suppress console output during tests */
});
});
afterEach(() => {
@@ -564,7 +564,7 @@ describe("IPC Handlers", { timeout: 30000 }, () => {
expect(result).toHaveProperty("success", true);
const data = (result as { data: { theme: string } }).data;
expect(data).toHaveProperty("theme", "system");
expect(data).toHaveProperty("theme", "dark");
});
});
@@ -93,7 +93,7 @@ describe('PythonEnvManager', () => {
const sitePackagesPath = 'C:\\test\\site-packages';
// Access private property for testing
(manager as any).sitePackagesPath = sitePackagesPath;
(manager as unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath;
const env = manager.getPythonEnv();
@@ -106,7 +106,7 @@ describe('PythonEnvManager', () => {
const sitePackagesPath = 'C:\\test\\site-packages';
// Access private property for testing
(manager as any).sitePackagesPath = sitePackagesPath;
(manager as unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath;
const env = manager.getPythonEnv();
@@ -125,7 +125,7 @@ describe('PythonEnvManager', () => {
const sitePackagesPath = '/test/site-packages';
// Access private property for testing
(manager as any).sitePackagesPath = sitePackagesPath;
(manager as unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath;
const env = manager.getPythonEnv();
@@ -144,7 +144,7 @@ describe('PythonEnvManager', () => {
const sitePackagesPath = 'C:\\test\\site-packages';
// Access private property for testing
(manager as any).sitePackagesPath = sitePackagesPath;
(manager as unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath;
// Save and clear existing PATH, then set lowercase 'Path'
// This simulates a Windows environment where the system has 'Path' instead of 'PATH'
@@ -507,7 +507,7 @@ describe('Rate Limit Edge Cases', () => {
];
for (const msg of falsePositives) {
const result = detectRateLimit(msg);
const _result = detectRateLimit(msg);
// Note: Some may still match secondary indicators - that's intentional
// The primary pattern should NOT match these
const primaryPattern = /Limit reached\s*[·•]\s*resets/i;
@@ -243,24 +243,31 @@ describe('SETTINGS_CLAUDE_CODE_GET_ONBOARDING_STATUS handler', () => {
// Get the mocked functions (vitest stores the implementation)
const { existsSync, readFileSync } = await import('fs');
// Cast to vi.Mock for accessing mock methods
type MockFn = ReturnType<typeof vi.fn>;
const existsSyncMock = existsSync as unknown as MockFn;
const readFileSyncMock = readFileSync as unknown as MockFn;
// Save original mock implementations to restore after test
const originalExistsSync = (existsSync as any).getMockImplementation();
const originalReadFileSync = (readFileSync as any).getMockImplementation();
type ExistsSyncFn = (path: string) => boolean;
type ReadFileSyncFn = (path: string) => string;
const originalExistsSync: ExistsSyncFn = (existsSyncMock.getMockImplementation() as ExistsSyncFn | undefined) ?? (() => false);
const originalReadFileSync: ReadFileSyncFn = (readFileSyncMock.getMockImplementation() as ReadFileSyncFn | undefined) ?? (() => '');
// Override existsSync to make file appear to exist
(existsSync as any).mockImplementation((path: string) => {
existsSyncMock.mockImplementation((path: string) => {
if (path === claudeJsonPath) {
return true; // File appears to exist
}
return originalExistsSync ? originalExistsSync(path) : false;
return originalExistsSync(path);
});
// Override readFileSync to throw error for our specific file
(readFileSync as any).mockImplementation((path: string) => {
readFileSyncMock.mockImplementation((path: string) => {
if (path === claudeJsonPath) {
throw new Error('EACCES: permission denied, open \'' + path + '\'');
}
return originalReadFileSync ? originalReadFileSync(path) : '';
return originalReadFileSync(path);
});
const result = await onboardingStatusHandler({}, null) as {
@@ -272,8 +279,8 @@ describe('SETTINGS_CLAUDE_CODE_GET_ONBOARDING_STATUS handler', () => {
expect(result.data?.hasCompletedOnboarding).toBe(false);
// Restore original mocks
(existsSync as any).mockImplementation(originalExistsSync);
(readFileSync as any).mockImplementation(originalReadFileSync);
existsSyncMock.mockImplementation(originalExistsSync);
readFileSyncMock.mockImplementation(originalReadFileSync);
});
});
});
@@ -390,6 +390,83 @@ describe('TaskStateManager', () => {
// Should have sent PROCESS_EXITED event with unexpected=true
// This should transition to error state
});
it('should NOT mark exit code 0 as unexpected (plan_review stays intact)', () => {
// Simulate: PLANNING_STARTED → PLANNING_COMPLETE (requireReview) → process exits code 0
const planningStarted = {
type: 'PLANNING_STARTED',
taskId: mockTask.id,
specId: mockTask.specId,
projectId: mockProject.id,
timestamp: new Date().toISOString(),
eventId: 'evt-1',
sequence: 0
};
const planningComplete = {
type: 'PLANNING_COMPLETE',
taskId: mockTask.id,
specId: mockTask.specId,
projectId: mockProject.id,
timestamp: new Date().toISOString(),
eventId: 'evt-2',
sequence: 1,
hasSubtasks: false,
subtaskCount: 0,
requireReviewBeforeCoding: true
};
manager.handleTaskEvent(mockTask.id, planningStarted, mockTask, mockProject);
manager.handleTaskEvent(mockTask.id, planningComplete, mockTask, mockProject);
// XState should be in plan_review now
expect(manager.getCurrentState(mockTask.id)).toBe('plan_review');
// Process exits with code 0 - should NOT transition to error
manager.handleProcessExited(mockTask.id, 0, mockTask, mockProject);
// PLANNING_COMPLETE is a terminal event, so handleProcessExited should skip entirely
// Task should remain in plan_review
expect(manager.getCurrentState(mockTask.id)).toBe('plan_review');
});
it('should treat PLANNING_COMPLETE as a terminal event', () => {
// PLANNING_COMPLETE should prevent handleProcessExited from running
const planningStarted = {
type: 'PLANNING_STARTED',
taskId: mockTask.id,
specId: mockTask.specId,
projectId: mockProject.id,
timestamp: new Date().toISOString(),
eventId: 'evt-1',
sequence: 0
};
const planningComplete = {
type: 'PLANNING_COMPLETE',
taskId: mockTask.id,
specId: mockTask.specId,
projectId: mockProject.id,
timestamp: new Date().toISOString(),
eventId: 'evt-2',
sequence: 1,
hasSubtasks: true,
subtaskCount: 3,
requireReviewBeforeCoding: false
};
manager.handleTaskEvent(mockTask.id, planningStarted, mockTask, mockProject);
manager.handleTaskEvent(mockTask.id, planningComplete, mockTask, mockProject);
// XState should be in coding (no review required)
expect(manager.getCurrentState(mockTask.id)).toBe('coding');
// Process exits with code 1 - should still skip because PLANNING_COMPLETE is terminal
manager.handleProcessExited(mockTask.id, 1, mockTask, mockProject);
// Task should remain in coding, NOT transition to error
expect(manager.getCurrentState(mockTask.id)).toBe('coding');
});
});
describe('actor state restoration', () => {
+15 -5
View File
@@ -178,7 +178,9 @@ describe("safeSendToRenderer", () => {
describe("error handling - non-disposal errors", () => {
it("catches other errors and returns false", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {
/* intentionally empty - suppress console output during tests */
});
mockSend.mockImplementation(() => {
throw new Error("Some other IPC error");
@@ -216,7 +218,9 @@ describe("safeSendToRenderer", () => {
});
it("logs console.warn only once for multiple consecutive calls to same channel", () => {
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
/* intentionally empty - suppress console output during tests */
});
mockWindow = {
isDestroyed: vi.fn(() => true),
@@ -242,7 +246,9 @@ describe("safeSendToRenderer", () => {
});
it("logs console.warn separately for different channels", () => {
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
/* intentionally empty - suppress console output during tests */
});
mockWindow = {
isDestroyed: vi.fn(() => true),
@@ -307,7 +313,9 @@ describe("safeSendToRenderer", () => {
describe("warning pruning logic - 100-entry hard cap", () => {
it("enforces 100-entry cap by removing oldest entries when exceeded", async () => {
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
/* intentionally empty - suppress console output during tests */
});
mockWindow = {
isDestroyed: vi.fn(() => true),
@@ -340,7 +348,9 @@ describe("safeSendToRenderer", () => {
});
it("handles many unique channels without throwing errors", async () => {
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
/* intentionally empty - suppress console output during tests */
});
mockWindow = {
isDestroyed: vi.fn(() => true),
+35 -4
View File
@@ -3,6 +3,7 @@ import { parsePhaseEvent } from './phase-event-parser';
import {
wouldPhaseRegress,
isTerminalPhase,
isPausePhase,
isValidExecutionPhase,
type ExecutionPhase
} from '../../shared/constants/phase-protocol';
@@ -13,18 +14,48 @@ export class AgentEvents {
log: string,
currentPhase: ExecutionProgressData['phase'],
isSpecRunner: boolean
): { phase: ExecutionProgressData['phase']; message?: string; currentSubtask?: string } | null {
): {
phase: ExecutionProgressData['phase'];
message?: string;
currentSubtask?: string;
resetTimestamp?: number;
profileId?: string;
} | null {
const structuredEvent = parsePhaseEvent(log);
if (structuredEvent) {
return {
phase: structuredEvent.phase as ExecutionProgressData['phase'],
// structuredEvent.phase is validated as BackendPhase (via Zod schema),
// which is a subset of ExecutionPhase, so this assertion is safe
const result: {
phase: ExecutionProgressData['phase'];
message?: string;
currentSubtask?: string;
resetTimestamp?: number;
profileId?: string;
} = {
phase: structuredEvent.phase as ExecutionPhase,
message: structuredEvent.message,
currentSubtask: structuredEvent.subtask
};
// Include pause phase metadata if present
if (structuredEvent.reset_timestamp !== undefined) {
result.resetTimestamp = structuredEvent.reset_timestamp;
}
if (structuredEvent.profile_id !== undefined) {
result.profileId = structuredEvent.profile_id;
}
return result;
}
// Terminal states can't be changed by fallback matching
if (isTerminalPhase(currentPhase as ExecutionPhase)) {
if (isTerminalPhase(currentPhase)) {
return null;
}
// Pause phases should only be changed by structured events
// Don't allow fallback text matching to transition out of pause phases
if (isPausePhase(currentPhase)) {
return null;
}
+95 -16
View File
@@ -6,6 +6,8 @@ import { AgentEvents } from './agent-events';
import { AgentProcessManager } from './agent-process';
import { AgentQueueManager } from './agent-queue';
import { getClaudeProfileManager, initializeClaudeProfileManager } from '../claude-profile-manager';
import type { ClaudeProfileManager } from '../claude-profile-manager';
import { getOperationRegistry } from '../claude-profile/operation-registry';
import {
SpecCreationMetadata,
TaskExecutionOptions,
@@ -32,6 +34,9 @@ export class AgentManager extends EventEmitter {
metadata?: SpecCreationMetadata;
baseBranch?: string;
swapCount: number;
projectId?: string;
/** Generation counter to prevent stale cleanup after restart */
generation: number;
}> = new Map();
constructor() {
@@ -51,26 +56,40 @@ export class AgentManager extends EventEmitter {
});
// Listen for task completion to clean up context (prevent memory leak)
this.on('exit', (taskId: string, code: number | null) => {
this.on('exit', (taskId: string, code: number | null, _processType?: string, _projectId?: string) => {
// Clean up context when:
// 1. Task completed successfully (code === 0), or
// 2. Task failed and won't be restarted (handled by auto-swap logic)
// Capture generation at exit time to prevent race conditions with restarts
const contextAtExit = this.taskExecutionContext.get(taskId);
const generationAtExit = contextAtExit?.generation;
// Note: Auto-swap restart happens BEFORE this exit event is processed,
// so we need a small delay to allow restart to preserve context
setTimeout(() => {
const context = this.taskExecutionContext.get(taskId);
if (!context) return; // Already cleaned up or restarted
// Check if the context's generation matches - if not, a restart incremented it
// and this cleanup is for a stale exit event that shouldn't affect the new task
if (generationAtExit !== undefined && context.generation !== generationAtExit) {
return; // Stale exit event - task was restarted, don't clean up new context
}
// If task completed successfully, always clean up
if (code === 0) {
this.taskExecutionContext.delete(taskId);
// Unregister from OperationRegistry
getOperationRegistry().unregisterOperation(taskId);
return;
}
// If task failed and hit max retries, clean up
if (context.swapCount >= 2) {
this.taskExecutionContext.delete(taskId);
// Unregister from OperationRegistry
getOperationRegistry().unregisterOperation(taskId);
}
// Otherwise keep context for potential restart
}, 1000); // Delay to allow restart logic to run first
@@ -84,6 +103,46 @@ export class AgentManager extends EventEmitter {
this.processManager.configure(pythonPath, autoBuildSourcePath);
}
/**
* Register a task with the unified OperationRegistry for proactive swap support.
* Extracted helper to avoid code duplication between spec creation and task execution.
* @private
*/
private registerTaskWithOperationRegistry(
taskId: string,
operationType: 'spec-creation' | 'task-execution',
metadata: Record<string, unknown>
): void {
const profileManager = getClaudeProfileManager();
const activeProfile = profileManager.getActiveProfile();
if (!activeProfile) {
return;
}
// Keep internal state tracking for backward compatibility
this.assignProfileToTask(taskId, activeProfile.id, activeProfile.name, 'proactive');
// Register with unified registry for proactive swap
// Note: We don't provide a stopFn because restartTask() already handles stopping
// the task internally via killTask() before restarting. Providing a separate
// stopFn would cause a redundant double-kill during profile swaps.
const operationRegistry = getOperationRegistry();
operationRegistry.registerOperation(
taskId,
operationType,
activeProfile.id,
activeProfile.name,
(newProfileId: string) => this.restartTask(taskId, newProfileId),
{ metadata }
);
console.log('[AgentManager] Task registered with OperationRegistry:', {
taskId,
profileId: activeProfile.id,
profileName: activeProfile.name,
type: operationType
});
}
/**
* Start spec creation process
*/
@@ -93,11 +152,12 @@ export class AgentManager extends EventEmitter {
taskDescription: string,
specDir?: string,
metadata?: SpecCreationMetadata,
baseBranch?: string
baseBranch?: string,
projectId?: string
): Promise<void> {
// Pre-flight auth check: Verify active profile has valid authentication
// Ensure profile manager is initialized to prevent race condition
let profileManager;
let profileManager: ClaudeProfileManager;
try {
profileManager = await initializeClaudeProfileManager();
} catch (error) {
@@ -173,10 +233,13 @@ export class AgentManager extends EventEmitter {
}
// Store context for potential restart
this.storeTaskContext(taskId, projectPath, '', {}, true, taskDescription, specDir, metadata, baseBranch);
this.storeTaskContext(taskId, projectPath, '', {}, true, taskDescription, specDir, metadata, baseBranch, projectId);
// Register with unified OperationRegistry for proactive swap support
this.registerTaskWithOperationRegistry(taskId, 'spec-creation', { projectPath, taskDescription, specDir });
// Note: This is spec-creation but it chains to task-execution via run.py
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution');
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution', projectId);
}
/**
@@ -186,11 +249,12 @@ export class AgentManager extends EventEmitter {
taskId: string,
projectPath: string,
specId: string,
options: TaskExecutionOptions = {}
options: TaskExecutionOptions = {},
projectId?: string
): Promise<void> {
// Pre-flight auth check: Verify active profile has valid authentication
// Ensure profile manager is initialized to prevent race condition
let profileManager;
let profileManager: ClaudeProfileManager;
try {
profileManager = await initializeClaudeProfileManager();
} catch (error) {
@@ -251,9 +315,12 @@ export class AgentManager extends EventEmitter {
// which allows per-phase configuration for planner, coder, and QA phases
// Store context for potential restart
this.storeTaskContext(taskId, projectPath, specId, options, false);
this.storeTaskContext(taskId, projectPath, specId, options, false, undefined, undefined, undefined, undefined, projectId);
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution');
// Register with unified OperationRegistry for proactive swap support
this.registerTaskWithOperationRegistry(taskId, 'task-execution', { projectPath, specId, options });
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution', projectId);
}
/**
@@ -262,7 +329,8 @@ export class AgentManager extends EventEmitter {
async startQAProcess(
taskId: string,
projectPath: string,
specId: string
specId: string,
projectId?: string
): Promise<void> {
// Ensure Python environment is ready before spawning process (prevents exit code 127 race condition)
const pythonStatus = await this.processManager.ensurePythonEnvReady('AgentManager');
@@ -290,7 +358,7 @@ export class AgentManager extends EventEmitter {
const args = [runPath, '--spec', specId, '--project-dir', projectPath, '--qa'];
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'qa-process');
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'qa-process', projectId);
}
/**
@@ -387,11 +455,14 @@ export class AgentManager extends EventEmitter {
taskDescription?: string,
specDir?: string,
metadata?: SpecCreationMetadata,
baseBranch?: string
baseBranch?: string,
projectId?: string
): void {
// Preserve swapCount if context already exists (for restarts)
const existingContext = this.taskExecutionContext.get(taskId);
const swapCount = existingContext?.swapCount ?? 0;
// Increment generation on each store (restarts) to invalidate pending cleanup callbacks
const generation = (existingContext?.generation ?? 0) + 1;
this.taskExecutionContext.set(taskId, {
projectPath,
@@ -402,7 +473,9 @@ export class AgentManager extends EventEmitter {
specDir,
metadata,
baseBranch,
swapCount // Preserve existing count instead of resetting
swapCount, // Preserve existing count instead of resetting
projectId,
generation, // Incremented to prevent stale exit cleanup
});
}
@@ -458,13 +531,18 @@ export class AgentManager extends EventEmitter {
console.log('[AgentManager] Restarting task now:', taskId);
if (context.isSpecCreation) {
console.log('[AgentManager] Restarting as spec creation');
if (!context.taskDescription) {
console.error('[AgentManager] Cannot restart spec creation: taskDescription is missing');
return;
}
this.startSpecCreation(
taskId,
context.projectPath,
context.taskDescription!,
context.taskDescription,
context.specDir,
context.metadata,
context.baseBranch
context.baseBranch,
context.projectId
);
} else {
console.log('[AgentManager] Restarting as task execution');
@@ -472,7 +550,8 @@ export class AgentManager extends EventEmitter {
taskId,
context.projectPath,
context.specId,
context.options
context.options,
context.projectId
);
}
}, 500);
@@ -13,7 +13,7 @@ function createMockProcess() {
return {
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn((event: string, callback: any) => {
on: vi.fn((event: string, callback: (code: number) => void) => {
if (event === 'exit') {
// Simulate immediate exit with code 0
setTimeout(() => callback(0), 10);
+89 -19
View File
@@ -281,7 +281,11 @@ export class AgentProcessManager {
} : 'NONE');
if (!bestProfile) {
console.log('[AgentProcess] No alternative profile available - falling back to manual modal');
// Single account case: let backend handle with intelligent pause
// Don't show manual modal - backend will pause intelligently and resume when ready
console.log('[AgentProcess] No alternative profile - backend will handle with intelligent pause');
// Return false to let handleProcessFailure emit sdk-rate-limit event
// The frontend can then show appropriate UI (e.g., "Paused until X time")
return false;
}
@@ -306,19 +310,84 @@ export class AgentProcessManager {
console.log('[AgentProcess] No rate limit detected - checking for auth failure');
const authFailureDetection = detectAuthFailure(allOutput);
if (authFailureDetection.isAuthFailure) {
console.log('[AgentProcess] Auth failure detected:', authFailureDetection);
if (!authFailureDetection.isAuthFailure) {
console.log('[AgentProcess] Process failed but no rate limit or auth failure detected');
return false;
}
console.log('[AgentProcess] Auth failure detected:', authFailureDetection);
// Try auto-swap if enabled
const wasHandled = this.handleAuthFailureWithAutoSwap(taskId, authFailureDetection);
if (!wasHandled) {
// Fall back to UI notification
this.emitter.emit('auth-failure', taskId, {
profileId: authFailureDetection.profileId,
failureType: authFailureDetection.failureType,
message: authFailureDetection.message,
originalError: authFailureDetection.originalError
});
return true;
}
console.log('[AgentProcess] Process failed but no rate limit or auth failure detected');
return false;
return true;
}
/**
* Attempt to auto-swap to another profile on authentication failure.
* Only works when autoSwitchOnAuthFailure is enabled and an alternative
* authenticated profile is available.
*/
private handleAuthFailureWithAutoSwap(
taskId: string,
authFailureDetection: ReturnType<typeof detectAuthFailure>
): boolean {
const profileManager = getClaudeProfileManager();
const autoSwitchSettings = profileManager.getAutoSwitchSettings();
console.log('[AgentProcess] Auth failure auto-switch settings:', {
enabled: autoSwitchSettings.enabled,
autoSwitchOnAuthFailure: autoSwitchSettings.autoSwitchOnAuthFailure
});
// Check if auto-switch on auth failure is enabled
if (!autoSwitchSettings.enabled || !autoSwitchSettings.autoSwitchOnAuthFailure) {
console.log('[AgentProcess] Auth failure auto-switch disabled - falling back to UI');
return false;
}
const currentProfileId = authFailureDetection.profileId;
const bestProfile = profileManager.getBestAvailableProfile(currentProfileId);
console.log('[AgentProcess] Best available profile for auth failure swap:', bestProfile ? {
id: bestProfile.id,
name: bestProfile.name,
isAuthenticated: bestProfile.isAuthenticated
} : 'NONE');
// Verify the best profile is actually authenticated
if (!bestProfile || !bestProfile.isAuthenticated) {
console.log('[AgentProcess] No authenticated alternative profile - falling back to UI');
return false;
}
console.log('[AgentProcess] AUTH-FAILURE AUTO-SWAP:', currentProfileId, '->', bestProfile.id);
profileManager.setActiveProfile(bestProfile.id);
// Emit auth-failure event with swap metadata for UI notification
this.emitter.emit('auth-failure', taskId, {
profileId: authFailureDetection.profileId,
failureType: authFailureDetection.failureType,
message: authFailureDetection.message,
originalError: authFailureDetection.originalError,
wasAutoSwapped: true,
swappedToProfile: { id: bestProfile.id, name: bestProfile.name }
});
// Reuse existing restart event
console.log('[AgentProcess] Emitting auto-swap-restart-task event for auth failure:', taskId);
this.emitter.emit('auto-swap-restart-task', taskId, bestProfile.id);
return true;
}
/**
@@ -510,7 +579,8 @@ export class AgentProcessManager {
cwd: string,
args: string[],
extraEnv: Record<string, string> = {},
processType: ProcessType = 'task-execution'
processType: ProcessType = 'task-execution',
projectId?: string
): Promise<void> {
const isSpecRunner = processType === 'spec-creation';
this.killProcess(taskId);
@@ -562,7 +632,7 @@ export class AgentProcessManager {
// spawn() failed synchronously (e.g., command not found, permission denied)
// Clean up tracking entry and propagate error
this.state.deleteProcess(taskId);
this.emitter.emit('error', taskId, err instanceof Error ? err.message : String(err));
this.emitter.emit('error', taskId, err instanceof Error ? err.message : String(err), projectId);
throw err;
}
@@ -609,7 +679,7 @@ export class AgentProcessManager {
message: isSpecRunner ? 'Starting spec creation...' : 'Starting build process...',
sequenceNumber: ++sequenceNumber,
completedPhases: [...completedPhases]
});
}, projectId);
const isDebug = ['true', '1', 'yes', 'on'].includes(process.env.DEBUG?.toLowerCase() ?? '');
@@ -629,7 +699,7 @@ export class AgentProcessManager {
const taskEvent = parseTaskEvent(line);
if (taskEvent) {
console.log(`[AgentProcess:${taskId}] Parsed task event:`, taskEvent.type, taskEvent);
this.emitter.emit('task-event', taskId, taskEvent);
this.emitter.emit('task-event', taskId, taskEvent, projectId);
}
const phaseUpdate = this.events.parseExecutionPhase(line, currentPhase, isSpecRunner);
@@ -689,7 +759,7 @@ export class AgentProcessManager {
message: lastMessage,
sequenceNumber: ++sequenceNumber,
completedPhases: [...completedPhases]
});
}, projectId);
}
};
@@ -709,7 +779,7 @@ export class AgentProcessManager {
for (const line of lines) {
if (line.trim()) {
this.emitter.emit('log', taskId, line + '\n');
this.emitter.emit('log', taskId, line + '\n', projectId);
processLog(line);
if (isDebug) {
console.log(`[Agent:${taskId}] ${line}`);
@@ -730,11 +800,11 @@ export class AgentProcessManager {
childProcess.on('exit', (code: number | null) => {
if (stdoutBuffer.trim()) {
this.emitter.emit('log', taskId, stdoutBuffer + '\n');
this.emitter.emit('log', taskId, stdoutBuffer + '\n', projectId);
processLog(stdoutBuffer);
}
if (stderrBuffer.trim()) {
this.emitter.emit('log', taskId, stderrBuffer + '\n');
this.emitter.emit('log', taskId, stderrBuffer + '\n', projectId);
processLog(stderrBuffer);
}
@@ -749,7 +819,7 @@ export class AgentProcessManager {
console.log('[AgentProcess] Process failed with code:', code, 'for task:', taskId);
const wasHandled = this.handleProcessFailure(taskId, allOutput, processType);
if (wasHandled) {
this.emitter.emit('exit', taskId, code, processType);
this.emitter.emit('exit', taskId, code, processType, projectId);
return;
}
}
@@ -762,10 +832,10 @@ export class AgentProcessManager {
message: `Process exited with code ${code}`,
sequenceNumber: ++sequenceNumber,
completedPhases: [...completedPhases]
});
}, projectId);
}
this.emitter.emit('exit', taskId, code, processType);
this.emitter.emit('exit', taskId, code, processType, projectId);
});
// Handle process error
@@ -780,9 +850,9 @@ export class AgentProcessManager {
message: `Error: ${err.message}`,
sequenceNumber: ++sequenceNumber,
completedPhases: [...completedPhases]
});
}, projectId);
this.emitter.emit('error', taskId, err.message);
this.emitter.emit('error', taskId, err.message, projectId);
});
}
+6 -1
View File
@@ -417,7 +417,12 @@ export class AgentQueueManager {
// Track completed types for progress calculation
const completedTypes = new Set<string>();
const totalTypes = 7; // Default all types
// Derive totalTypes from --types argument instead of hardcoding
const typesArgIndex = args.findIndex((arg) => arg === '--types');
const totalTypes =
typesArgIndex > -1 && args[typesArgIndex + 1]
? args[typesArgIndex + 1].split(',').length
: 6; // Default to 6 if not specified
// Handle stdout - explicitly decode as UTF-8 for cross-platform Unicode support
childProcess.stdout?.on('data', (data: Buffer) => {
@@ -24,9 +24,24 @@ describe('AgentState - Queue Routing', () => {
it('should group tasks by profile', () => {
// Add mock processes
state.addProcess('task-1', { pid: 1001 } as any);
state.addProcess('task-2', { pid: 1002 } as any);
state.addProcess('task-3', { pid: 1003 } as any);
state.addProcess('task-1', {
taskId: 'task-1',
process: { pid: 1001 } as unknown as import('child_process').ChildProcess,
startedAt: new Date(),
spawnId: 1
});
state.addProcess('task-2', {
taskId: 'task-2',
process: { pid: 1002 } as unknown as import('child_process').ChildProcess,
startedAt: new Date(),
spawnId: 2
});
state.addProcess('task-3', {
taskId: 'task-3',
process: { pid: 1003 } as unknown as import('child_process').ChildProcess,
startedAt: new Date(),
spawnId: 3
});
// Assign profiles
state.assignProfileToTask('task-1', 'profile-1', 'Profile 1', 'proactive');
@@ -42,7 +57,12 @@ describe('AgentState - Queue Routing', () => {
it('should use default profile for unassigned tasks', () => {
// Add process without profile assignment
state.addProcess('task-1', { pid: 1001 } as any);
state.addProcess('task-1', {
taskId: 'task-1',
process: { pid: 1001 } as unknown as import('child_process').ChildProcess,
startedAt: new Date(),
spawnId: 1
});
const result = state.getRunningTasksByProfile();
@@ -14,6 +14,9 @@ export interface PhaseParseResult<TPhase extends string = string> {
message?: string;
currentSubtask?: string;
progress?: number;
// Pause phase metadata
resetTimestamp?: number; // Unix timestamp for rate limit reset
profileId?: string; // Profile that hit the limit
}
/**
@@ -9,6 +9,7 @@ import { BasePhaseParser, type PhaseParseResult, type PhaseParserContext } from
import {
EXECUTION_PHASES,
TERMINAL_PHASES,
isPausePhase,
type ExecutionPhase
} from '../../../shared/constants/phase-protocol';
import { parsePhaseEvent } from '../phase-event-parser';
@@ -40,11 +41,21 @@ export class ExecutionPhaseParser extends BasePhaseParser<ExecutionPhase> {
// 1. Try structured event first (authoritative source)
const structuredEvent = parsePhaseEvent(log);
if (structuredEvent) {
return {
const result: PhaseParseResult<ExecutionPhase> = {
phase: structuredEvent.phase as ExecutionPhase,
message: structuredEvent.message,
currentSubtask: structuredEvent.subtask
};
// Include pause phase metadata if present
if (structuredEvent.reset_timestamp !== undefined) {
result.resetTimestamp = structuredEvent.reset_timestamp;
}
if (structuredEvent.profile_id !== undefined) {
result.profileId = structuredEvent.profile_id;
}
return result;
}
// 2. Terminal states can't be changed by fallback matching
@@ -52,7 +63,13 @@ export class ExecutionPhaseParser extends BasePhaseParser<ExecutionPhase> {
return null;
}
// 3. Fall back to text pattern matching
// 3. Pause phases should only be changed by structured events
// Don't allow fallback text matching to transition out of pause phases
if (isPausePhase(context.currentPhase)) {
return null;
}
// 4. Fall back to text pattern matching
return this.parseFallbackPatterns(log, context);
}
@@ -7,7 +7,10 @@ export const PhaseEventSchema = z.object({
phase: BackendPhaseSchema,
message: z.string().default(''),
progress: z.number().int().min(0).max(100).optional(),
subtask: z.string().optional()
subtask: z.string().optional(),
// Pause phase metadata
reset_timestamp: z.number().int().optional(), // Unix timestamp for rate limit reset
profile_id: z.string().optional() // Profile that hit the limit
});
export type PhaseEventPayload = z.infer<typeof PhaseEventSchema>;
+9 -8
View File
@@ -1,6 +1,5 @@
import { ChildProcess } from 'child_process';
import type { IdeationConfig } from '../../shared/types';
import type { CompletablePhase } from '../../shared/constants/phase-protocol';
import type { CompletablePhase, ExecutionPhase } from '../../shared/constants/phase-protocol';
import type { TaskEventPayload } from './task-event-schema';
/**
@@ -19,7 +18,7 @@ export interface AgentProcess {
}
export interface ExecutionProgressData {
phase: 'idle' | 'planning' | 'coding' | 'qa_review' | 'qa_fixing' | 'complete' | 'failed';
phase: ExecutionPhase;
phaseProgress: number;
overallProgress: number;
currentSubtask?: string;
@@ -31,11 +30,11 @@ export interface ExecutionProgressData {
export type ProcessType = 'spec-creation' | 'task-execution' | 'qa-process';
export interface AgentManagerEvents {
log: (taskId: string, log: string) => void;
error: (taskId: string, error: string) => void;
exit: (taskId: string, code: number | null, processType: ProcessType) => void;
'execution-progress': (taskId: string, progress: ExecutionProgressData) => void;
'task-event': (taskId: string, event: TaskEventPayload) => void;
log: (taskId: string, log: string, projectId?: string) => void;
error: (taskId: string, error: string, projectId?: string) => void;
exit: (taskId: string, code: number | null, processType: ProcessType, projectId?: string) => void;
'execution-progress': (taskId: string, progress: ExecutionProgressData, projectId?: string) => void;
'task-event': (taskId: string, event: TaskEventPayload, projectId?: string) => void;
}
// IdeationConfig now imported from shared types to maintain consistency
@@ -50,6 +49,7 @@ export interface TaskExecutionOptions {
workers?: number;
baseBranch?: string;
useWorktree?: boolean; // If false, use --direct mode (no worktree isolation)
useLocalBranch?: boolean; // If true, use local branch directly instead of preferring origin/branch
}
export interface SpecCreationMetadata {
@@ -73,6 +73,7 @@ export interface SpecCreationMetadata {
thinkingLevel?: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
// Workspace mode - whether to use worktree isolation
useWorktree?: boolean; // If false, use --direct mode (no worktree isolation)
useLocalBranch?: boolean; // If true, use local branch directly instead of preferring origin/branch
}
export interface IdeationProgressData {
+58 -12
View File
@@ -17,6 +17,8 @@
* - APP_UPDATE_ERROR: Error during update process
*/
import { accessSync, constants as fsConstants } from 'fs';
import path from 'path';
import { autoUpdater } from 'electron-updater';
import type { UpdateInfo } from 'electron-updater';
import { app, net } from 'electron';
@@ -24,6 +26,7 @@ import type { BrowserWindow } from 'electron';
import { IPC_CHANNELS } from '../shared/constants';
import type { AppUpdateInfo } from '../shared/types';
import { compareVersions } from './updater/version-manager';
import { isMacOS } from './platform';
// GitHub repo info for API calls
const GITHUB_OWNER = 'AndyMik90';
@@ -91,10 +94,13 @@ function formatReleaseNotes(releaseNotes: UpdateInfo['releaseNotes']): string |
*/
export function setUpdateChannel(channel: UpdateChannel): void {
autoUpdater.channel = channel;
// Enable pre-release scanning when beta channel is selected
// This allows electron-updater to find beta releases on GitHub
autoUpdater.allowPrerelease = channel === 'beta';
// Clear any downloaded update info when channel changes to prevent showing
// an Install button for an update from a different channel
downloadedUpdateInfo = null;
console.warn(`[app-updater] Update channel set to: ${channel}`);
console.warn(`[app-updater] Update channel set to: ${channel}, allowPrerelease: ${autoUpdater.allowPrerelease}`);
}
// Enable more verbose logging in debug mode
@@ -187,8 +193,7 @@ export function initializeAppUpdater(window: BrowserWindow, betaUpdates = false)
console.error('[app-updater] Update error:', error);
if (mainWindow) {
mainWindow.webContents.send(IPC_CHANNELS.APP_UPDATE_ERROR, {
message: error.message,
stack: error.stack
message: error.message
});
}
});
@@ -293,13 +298,57 @@ export async function downloadUpdate(): Promise<void> {
}
}
/**
* Check if the app is running from a read-only volume (e.g., DMG on macOS)
* Returns true if the app cannot be updated in place
*/
function isRunningFromReadOnlyVolume(): boolean {
if (!isMacOS()) {
return false;
}
const appPath = app.getAppPath();
// Check if the filesystem is read-only by testing write access.
// We don't use a /Volumes/ prefix check because writable external drives
// (USB, external SSDs) are also mounted under /Volumes/ on macOS.
try {
// Navigate from app.asar to the Contents/ directory (app.asar -> Resources -> Contents)
const contentsPath = path.resolve(appPath, '..', '..');
// Try to check if we can write to the app bundle's parent directory
accessSync(path.dirname(contentsPath), fsConstants.W_OK);
return false;
} catch (error: unknown) {
// Only treat as read-only if the filesystem itself is read-only (EROFS).
// Permission errors (EACCES) in managed/enterprise environments should not
// block updates — the updater may still have elevated access.
const code = error instanceof Error ? (error as NodeJS.ErrnoException).code : undefined;
return code === 'EROFS';
}
}
/**
* Quit and install update
* Called from IPC handler when user confirms installation
* Returns false if running from a read-only volume (update cannot proceed)
*/
export function quitAndInstall(): void {
export function quitAndInstall(): boolean {
// Check if running from read-only volume before attempting install
if (isRunningFromReadOnlyVolume()) {
console.warn('[app-updater] Cannot install: running from read-only volume');
if (mainWindow) {
mainWindow.webContents.send(IPC_CHANNELS.APP_UPDATE_READONLY_VOLUME, {
appPath: app.getAppPath()
});
}
return false;
}
console.warn('[app-updater] Quitting and installing update');
autoUpdater.quitAndInstall(false, true);
return true;
}
/**
@@ -402,7 +451,7 @@ async function fetchLatestStableRelease(): Promise<AppUpdateInfo | null> {
const version = latestStable.tag_name.replace(/^v/, '');
// Sanitize version string for logging (remove control characters and limit length)
// eslint-disable-next-line no-control-regex
// biome-ignore lint/suspicious/noControlCharactersInRegex: Intentionally matching control chars for sanitization
const safeVersion = String(version).replace(/[\x00-\x1f\x7f]/g, '').slice(0, 50);
console.warn('[app-updater] Found latest stable release:', safeVersion);
@@ -483,11 +532,8 @@ export async function setUpdateChannelWithDowngradeCheck(
channel: UpdateChannel,
triggerDowngradeCheck = false
): Promise<AppUpdateInfo | null> {
autoUpdater.channel = channel;
// Clear any downloaded update info when channel changes to prevent showing
// an Install button for an update from a different channel
downloadedUpdateInfo = null;
console.warn(`[app-updater] Update channel set to: ${channel}`);
// Use the shared channel-setting function to avoid code duplication
setUpdateChannel(channel);
// If switching to stable and downgrade check requested, look for stable version
if (channel === 'latest' && triggerDowngradeCheck) {
@@ -509,8 +555,8 @@ export async function setUpdateChannelWithDowngradeCheck(
* Uses electron-updater with allowDowngrade enabled to download older stable versions
*/
export async function downloadStableVersion(): Promise<void> {
// Switch to stable channel
autoUpdater.channel = 'latest';
// Switch to stable channel (resets allowPrerelease and clears downloadedUpdateInfo)
setUpdateChannel('latest');
// Enable downgrade to allow downloading older versions (e.g., stable when on beta)
autoUpdater.allowDowngrade = true;
console.warn('[app-updater] Downloading stable version (allowDowngrade=true)...');
@@ -44,7 +44,6 @@ export class ChangelogService extends EventEmitter {
private _pythonPath: string | null = null;
private claudePath: string;
private autoBuildSourcePath: string = '';
private cachedEnv: Record<string, string> | null = null;
private debugEnabled: boolean | null = null;
private generator: ChangelogGenerator | null = null;
private versionSuggester: VersionSuggester | null = null;
@@ -454,7 +453,7 @@ export class ChangelogService extends EventEmitter {
}
const parts = currentVersion.split('.').map(Number);
if (parts.length !== 3 || parts.some(isNaN)) {
if (parts.length !== 3 || parts.some(Number.isNaN)) {
return '1.0.0';
}
@@ -491,7 +490,7 @@ export class ChangelogService extends EventEmitter {
* Suggest version using AI analysis of git commits
*/
async suggestVersionFromCommits(
projectPath: string,
_projectPath: string,
commits: import('../../shared/types').GitCommit[],
currentVersion?: string
): Promise<{ version: string; reason: string }> {
@@ -502,7 +501,7 @@ export class ChangelogService extends EventEmitter {
}
const parts = currentVersion.split('.').map(Number);
if (parts.length !== 3 || parts.some(isNaN)) {
if (parts.length !== 3 || parts.some(Number.isNaN)) {
return { version: '1.0.0', reason: 'Invalid current version, resetting to 1.0.0' };
}
@@ -198,7 +198,6 @@ except Exception as e:
case 'minor':
newVersion = `${major}.${minor + 1}.0`;
break;
case 'patch':
default:
newVersion = `${major}.${minor}.${patch + 1}`;
break;
@@ -32,7 +32,6 @@ import {
clearRateLimitEvents as clearRateLimitEventsImpl
} from './claude-profile/rate-limit-manager';
import {
loadProfileStore,
loadProfileStoreAsync,
saveProfileStore,
ProfileStoreData,
@@ -43,7 +42,7 @@ import {
shouldProactivelySwitch as shouldProactivelySwitchImpl,
getProfilesSortedByAvailability as getProfilesSortedByAvailabilityImpl
} from './claude-profile/profile-scorer';
import { getCredentialsFromKeychain, normalizeWindowsPath } from './claude-profile/credential-utils';
import { getCredentialsFromKeychain, normalizeWindowsPath, updateProfileSubscriptionMetadata } from './claude-profile/credential-utils';
import {
CLAUDE_PROFILES_DIR,
generateProfileId as generateProfileIdImpl,
@@ -96,6 +95,10 @@ export class ClaudeProfileManager {
// This repairs emails that were truncated due to ANSI escape codes in terminal output
this.migrateCorruptedEmails();
// Populate missing subscription metadata for existing profiles
// This reads subscriptionType and rateLimitTier from Keychain credentials
this.populateSubscriptionMetadata();
this.initialized = true;
}
@@ -133,6 +136,58 @@ export class ClaudeProfileManager {
}
}
/**
* Populate missing subscription metadata (subscriptionType, rateLimitTier) for existing profiles.
*
* This reads from Keychain credentials and updates profiles that don't have this metadata.
* Runs on initialization to ensure existing profiles get the subscription info for UI display.
*/
private populateSubscriptionMetadata(): void {
let needsSave = false;
for (const profile of this.data.profiles) {
if (!profile.configDir) {
continue;
}
// Skip if profile already has subscription metadata
if (profile.subscriptionType && profile.rateLimitTier) {
continue;
}
// Expand ~ to home directory
const expandedConfigDir = normalizeWindowsPath(
profile.configDir.startsWith('~')
? profile.configDir.replace(/^~/, homedir())
: profile.configDir
);
// Use helper with onlyIfMissing option to preserve existing values
const result = updateProfileSubscriptionMetadata(profile, expandedConfigDir, { onlyIfMissing: true });
if (result.subscriptionTypeUpdated) {
needsSave = true;
console.warn('[ClaudeProfileManager] Populated subscriptionType for profile:', {
profileId: profile.id,
subscriptionType: result.subscriptionType
});
}
if (result.rateLimitTierUpdated) {
needsSave = true;
console.warn('[ClaudeProfileManager] Populated rateLimitTier for profile:', {
profileId: profile.id,
rateLimitTier: result.rateLimitTier
});
}
}
if (needsSave) {
this.save();
console.warn('[ClaudeProfileManager] Subscription metadata population complete');
}
}
/**
* Check if the profile manager has been initialized
*/
@@ -140,31 +195,6 @@ export class ClaudeProfileManager {
return this.initialized;
}
/**
* Load profiles from disk
*/
private load(): ProfileStoreData {
const loadedData = loadProfileStore(this.storePath);
if (loadedData) {
if (process.env.DEBUG === 'true') {
console.warn('[ClaudeProfileManager] Loaded profiles:', {
count: loadedData.profiles.length,
activeProfileId: loadedData.activeProfileId,
profiles: loadedData.profiles.map(p => ({
id: p.id,
name: p.name,
email: p.email,
isDefault: p.isDefault
}))
});
}
return loadedData;
}
// Return default with a single "Default" profile
return this.createDefaultData();
}
/**
* Create default profile data
*
@@ -0,0 +1,673 @@
/**
* Unit tests for OperationRegistry
*
* Tests cover:
* - Singleton pattern
* - Operation registration/unregistration
* - Profile-based querying
* - Summary generation
* - Operation restart functionality
* - Event emissions
* - Edge cases
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
getOperationRegistry,
resetOperationRegistry,
type OperationType,
} from '../operation-registry';
describe('OperationRegistry', () => {
beforeEach(() => {
// Reset registry before each test
resetOperationRegistry();
});
afterEach(() => {
// Clean up after each test
resetOperationRegistry();
});
describe('Singleton Pattern', () => {
it('should return the same instance on multiple calls', () => {
const instance1 = getOperationRegistry();
const instance2 = getOperationRegistry();
expect(instance1).toBe(instance2);
});
it('should create new instance after reset', () => {
const instance1 = getOperationRegistry();
resetOperationRegistry();
const instance2 = getOperationRegistry();
expect(instance1).not.toBe(instance2);
});
it('should clear all operations on reset', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
registry.registerOperation(
'op1',
'spec-creation',
'profile1',
'Profile 1',
mockRestart
);
expect(registry.getOperationCount()).toBe(1);
resetOperationRegistry();
const newRegistry = getOperationRegistry();
expect(newRegistry.getOperationCount()).toBe(0);
});
});
describe('registerOperation', () => {
it('should register a basic operation', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
registry.registerOperation(
'op1',
'spec-creation',
'profile1',
'Profile 1',
mockRestart
);
const operation = registry.getOperation('op1');
expect(operation).toBeDefined();
expect(operation?.id).toBe('op1');
expect(operation?.type).toBe('spec-creation');
expect(operation?.profileId).toBe('profile1');
expect(operation?.profileName).toBe('Profile 1');
expect(operation?.restartFn).toBe(mockRestart);
expect(operation?.startedAt).toBeInstanceOf(Date);
});
it('should register operation with optional stopFn', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
const mockStop = vi.fn();
registry.registerOperation(
'op1',
'pr-review',
'profile1',
'Profile 1',
mockRestart,
{ stopFn: mockStop }
);
const operation = registry.getOperation('op1');
expect(operation?.stopFn).toBe(mockStop);
});
it('should register operation with metadata', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
const metadata = { projectId: 'proj1', prNumber: 123 };
registry.registerOperation(
'op1',
'pr-review',
'profile1',
'Profile 1',
mockRestart,
{ metadata }
);
const operation = registry.getOperation('op1');
expect(operation?.metadata).toEqual(metadata);
});
it('should emit operation-registered event', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
const eventListener = vi.fn();
registry.on('operation-registered', eventListener);
registry.registerOperation(
'op1',
'task-execution',
'profile1',
'Profile 1',
mockRestart
);
expect(eventListener).toHaveBeenCalledTimes(1);
const emittedOperation = eventListener.mock.calls[0][0];
expect(emittedOperation.id).toBe('op1');
expect(emittedOperation.type).toBe('task-execution');
});
it('should increment operation count', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
expect(registry.getOperationCount()).toBe(0);
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
expect(registry.getOperationCount()).toBe(1);
registry.registerOperation('op2', 'roadmap', 'profile1', 'Profile 1', mockRestart);
expect(registry.getOperationCount()).toBe(2);
});
it('should allow registering multiple operation types', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
const types: OperationType[] = [
'spec-creation',
'task-execution',
'pr-review',
'mr-review',
'insights',
'roadmap',
'changelog',
'ideation',
'triage',
'other',
];
types.forEach((type, index) => {
registry.registerOperation(
`op${index}`,
type,
'profile1',
'Profile 1',
mockRestart
);
});
expect(registry.getOperationCount()).toBe(types.length);
types.forEach((type, index) => {
const op = registry.getOperation(`op${index}`);
expect(op?.type).toBe(type);
});
});
});
describe('unregisterOperation', () => {
it('should unregister an existing operation', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
expect(registry.getOperation('op1')).toBeDefined();
registry.unregisterOperation('op1');
expect(registry.getOperation('op1')).toBeUndefined();
expect(registry.getOperationCount()).toBe(0);
});
it('should emit operation-unregistered event', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
const eventListener = vi.fn();
registry.on('operation-unregistered', eventListener);
registry.registerOperation('op1', 'task-execution', 'profile1', 'Profile 1', mockRestart);
registry.unregisterOperation('op1');
expect(eventListener).toHaveBeenCalledTimes(1);
expect(eventListener).toHaveBeenCalledWith('op1', 'task-execution');
});
it('should handle unregistering non-existent operation gracefully', () => {
const registry = getOperationRegistry();
const eventListener = vi.fn();
registry.on('operation-unregistered', eventListener);
// Should not throw
expect(() => registry.unregisterOperation('non-existent')).not.toThrow();
// Should not emit event for non-existent operation
expect(eventListener).not.toHaveBeenCalled();
});
it('should decrement operation count', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
registry.registerOperation('op2', 'roadmap', 'profile1', 'Profile 1', mockRestart);
expect(registry.getOperationCount()).toBe(2);
registry.unregisterOperation('op1');
expect(registry.getOperationCount()).toBe(1);
registry.unregisterOperation('op2');
expect(registry.getOperationCount()).toBe(0);
});
});
describe('getOperation', () => {
it('should retrieve operation by id', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
registry.registerOperation('op1', 'pr-review', 'profile1', 'Profile 1', mockRestart);
const operation = registry.getOperation('op1');
expect(operation).toBeDefined();
expect(operation?.id).toBe('op1');
});
it('should return undefined for non-existent operation', () => {
const registry = getOperationRegistry();
const operation = registry.getOperation('non-existent');
expect(operation).toBeUndefined();
});
});
describe('getOperationsByProfile', () => {
it('should return operations for a specific profile', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart);
registry.registerOperation('op3', 'pr-review', 'profile2', 'Profile 2', mockRestart);
const profile1Ops = registry.getOperationsByProfile('profile1');
expect(profile1Ops).toHaveLength(2);
expect(profile1Ops.map(op => op.id)).toEqual(['op1', 'op2']);
const profile2Ops = registry.getOperationsByProfile('profile2');
expect(profile2Ops).toHaveLength(1);
expect(profile2Ops[0].id).toBe('op3');
});
it('should return empty array for profile with no operations', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
const profile2Ops = registry.getOperationsByProfile('profile2');
expect(profile2Ops).toEqual([]);
});
});
describe('getAllOperationsByProfile', () => {
it('should return all operations grouped by profile', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart);
registry.registerOperation('op3', 'pr-review', 'profile2', 'Profile 2', mockRestart);
registry.registerOperation('op4', 'roadmap', 'profile3', 'Profile 3', mockRestart);
const allOps = registry.getAllOperationsByProfile();
expect(Object.keys(allOps)).toEqual(['profile1', 'profile2', 'profile3']);
expect(allOps['profile1']).toHaveLength(2);
expect(allOps['profile2']).toHaveLength(1);
expect(allOps['profile3']).toHaveLength(1);
});
it('should return empty object when no operations', () => {
const registry = getOperationRegistry();
const allOps = registry.getAllOperationsByProfile();
expect(allOps).toEqual({});
});
});
describe('getSummary', () => {
it('should return correct summary with no operations', () => {
const registry = getOperationRegistry();
const summary = registry.getSummary();
expect(summary.totalRunning).toBe(0);
expect(summary.byProfile).toEqual({});
expect(summary.byType).toEqual({});
});
it('should count operations by profile', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart);
registry.registerOperation('op3', 'pr-review', 'profile2', 'Profile 2', mockRestart);
const summary = registry.getSummary();
expect(summary.totalRunning).toBe(3);
expect(summary.byProfile['profile1']).toEqual(['op1', 'op2']);
expect(summary.byProfile['profile2']).toEqual(['op3']);
});
it('should count operations by type', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
registry.registerOperation('op2', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
registry.registerOperation('op3', 'pr-review', 'profile1', 'Profile 1', mockRestart);
registry.registerOperation('op4', 'insights', 'profile2', 'Profile 2', mockRestart);
const summary = registry.getSummary();
expect(summary.byType['spec-creation']).toBe(2);
expect(summary.byType['pr-review']).toBe(1);
expect(summary.byType['insights']).toBe(1);
});
it('should return complete summary with multiple profiles and types', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart);
registry.registerOperation('op3', 'pr-review', 'profile2', 'Profile 2', mockRestart);
registry.registerOperation('op4', 'insights', 'profile2', 'Profile 2', mockRestart);
registry.registerOperation('op5', 'roadmap', 'profile3', 'Profile 3', mockRestart);
const summary = registry.getSummary();
expect(summary.totalRunning).toBe(5);
expect(Object.keys(summary.byProfile)).toHaveLength(3);
expect(Object.keys(summary.byType)).toHaveLength(5);
});
});
describe('restartOperationsOnProfile', () => {
it('should restart all operations on a profile', async () => {
const registry = getOperationRegistry();
const mockRestart1 = vi.fn().mockResolvedValue(true);
const mockRestart2 = vi.fn().mockResolvedValue(true);
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart1);
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart2);
const count = await registry.restartOperationsOnProfile(
'profile1',
'profile2',
'Profile 2'
);
expect(count).toBe(2);
expect(mockRestart1).toHaveBeenCalledWith('profile2');
expect(mockRestart2).toHaveBeenCalledWith('profile2');
// Verify profile was updated
const op1 = registry.getOperation('op1');
const op2 = registry.getOperation('op2');
expect(op1?.profileId).toBe('profile2');
expect(op1?.profileName).toBe('Profile 2');
expect(op2?.profileId).toBe('profile2');
expect(op2?.profileName).toBe('Profile 2');
});
it('should call stopFn before restart if provided', async () => {
const registry = getOperationRegistry();
const mockStop = vi.fn().mockResolvedValue(undefined);
const mockRestart = vi.fn().mockResolvedValue(true);
registry.registerOperation(
'op1',
'pr-review',
'profile1',
'Profile 1',
mockRestart,
{ stopFn: mockStop }
);
await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2');
expect(mockStop).toHaveBeenCalledTimes(1);
expect(mockRestart).toHaveBeenCalledWith('profile2');
// Ensure stopFn was called before restartFn
expect(mockStop.mock.invocationCallOrder[0]).toBeLessThan(
mockRestart.mock.invocationCallOrder[0]
);
});
it('should return 0 when no operations on profile', async () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockResolvedValue(true);
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
const count = await registry.restartOperationsOnProfile(
'profile2',
'profile3',
'Profile 3'
);
expect(count).toBe(0);
expect(mockRestart).not.toHaveBeenCalled();
});
it('should handle restart failure gracefully', async () => {
const registry = getOperationRegistry();
const mockRestart1 = vi.fn().mockResolvedValue(true);
const mockRestart2 = vi.fn().mockResolvedValue(false); // Fails
const mockRestart3 = vi.fn().mockRejectedValue(new Error('Restart error')); // Throws
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart1);
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart2);
registry.registerOperation('op3', 'pr-review', 'profile1', 'Profile 1', mockRestart3);
const count = await registry.restartOperationsOnProfile(
'profile1',
'profile2',
'Profile 2'
);
// Only op1 succeeded
expect(count).toBe(1);
// op1 should have updated profile
const op1 = registry.getOperation('op1');
expect(op1?.profileId).toBe('profile2');
// op2 and op3 should still have old profile
const op2 = registry.getOperation('op2');
const op3 = registry.getOperation('op3');
expect(op2?.profileId).toBe('profile1');
expect(op3?.profileId).toBe('profile1');
});
it('should emit operation-restarted event for each successful restart', async () => {
const registry = getOperationRegistry();
const mockRestart1 = vi.fn().mockResolvedValue(true);
const mockRestart2 = vi.fn().mockResolvedValue(true);
const eventListener = vi.fn();
registry.on('operation-restarted', eventListener);
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart1);
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart2);
await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2');
expect(eventListener).toHaveBeenCalledTimes(2);
expect(eventListener).toHaveBeenCalledWith('op1', 'profile1', 'profile2');
expect(eventListener).toHaveBeenCalledWith('op2', 'profile1', 'profile2');
});
it('should emit operations-restarted event after restart', async () => {
const registry = getOperationRegistry();
const mockRestart1 = vi.fn().mockResolvedValue(true);
const mockRestart2 = vi.fn().mockResolvedValue(true);
const eventListener = vi.fn();
registry.on('operations-restarted', eventListener);
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart1);
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart2);
await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2');
expect(eventListener).toHaveBeenCalledTimes(1);
expect(eventListener).toHaveBeenCalledWith(2, 'profile1', 'profile2');
});
it('should not emit operations-restarted event if no restarts succeeded', async () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockResolvedValue(false);
const eventListener = vi.fn();
registry.on('operations-restarted', eventListener);
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2');
expect(eventListener).not.toHaveBeenCalled();
});
it('should handle synchronous restart functions', async () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true); // Synchronous return
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
const count = await registry.restartOperationsOnProfile(
'profile1',
'profile2',
'Profile 2'
);
expect(count).toBe(1);
expect(mockRestart).toHaveBeenCalledWith('profile2');
const op1 = registry.getOperation('op1');
expect(op1?.profileId).toBe('profile2');
});
});
describe('updateOperationProfile', () => {
it('should update profile for existing operation', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
registry.updateOperationProfile('op1', 'profile2', 'Profile 2');
const operation = registry.getOperation('op1');
expect(operation?.profileId).toBe('profile2');
expect(operation?.profileName).toBe('Profile 2');
});
it('should handle updating non-existent operation gracefully', () => {
const registry = getOperationRegistry();
// Should not throw
expect(() =>
registry.updateOperationProfile('non-existent', 'profile2', 'Profile 2')
).not.toThrow();
});
});
describe('clear', () => {
it('should clear all operations', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart);
registry.registerOperation('op3', 'pr-review', 'profile2', 'Profile 2', mockRestart);
expect(registry.getOperationCount()).toBe(3);
registry.clear();
expect(registry.getOperationCount()).toBe(0);
expect(registry.getSummary().totalRunning).toBe(0);
});
});
describe('Edge Cases', () => {
it('should handle registering operation with same id (overwrites)', () => {
const registry = getOperationRegistry();
const mockRestart1 = vi.fn().mockReturnValue(true);
const mockRestart2 = vi.fn().mockReturnValue(true);
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart1);
registry.registerOperation('op1', 'task-execution', 'profile2', 'Profile 2', mockRestart2);
const operation = registry.getOperation('op1');
expect(operation?.type).toBe('task-execution');
expect(operation?.profileId).toBe('profile2');
expect(registry.getOperationCount()).toBe(1);
});
it('should handle multiple unregisters of same operation', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
registry.unregisterOperation('op1');
expect(registry.getOperationCount()).toBe(0);
// Second unregister should not throw or cause issues
registry.unregisterOperation('op1');
expect(registry.getOperationCount()).toBe(0);
});
it('should handle restart with no operations gracefully', async () => {
const registry = getOperationRegistry();
const count = await registry.restartOperationsOnProfile(
'profile1',
'profile2',
'Profile 2'
);
expect(count).toBe(0);
});
it('should preserve operation metadata through restart', async () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockResolvedValue(true);
const metadata = { projectId: 'proj1', prNumber: 123 };
registry.registerOperation(
'op1',
'pr-review',
'profile1',
'Profile 1',
mockRestart,
{ metadata }
);
await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2');
const operation = registry.getOperation('op1');
expect(operation?.metadata).toEqual(metadata);
});
it('should preserve startedAt timestamp through restart', async () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockResolvedValue(true);
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
const originalOp = registry.getOperation('op1');
const originalStartTime = originalOp?.startedAt;
await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2');
const updatedOp = registry.getOperation('op1');
expect(updatedOp?.startedAt).toBe(originalStartTime);
});
});
});
@@ -345,7 +345,7 @@ function executeCredentialRead(
executablePath: string,
args: string[],
timeout: number,
identifier: string
_identifier: string
): string | null {
try {
const result = execFileSync(executablePath, args, {
@@ -2205,3 +2205,101 @@ export function updateKeychainCredentials(
return { success: false, error: `Unsupported platform: ${process.platform}` };
}
// =============================================================================
// Profile Subscription Metadata Helper
// =============================================================================
/**
* Result of updating profile subscription metadata
*/
export interface UpdateSubscriptionMetadataResult {
/** Whether subscriptionType was updated */
subscriptionTypeUpdated: boolean;
/** Whether rateLimitTier was updated */
rateLimitTierUpdated: boolean;
/** The subscriptionType value (if found) */
subscriptionType?: string | null;
/** The rateLimitTier value (if found) */
rateLimitTier?: string | null;
}
/**
* Options for updateProfileSubscriptionMetadata
*/
export interface UpdateSubscriptionMetadataOptions {
/**
* If true, only update fields that are currently missing (undefined/null/empty).
* This is useful for migration/initialization code that should not overwrite existing values.
* Default: false (always update if credentials have values)
*/
onlyIfMissing?: boolean;
}
/**
* Update a profile's subscription metadata (subscriptionType, rateLimitTier) from Keychain credentials.
*
* This helper centralizes the common pattern of reading subscription info from Keychain
* and updating a profile object. It's used after OAuth login, onboarding completion,
* and profile authentication verification.
*
* NOTE: This function mutates the profile object directly. The caller is responsible
* for saving the profile after calling this function.
*
* @param profile - The profile object to update (must have subscriptionType and rateLimitTier properties)
* @param configDirOrCredentials - Either a config directory path to read credentials from,
* or pre-fetched FullOAuthCredentials to avoid redundant reads
* @param options - Optional settings like onlyIfMissing
* @returns Information about what was updated
*
* @example
* ```typescript
* // Option 1: Pass configDir - helper fetches credentials
* const result = updateProfileSubscriptionMetadata(profile, profile.configDir);
*
* // Option 2: Pass pre-fetched credentials (more efficient when already fetched)
* const fullCreds = getFullCredentialsFromKeychain(profile.configDir);
* const result = updateProfileSubscriptionMetadata(profile, fullCreds);
*
* // Option 3: Only populate if missing (for migration/initialization)
* const result = updateProfileSubscriptionMetadata(profile, profile.configDir, { onlyIfMissing: true });
*
* if (result.subscriptionTypeUpdated || result.rateLimitTierUpdated) {
* profileManager.saveProfile(profile);
* }
* ```
*/
export function updateProfileSubscriptionMetadata(
profile: { subscriptionType?: string | null; rateLimitTier?: string | null },
configDirOrCredentials: string | undefined | FullOAuthCredentials,
options?: UpdateSubscriptionMetadataOptions
): UpdateSubscriptionMetadataResult {
const result: UpdateSubscriptionMetadataResult = {
subscriptionTypeUpdated: false,
rateLimitTierUpdated: false,
};
const onlyIfMissing = options?.onlyIfMissing ?? false;
// Determine if we received pre-fetched credentials or a configDir
const fullCreds: FullOAuthCredentials =
typeof configDirOrCredentials === 'object' && configDirOrCredentials !== null
? configDirOrCredentials
: getFullCredentialsFromKeychain(configDirOrCredentials);
// Update subscriptionType if credentials have it and (not onlyIfMissing OR profile doesn't have it)
if (fullCreds.subscriptionType && (!onlyIfMissing || !profile.subscriptionType)) {
profile.subscriptionType = fullCreds.subscriptionType;
result.subscriptionTypeUpdated = true;
result.subscriptionType = fullCreds.subscriptionType;
}
// Update rateLimitTier if credentials have it and (not onlyIfMissing OR profile doesn't have it)
if (fullCreds.rateLimitTier && (!onlyIfMissing || !profile.rateLimitTier)) {
profile.rateLimitTier = fullCreds.rateLimitTier;
result.rateLimitTierUpdated = true;
result.rateLimitTier = fullCreds.rateLimitTier;
}
return result;
}
@@ -0,0 +1,497 @@
/**
* Unified registry for ALL Claude Agent SDK operations.
*
* This is the single source of truth for tracking running operations that use
* Claude profiles. It enables:
* 1. Proactive account swapping - restart operations on a different profile
* 2. Rate limit recovery - know which operations to restart after auth refresh
* 3. Usage attribution - track which profile is being used by which operation
*
* Operations include:
* - Autonomous tasks (spec creation, task execution)
* - GitHub PR reviews
* - GitLab MR reviews
* - Insights analysis
* - Roadmap generation
* - Changelog generation
* - Any other Claude SDK subprocess
*/
import { EventEmitter } from 'events';
/**
* Types of operations that use Claude SDK
*/
export type OperationType =
| 'spec-creation'
| 'task-execution'
| 'pr-review'
| 'mr-review'
| 'insights'
| 'roadmap'
| 'changelog'
| 'ideation'
| 'triage'
| 'other';
/**
* Registered operation entry
*
* IMPORTANT: Object reference stability during restarts
* =====================================================
* When an operation is restarted via restartFn, the restartFn implementation may
* choose to re-register the operation (creating a new RegisteredOperation object)
* OR update the existing one. Either approach is valid:
*
* 1. RE-REGISTRATION (AgentManager pattern):
* - restartFn calls registerOperation() which replaces the Map entry
* - Creates a new RegisteredOperation object with fresh closures
* - Previous object references become stale and should not be used
* - Callers MUST call getOperation(id) again to get the fresh reference
*
* 2. IN-PLACE UPDATE (alternative pattern):
* - restartFn updates internal state but doesn't re-register
* - Object reference remains valid
* - Registry calls updateOperationProfile() to sync profileId
*
* BEST PRACTICE for consumers:
* - Don't hold long-lived references to RegisteredOperation objects
* - Always use getOperation(id) to get current state
* - Subscribe to 'operation-restarted' events to know when to refresh
* - If you must hold a reference, listen for 'operation-restarted' and refresh it
*/
export interface RegisteredOperation {
/** Unique operation ID */
id: string;
/** Type of operation */
type: OperationType;
/** Profile ID currently being used */
profileId: string;
/** Profile name for logging */
profileName: string;
/** When the operation started */
startedAt: Date;
/** Optional metadata (project ID, PR number, etc.) */
metadata?: Record<string, unknown>;
/**
* Function to restart this operation with a new profile.
* Returns true if restart was initiated successfully.
* The registry will update the profileId after successful restart.
*
* IMPORTANT: This function may re-register the operation (creating a new object)
* or update in-place. Callers should use getOperation(id) after restart to get
* the current reference.
*/
restartFn: (newProfileId: string) => boolean | Promise<boolean>;
/**
* Optional function to stop the operation.
* Called before restart if provided.
*/
stopFn?: () => void | Promise<void>;
}
/**
* Events emitted by the operation registry
*
* NOTE: This interface is defined for documentation purposes only. It describes the event types
* that ClaudeOperationRegistry can emit, but is not currently enforced at the type system level.
* EventEmitter uses runtime event names, so type-safe event binding would require additional
* type assertion infrastructure. This interface serves as documentation for consumers of the
* operation registry to know which events are available and their callback signatures.
*/
export interface OperationRegistryEvents {
'operation-registered': (operation: RegisteredOperation) => void;
'operation-unregistered': (operationId: string, type: OperationType) => void;
'operation-restarted': (operationId: string, oldProfileId: string, newProfileId: string) => void;
'operations-restarted': (count: number, oldProfileId: string, newProfileId: string) => void;
'operation-profile-updated': (operationId: string, oldProfileId: string, newProfileId: string) => void;
}
/**
* Singleton registry for Claude SDK operations
*
* CONSUMER GUIDELINES: Object Reference Stability
* ================================================
* Operations may be restarted during profile swaps. When this happens:
*
* 1. The operation's restartFn is called with a new profileId
* 2. The restartFn may choose to:
* a) Re-register the operation (creates new RegisteredOperation object), OR
* b) Update internal state without re-registering (keeps same object)
*
* 3. Either pattern is valid, but has implications for consumers:
* - Pattern (a): Previous object references become stale
* - Pattern (b): Object references remain valid
*
* BEST PRACTICES for consumers:
* - Don't hold long-lived references to RegisteredOperation objects
* - Always use getOperation(id) to get current state when needed
* - Subscribe to 'operation-restarted' events to know when state may have changed
* - Use hasOperation(id) to verify an operation is still registered
*
* EXAMPLE: Safely working with operation references
* ```typescript
* const registry = getOperationRegistry();
*
* // Initial fetch
* let operation = registry.getOperation('task-123');
*
* // Listen for restarts
* registry.onOperationRestarted((operationId, oldProfileId, newProfileId) => {
* if (operationId === 'task-123') {
* // Refresh reference after restart
* operation = registry.getOperation('task-123');
* console.log('Operation restarted with new profile:', newProfileId);
* }
* });
*
* // When accessing operation state later, prefer fresh fetch:
* const currentOp = registry.getOperation('task-123');
* if (currentOp) {
* console.log('Current profile:', currentOp.profileId);
* }
* ```
*/
class ClaudeOperationRegistry extends EventEmitter {
private operations: Map<string, RegisteredOperation> = new Map();
private debugMode: boolean;
constructor() {
super();
this.debugMode = process.env.DEBUG === 'true';
}
private debugLog(...args: unknown[]): void {
if (this.debugMode) {
console.log('[OperationRegistry]', ...args);
}
}
/**
* Register a new operation
*/
registerOperation(
id: string,
type: OperationType,
profileId: string,
profileName: string,
restartFn: RegisteredOperation['restartFn'],
options?: {
stopFn?: RegisteredOperation['stopFn'];
metadata?: Record<string, unknown>;
}
): void {
const operation: RegisteredOperation = {
id,
type,
profileId,
profileName,
startedAt: new Date(),
restartFn,
stopFn: options?.stopFn,
metadata: options?.metadata,
};
this.operations.set(id, operation);
this.debugLog('Operation registered:', {
id,
type,
profileId,
profileName,
metadata: options?.metadata,
});
this.emit('operation-registered', operation);
}
/**
* Unregister an operation (when it completes or is cancelled)
*/
unregisterOperation(id: string): void {
const operation = this.operations.get(id);
if (operation) {
this.operations.delete(id);
this.debugLog('Operation unregistered:', { id, type: operation.type });
this.emit('operation-unregistered', id, operation.type);
}
}
/**
* Get all operations running on a specific profile
*/
getOperationsByProfile(profileId: string): RegisteredOperation[] {
const result: RegisteredOperation[] = [];
for (const op of this.operations.values()) {
if (op.profileId === profileId) {
result.push(op);
}
}
return result;
}
/**
* Get all running operations grouped by profile
*/
getAllOperationsByProfile(): Record<string, RegisteredOperation[]> {
const result: Record<string, RegisteredOperation[]> = {};
for (const op of this.operations.values()) {
if (!result[op.profileId]) {
result[op.profileId] = [];
}
result[op.profileId].push(op);
}
return result;
}
/**
* Get operation by ID
*
* IMPORTANT: Always call this method to get the current operation state.
* Don't hold long-lived references to RegisteredOperation objects, as they
* may become stale after a restart. Instead, call getOperation(id) whenever
* you need current state, or subscribe to 'operation-restarted' events.
*/
getOperation(id: string): RegisteredOperation | undefined {
return this.operations.get(id);
}
/**
* Check if an operation exists and is currently registered.
* Use this to verify an operation reference is still valid.
*
* @param id - Operation ID to check
* @returns true if operation exists in registry, false otherwise
*/
hasOperation(id: string): boolean {
return this.operations.has(id);
}
/**
* Get count of running operations
*/
getOperationCount(): number {
return this.operations.size;
}
/**
* Get summary of running operations for logging
*/
getSummary(): {
totalRunning: number;
byProfile: Record<string, string[]>;
byType: Record<OperationType, number>;
} {
const byProfile: Record<string, string[]> = {};
const byType: Record<string, number> = {};
for (const op of this.operations.values()) {
// By profile
if (!byProfile[op.profileId]) {
byProfile[op.profileId] = [];
}
byProfile[op.profileId].push(op.id);
// By type
byType[op.type] = (byType[op.type] || 0) + 1;
}
return {
totalRunning: this.operations.size,
byProfile,
byType: byType as Record<OperationType, number>,
};
}
/**
* Restart all operations running on a specific profile with a new profile.
* This is called by UsageMonitor during proactive swaps.
*
* IMPORTANT: Object reference stability after restart
* ====================================================
* When operations are restarted, their restartFn implementations may:
* 1. Re-register the operation (AgentManager pattern) - creates new object
* 2. Update in-place (alternative pattern) - keeps same object
*
* For consumers holding operation references:
* - Your reference may become stale if the operation re-registers
* - Always call getOperation(id) after this method to get fresh reference
* - Or subscribe to 'operation-restarted' events and refresh on each event
*
* This method emits:
* - 'operation-restarted' for each successful restart (use this to refresh refs)
* - 'operations-restarted' once with total count
* - 'operation-profile-updated' for each profile update
*
* @param oldProfileId - Profile ID to migrate away from
* @param newProfileId - Profile ID to migrate to
* @param newProfileName - Profile name for logging
* @returns Number of operations that were restarted
*/
async restartOperationsOnProfile(
oldProfileId: string,
newProfileId: string,
newProfileName: string
): Promise<number> {
const operations = this.getOperationsByProfile(oldProfileId);
if (operations.length === 0) {
this.debugLog('No operations to restart on profile:', oldProfileId);
return 0;
}
console.log('[OperationRegistry] Restarting', operations.length, 'operations:', {
from: oldProfileId,
to: newProfileId,
operations: operations.map(op => ({ id: op.id, type: op.type })),
});
let restartedCount = 0;
for (const op of operations) {
try {
// Stop the operation first if a stop function is provided
if (op.stopFn) {
this.debugLog('Stopping operation before restart:', op.id);
await op.stopFn();
}
// Call the restart function
this.debugLog('Restarting operation:', op.id, 'with profile:', newProfileId);
const success = await op.restartFn(newProfileId);
if (success) {
restartedCount++;
// Update the profile for operations that weren't re-registered during restart.
// For AgentManager tasks, restartFn may create a NEW object in the Map,
// in which case this update is harmless (updates the new reference).
// For other operations, this ensures the profile is properly updated.
this.updateOperationProfile(op.id, newProfileId, newProfileName);
// Re-fetch from Map to get the current object (restartFn may have
// re-registered the operation with a new object)
const currentOp = this.operations.get(op.id);
console.log('[OperationRegistry] Operation restarted successfully:', {
id: op.id,
type: currentOp?.type ?? op.type,
newProfile: newProfileName,
});
this.emit('operation-restarted', op.id, oldProfileId, newProfileId);
} else {
console.warn('[OperationRegistry] Operation restart returned false:', op.id);
}
} catch (error) {
console.error('[OperationRegistry] Failed to restart operation:', op.id, error);
}
}
if (restartedCount > 0) {
this.emit('operations-restarted', restartedCount, oldProfileId, newProfileId);
}
console.log('[OperationRegistry] Restart complete:', {
total: operations.length,
succeeded: restartedCount,
failed: operations.length - restartedCount,
});
return restartedCount;
}
/**
* Update the profile assignment for an operation (e.g., after restart)
*/
updateOperationProfile(id: string, newProfileId: string, newProfileName: string): void {
const operation = this.operations.get(id);
if (operation) {
const oldProfileId = operation.profileId;
operation.profileId = newProfileId;
operation.profileName = newProfileName;
this.debugLog('Operation profile updated:', {
id,
from: oldProfileId,
to: newProfileId,
});
this.emit('operation-profile-updated', id, oldProfileId, newProfileId);
}
}
/**
* Clear all registered operations (for testing or cleanup)
*/
clear(): void {
this.operations.clear();
this.debugLog('All operations cleared');
}
/**
* Type-safe event subscription: operation-registered
* Subscribe to operation registration events
*/
onOperationRegistered(callback: (operation: RegisteredOperation) => void): () => void {
this.on('operation-registered', callback);
return () => this.off('operation-registered', callback);
}
/**
* Type-safe event subscription: operation-unregistered
* Subscribe to operation unregistration events
*/
onOperationUnregistered(callback: (operationId: string, type: OperationType) => void): () => void {
this.on('operation-unregistered', callback);
return () => this.off('operation-unregistered', callback);
}
/**
* Type-safe event subscription: operation-restarted
* Subscribe to individual operation restart events
*/
onOperationRestarted(callback: (operationId: string, oldProfileId: string, newProfileId: string) => void): () => void {
this.on('operation-restarted', callback);
return () => this.off('operation-restarted', callback);
}
/**
* Type-safe event subscription: operations-restarted
* Subscribe to batch operation restart events
*/
onOperationsRestarted(callback: (count: number, oldProfileId: string, newProfileId: string) => void): () => void {
this.on('operations-restarted', callback);
return () => this.off('operations-restarted', callback);
}
/**
* Type-safe event subscription: operation-profile-updated
* Subscribe to operation profile update events
*/
onOperationProfileUpdated(callback: (operationId: string, oldProfileId: string, newProfileId: string) => void): () => void {
this.on('operation-profile-updated', callback);
return () => this.off('operation-profile-updated', callback);
}
}
// Singleton instance
let registryInstance: ClaudeOperationRegistry | null = null;
/**
* Get the singleton ClaudeOperationRegistry instance
*/
export function getOperationRegistry(): ClaudeOperationRegistry {
if (!registryInstance) {
registryInstance = new ClaudeOperationRegistry();
}
return registryInstance;
}
/**
* Reset the registry (for testing)
*/
export function resetOperationRegistry(): void {
if (registryInstance) {
registryInstance.clear();
registryInstance.removeAllListeners();
}
registryInstance = null;
}
@@ -26,6 +26,7 @@ export const DEFAULT_AUTO_SWITCH_SETTINGS: ClaudeAutoSwitchSettings = {
sessionThreshold: 95, // Consider switching at 95% session usage
weeklyThreshold: 99, // Consider switching at 99% weekly usage
autoSwitchOnRateLimit: false, // Prompt user by default
autoSwitchOnAuthFailure: false, // Prompt user by default on auth failures
usageCheckInterval: 30000 // Check every 30s when enabled (0 = disabled)
};
@@ -207,7 +207,7 @@ export function hasValidToken(profile: ClaudeProfile): boolean {
* Expand ~ in path to home directory
*/
export function expandHomePath(path: string): string {
if (path && path.startsWith('~')) {
if (path?.startsWith('~')) {
const home = homedir();
return path.replace(/^~/, home);
}
@@ -12,7 +12,6 @@ import {
refreshOAuthToken,
ensureValidToken,
reactiveTokenRefresh,
type TokenRefreshResult
} from './token-refresh';
// Mock credential-utils
@@ -19,6 +19,7 @@ import { detectProvider as sharedDetectProvider, type ApiProvider } from '../../
import { getCredentialsFromKeychain, clearKeychainCache } from './credential-utils';
import { reactiveTokenRefresh, ensureValidToken } from './token-refresh';
import { isProfileRateLimited } from './rate-limit-manager';
import { getOperationRegistry } from './operation-registry';
// Re-export for backward compatibility
export type { ApiProvider };
@@ -775,7 +776,7 @@ export class UsageMonitor extends EventEmitter {
const activeProfile = profilesFile.profiles.find(
(p) => p.id === profilesFile.activeProfileId
);
if (activeProfile && activeProfile.apiKey) {
if (activeProfile?.apiKey) {
this.debugLog('[UsageMonitor:TRACE] Using API profile credential: ' + activeProfile.name);
return activeProfile.apiKey;
}
@@ -1333,7 +1334,7 @@ export class UsageMonitor extends EventEmitter {
let baseUrl: string;
let provider: ApiProvider;
if (activeProfile && activeProfile.isAPIProfile) {
if (activeProfile?.isAPIProfile) {
// Use the pre-determined profile to avoid race conditions
// Trust the activeProfile data and use baseUrl directly
baseUrl = activeProfile.baseUrl;
@@ -1347,7 +1348,7 @@ export class UsageMonitor extends EventEmitter {
const profilesFile = await loadProfilesFile();
apiProfile = profilesFile.profiles.find(p => p.id === profileId);
if (apiProfile && apiProfile.apiKey) {
if (apiProfile?.apiKey) {
// API profile found
baseUrl = apiProfile.baseUrl;
provider = detectProvider(baseUrl);
@@ -2007,6 +2008,46 @@ export class UsageMonitor extends EventEmitter {
limitType
});
// PROACTIVE OPERATION RESTART: Stop and restart all running Claude SDK operations with new profile credentials
// This includes autonomous tasks, PR reviews, insights, roadmap, etc.
// Claude Agent SDK sessions maintain state independently of auth tokens, so no progress is lost
const operationRegistry = getOperationRegistry();
const operationSummary = operationRegistry.getSummary();
const operationIdsOnOldProfile = operationSummary.byProfile[currentProfileId] || [];
// Always log running operations info for debugging
console.log('[UsageMonitor] PROACTIVE-SWAP: Checking running operations:', {
oldProfileId: currentProfileId,
newProfileId: bestAccount.id,
totalRunning: operationSummary.totalRunning,
byProfile: operationSummary.byProfile,
byType: operationSummary.byType,
operationIdsOnOldProfile: operationIdsOnOldProfile
});
if (operationIdsOnOldProfile.length > 0) {
console.log('[UsageMonitor] PROACTIVE-SWAP: Found', operationIdsOnOldProfile.length, 'operations to restart:', operationIdsOnOldProfile);
// Restart all operations on the old profile with the new profile
const restartedCount = await operationRegistry.restartOperationsOnProfile(
currentProfileId,
bestAccount.id,
bestAccount.name
);
// Emit event for tracking/logging
this.emit('proactive-operations-restarted', {
fromProfile: { id: currentProfileId, name: fromProfileName },
toProfile: { id: bestAccount.id, name: bestAccount.name },
operationIds: operationIdsOnOldProfile,
restartedCount,
limitType,
timestamp: new Date()
});
} else {
console.log('[UsageMonitor] PROACTIVE-SWAP: No operations running on old profile', currentProfileId, '- swap complete without restart');
}
// Note: Don't immediately check new profile - let normal interval handle it
// This prevents cascading swaps if multiple profiles are near limits
}
+1 -1
View File
@@ -95,7 +95,7 @@ function getNpmGlobalPrefix(): string | null {
const normalizedPath = path.normalize(binPath);
return fs.existsSync(normalizedPath) ? normalizedPath : null;
} catch (error) {
} catch (_error) {
// Fallback for Windows: try default npm global location when npm.cmd is not in PATH
// This happens when the packaged app launches from GUI without full shell environment
if (isWindows()) {
+2 -3
View File
@@ -157,8 +157,7 @@ function createWindow(): void {
const display = screen.getPrimaryDisplay();
// Validate the returned object has expected structure with valid dimensions
if (
display &&
display.workAreaSize &&
display?.workAreaSize &&
typeof display.workAreaSize.width === 'number' &&
typeof display.workAreaSize.height === 'number' &&
display.workAreaSize.width > 0 &&
@@ -495,7 +494,7 @@ app.whenReady().then(() => {
// Setup event forwarding from usage monitor to renderer
initializeUsageMonitorForwarding(mainWindow);
// Start the usage monitor
// Start the usage monitor (uses unified OperationRegistry for proactive restart)
const usageMonitor = getUsageMonitor();
usageMonitor.start();
console.warn('[main] Usage monitor initialized and started (after profile load)');
@@ -9,11 +9,10 @@ import { InsightsPaths } from './paths';
export class SessionManager {
private sessions: Map<string, InsightsSession> = new Map();
private storage: SessionStorage;
private paths: InsightsPaths;
constructor(storage: SessionStorage, paths: InsightsPaths) {
constructor(storage: SessionStorage, _paths: InsightsPaths) {
this.storage = storage;
this.paths = paths;
// Note: paths parameter kept for API compatibility but not currently used
}
/**
@@ -0,0 +1,113 @@
/**
* Tests for the XState settled-state guard logic used in agent-events-handlers.
*
* The guard prevents execution-progress events from overwriting XState's
* persisted status when the state machine has already settled into a
* terminal/review state.
*/
import { describe, it, expect } from 'vitest';
import { XSTATE_SETTLED_STATES, XSTATE_TO_PHASE, TASK_STATE_NAMES } from '../../../shared/state-machines';
describe('XSTATE_SETTLED_STATES', () => {
it('should contain the expected settled states', () => {
expect(XSTATE_SETTLED_STATES.has('plan_review')).toBe(true);
expect(XSTATE_SETTLED_STATES.has('human_review')).toBe(true);
expect(XSTATE_SETTLED_STATES.has('error')).toBe(true);
expect(XSTATE_SETTLED_STATES.has('creating_pr')).toBe(true);
expect(XSTATE_SETTLED_STATES.has('pr_created')).toBe(true);
expect(XSTATE_SETTLED_STATES.has('done')).toBe(true);
});
it('should NOT contain active processing states', () => {
expect(XSTATE_SETTLED_STATES.has('backlog')).toBe(false);
expect(XSTATE_SETTLED_STATES.has('planning')).toBe(false);
expect(XSTATE_SETTLED_STATES.has('coding')).toBe(false);
expect(XSTATE_SETTLED_STATES.has('qa_review')).toBe(false);
expect(XSTATE_SETTLED_STATES.has('qa_fixing')).toBe(false);
});
it('should only contain valid task state names', () => {
const validNames = new Set(TASK_STATE_NAMES);
for (const state of XSTATE_SETTLED_STATES) {
expect(validNames.has(state as typeof TASK_STATE_NAMES[number])).toBe(true);
}
});
});
describe('settled state guard behavior', () => {
/**
* Simulates the guard logic from agent-events-handlers execution-progress handler.
* Returns true if the event should be blocked (XState is in a settled state).
*/
function shouldBlockExecutionProgress(currentXState: string | undefined): boolean {
return !!(currentXState && XSTATE_SETTLED_STATES.has(currentXState));
}
it('should block execution-progress when XState is in plan_review', () => {
// After PLANNING_COMPLETE with requireReviewBeforeCoding=true,
// process exits with code 1 emitting phase='failed' — must be blocked
expect(shouldBlockExecutionProgress('plan_review')).toBe(true);
});
it('should block execution-progress when XState is in human_review', () => {
// After QA_PASSED, any stale events from the dying process must be blocked
expect(shouldBlockExecutionProgress('human_review')).toBe(true);
});
it('should block execution-progress when XState is in error', () => {
// After PLANNING_FAILED/CODING_FAILED, stale events must not overwrite error status
expect(shouldBlockExecutionProgress('error')).toBe(true);
});
it('should block execution-progress when XState is in done', () => {
expect(shouldBlockExecutionProgress('done')).toBe(true);
});
it('should allow execution-progress when XState is in planning', () => {
expect(shouldBlockExecutionProgress('planning')).toBe(false);
});
it('should allow execution-progress when XState is in coding', () => {
// After USER_RESUMED from error, XState transitions to coding synchronously.
// New agent events should flow through normally.
expect(shouldBlockExecutionProgress('coding')).toBe(false);
});
it('should allow execution-progress when XState is in qa_review', () => {
expect(shouldBlockExecutionProgress('qa_review')).toBe(false);
});
it('should allow execution-progress when no XState actor exists', () => {
// No actor yet (first event for this task) — must not block
expect(shouldBlockExecutionProgress(undefined)).toBe(false);
});
});
describe('XSTATE_TO_PHASE', () => {
it('should have a mapping for every task state', () => {
for (const state of TASK_STATE_NAMES) {
expect(XSTATE_TO_PHASE[state]).toBeDefined();
}
});
it('should map settled states to non-active phases', () => {
// Settled states should map to phases that indicate completion or stoppage
expect(XSTATE_TO_PHASE['plan_review']).toBe('planning');
expect(XSTATE_TO_PHASE['human_review']).toBe('complete');
expect(XSTATE_TO_PHASE['error']).toBe('failed');
expect(XSTATE_TO_PHASE['done']).toBe('complete');
expect(XSTATE_TO_PHASE['pr_created']).toBe('complete');
expect(XSTATE_TO_PHASE['creating_pr']).toBe('complete');
});
it('should map active states to processing phases', () => {
expect(XSTATE_TO_PHASE['planning']).toBe('planning');
expect(XSTATE_TO_PHASE['coding']).toBe('coding');
expect(XSTATE_TO_PHASE['qa_review']).toBe('qa_review');
expect(XSTATE_TO_PHASE['qa_fixing']).toBe('qa_fixing');
});
it('should return undefined for unknown states', () => {
expect(XSTATE_TO_PHASE['nonexistent']).toBeUndefined();
});
});
@@ -7,12 +7,13 @@ import type {
AuthFailureInfo,
ImplementationPlan,
} from "../../shared/types";
import { XSTATE_SETTLED_STATES, XSTATE_TO_PHASE, mapStateToLegacy } from "../../shared/state-machines";
import { AgentManager } from "../agent";
import type { ProcessType, ExecutionProgressData } from "../agent";
import { titleGenerator } from "../title-generator";
import { fileWatcher } from "../file-watcher";
import { notificationService } from "../notification-service";
import { persistPlanLastEventSync, getPlanPath, persistPlanPhaseSync } from "./task/plan-file-utils";
import { persistPlanLastEventSync, getPlanPath, persistPlanPhaseSync, persistPlanStatusAndReasonSync } from "./task/plan-file-utils";
import { findTaskWorktree } from "../worktree-paths";
import { findTaskAndProject } from "./task/shared";
import { safeSendToRenderer } from "./utils";
@@ -32,16 +33,22 @@ export function registerAgenteventsHandlers(
// Agent Manager Events → Renderer
// ============================================
agentManager.on("log", (taskId: string, log: string) => {
// Include projectId for multi-project filtering (issue #723)
const { project } = findTaskAndProject(taskId);
safeSendToRenderer(getMainWindow, IPC_CHANNELS.TASK_LOG, taskId, log, project?.id);
agentManager.on("log", (taskId: string, log: string, projectId?: string) => {
// Use projectId from event when available; fall back to lookup for backward compatibility
if (!projectId) {
const { project } = findTaskAndProject(taskId);
projectId = project?.id;
}
safeSendToRenderer(getMainWindow, IPC_CHANNELS.TASK_LOG, taskId, log, projectId);
});
agentManager.on("error", (taskId: string, error: string) => {
// Include projectId for multi-project filtering (issue #723)
const { project } = findTaskAndProject(taskId);
safeSendToRenderer(getMainWindow, IPC_CHANNELS.TASK_ERROR, taskId, error, project?.id);
agentManager.on("error", (taskId: string, error: string, projectId?: string) => {
// Use projectId from event when available; fall back to lookup for backward compatibility
if (!projectId) {
const { project } = findTaskAndProject(taskId);
projectId = project?.id;
}
safeSendToRenderer(getMainWindow, IPC_CHANNELS.TASK_ERROR, taskId, error, projectId);
});
// Handle SDK rate limit events from agent manager
@@ -82,10 +89,10 @@ export function registerAgenteventsHandlers(
safeSendToRenderer(getMainWindow, IPC_CHANNELS.CLAUDE_AUTH_FAILURE, authFailureInfo);
});
agentManager.on("exit", (taskId: string, code: number | null, processType: ProcessType) => {
// Get task + project for context and multi-project filtering (issue #723)
const { task: exitTask, project: exitProject } = findTaskAndProject(taskId);
const exitProjectId = exitProject?.id;
agentManager.on("exit", (taskId: string, code: number | null, processType: ProcessType, projectId?: string) => {
// Use projectId from event to scope the lookup (prevents cross-project contamination)
const { task: exitTask, project: exitProject } = findTaskAndProject(taskId, projectId);
const exitProjectId = exitProject?.id || projectId;
taskStateManager.handleProcessExited(taskId, code, exitTask, exitProject);
@@ -109,7 +116,7 @@ export function registerAgenteventsHandlers(
return;
}
const { task, project } = findTaskAndProject(taskId);
const { task, project } = findTaskAndProject(taskId, projectId);
if (!task || !project) return;
const taskTitle = task.title || task.specId;
@@ -120,11 +127,11 @@ export function registerAgenteventsHandlers(
}
});
agentManager.on("task-event", (taskId: string, event) => {
console.log(`[agent-events-handlers] Received task-event for ${taskId}:`, event.type, event);
agentManager.on("task-event", (taskId: string, event, projectId?: string) => {
console.debug(`[agent-events-handlers] Received task-event for ${taskId}:`, event.type, event);
if (taskStateManager.getLastSequence(taskId) === undefined) {
const { task, project } = findTaskAndProject(taskId);
const { task, project } = findTaskAndProject(taskId, projectId);
if (task && project) {
try {
const planPath = getPlanPath(project, task);
@@ -140,20 +147,20 @@ export function registerAgenteventsHandlers(
}
}
const { task, project } = findTaskAndProject(taskId);
const { task, project } = findTaskAndProject(taskId, projectId);
if (!task || !project) {
console.log(`[agent-events-handlers] No task/project found for ${taskId}`);
console.debug(`[agent-events-handlers] No task/project found for ${taskId}`);
return;
}
console.log(`[agent-events-handlers] Task state before handleTaskEvent:`, {
console.debug(`[agent-events-handlers] Task state before handleTaskEvent:`, {
status: task.status,
reviewReason: task.reviewReason,
phase: task.executionProgress?.phase
});
const accepted = taskStateManager.handleTaskEvent(taskId, event, task, project);
console.log(`[agent-events-handlers] Event ${event.type} accepted: ${accepted}`);
console.debug(`[agent-events-handlers] Event ${event.type} accepted: ${accepted}`);
if (!accepted) {
return;
}
@@ -176,14 +183,27 @@ export function registerAgenteventsHandlers(
}
});
agentManager.on("execution-progress", (taskId: string, progress: ExecutionProgressData) => {
// Use shared helper to find task and project (issue #723 - deduplicate lookup)
const { task, project } = findTaskAndProject(taskId);
const taskProjectId = project?.id;
agentManager.on("execution-progress", (taskId: string, progress: ExecutionProgressData, projectId?: string) => {
// Use projectId from event to scope the lookup (prevents cross-project contamination)
const { task, project } = findTaskAndProject(taskId, projectId);
const taskProjectId = project?.id || projectId;
// Check if XState has already established a terminal/review state for this task.
// XState is the source of truth for status. When XState is in a terminal state
// (e.g., plan_review after PLANNING_COMPLETE), execution-progress events from the
// agent process are stale and must not overwrite XState's persisted status.
//
// Example: When requireReviewBeforeCoding=true, the process exits with code 1 after
// PLANNING_COMPLETE. The exit handler emits execution-progress with phase='failed',
// which would incorrectly overwrite status='human_review' with status='error' via
// persistPlanPhaseSync, and send a 'failed' phase to the renderer overwriting the
// 'planning' phase that XState already emitted via emitPhaseFromState.
const currentXState = taskStateManager.getCurrentState(taskId);
const xstateInTerminalState = currentXState && XSTATE_SETTLED_STATES.has(currentXState);
// Persist phase to plan file for restoration on app refresh
// Must persist to BOTH main project and worktree (if exists) since task may be loaded from either
if (task && project && progress.phase) {
if (task && project && progress.phase && !xstateInTerminalState) {
const mainPlanPath = getPlanPath(project, task);
persistPlanPhaseSync(mainPlanPath, progress.phase, project.id);
@@ -201,9 +221,16 @@ export function registerAgenteventsHandlers(
persistPlanPhaseSync(worktreePlanPath, progress.phase, project.id);
}
}
} else if (xstateInTerminalState && progress.phase) {
console.debug(`[agent-events-handlers] Skipping persistPlanPhaseSync for ${taskId}: XState in '${currentXState}', not overwriting with phase '${progress.phase}'`);
}
// Include projectId in execution progress event for multi-project filtering
// Skip sending execution-progress to renderer when XState has settled.
// XState's emitPhaseFromState already sent the correct phase to the renderer.
if (xstateInTerminalState) {
console.debug(`[agent-events-handlers] Skipping execution-progress to renderer for ${taskId}: XState in '${currentXState}', ignoring phase '${progress.phase}'`);
return;
}
safeSendToRenderer(
getMainWindow,
IPC_CHANNELS.TASK_EXECUTION_PROGRESS,
@@ -218,13 +245,42 @@ export function registerAgenteventsHandlers(
// ============================================
fileWatcher.on("progress", (taskId: string, plan: ImplementationPlan) => {
// Use shared helper to find project (issue #723 - deduplicate lookup)
const { project } = findTaskAndProject(taskId);
// File watcher events don't carry projectId — fall back to lookup
const { task, project } = findTaskAndProject(taskId);
safeSendToRenderer(getMainWindow, IPC_CHANNELS.TASK_PROGRESS, taskId, plan, project?.id);
// Re-stamp XState status fields if the backend overwrote the plan file without them.
// The planner agent writes implementation_plan.json via the Write tool, which replaces
// the entire file and strips the frontend's status/xstateState/executionPhase fields.
// This causes tasks to snap back to backlog on refresh.
const planWithStatus = plan as { xstateState?: string; executionPhase?: string; status?: string };
const currentXState = taskStateManager.getCurrentState(taskId);
if (currentXState && !planWithStatus.xstateState && task && project) {
console.debug(`[agent-events-handlers] Re-stamping XState status on plan file for ${taskId} (state: ${currentXState})`);
const mainPlanPath = getPlanPath(project, task);
const { status, reviewReason } = mapStateToLegacy(currentXState);
const phase = XSTATE_TO_PHASE[currentXState] || 'idle';
persistPlanStatusAndReasonSync(mainPlanPath, status, reviewReason, project.id, currentXState, phase);
// Also re-stamp worktree copy if it exists
const worktreePath = findTaskWorktree(project.path, task.specId);
if (worktreePath) {
const specsBaseDir = getSpecsDir(project.autoBuildPath);
const worktreePlanPath = path.join(
worktreePath,
specsBaseDir,
task.specId,
AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN
);
if (existsSync(worktreePlanPath)) {
persistPlanStatusAndReasonSync(worktreePlanPath, status, reviewReason, project.id, currentXState, phase);
}
}
}
});
fileWatcher.on("error", (taskId: string, error: string) => {
// Include projectId for multi-project filtering (issue #723)
// File watcher events don't carry projectId — fall back to lookup
const { project } = findTaskAndProject(taskId);
safeSendToRenderer(getMainWindow, IPC_CHANNELS.TASK_ERROR, taskId, error, project?.id);
});
@@ -95,6 +95,10 @@ export function registerAppUpdateHandlers(): void {
IPC_CHANNELS.APP_UPDATE_INSTALL,
async (): Promise<IPCResult> => {
try {
// quitAndInstall() returns false if blocked by read-only volume,
// but the user is notified via APP_UPDATE_READONLY_VOLUME event instead.
// The preload fires this as fire-and-forget, so the return value is
// only consumed by the .catch() handler for unexpected errors.
quitAndInstall();
return { success: true };
} catch (error) {
@@ -52,7 +52,7 @@ export function registerChangelogHandlers(
}
});
changelogService.on('rate-limit', (projectId: string, rateLimitInfo: import('../../shared/types').SDKRateLimitInfo) => {
changelogService.on('rate-limit', (_projectId: string, rateLimitInfo: import('../../shared/types').SDKRateLimitInfo) => {
const mainWindow = getMainWindow();
if (mainWindow) {
mainWindow.webContents.send(IPC_CHANNELS.CLAUDE_SDK_RATE_LIMIT, rateLimitInfo);
@@ -23,7 +23,7 @@ import { isSecurePath } from '../utils/windows-paths';
import { isWindows, isMacOS, isLinux } from '../platform';
import { getClaudeProfileManager } from '../claude-profile-manager';
import { isValidConfigDir } from '../utils/config-path-validator';
import { clearKeychainCache, getCredentialsFromKeychain } from '../claude-profile/credential-utils';
import { clearKeychainCache, getCredentialsFromKeychain, updateProfileSubscriptionMetadata } from '../claude-profile/credential-utils';
import { getUsageMonitor } from '../claude-profile/usage-monitor';
import semver from 'semver';
@@ -216,10 +216,12 @@ async function scanClaudeInstallations(activePath: string | null): Promise<Claud
* @param currentInstalled - Optional currently installed version. If provided and newer than
* cached latest, cache will be invalidated and fresh data fetched.
* This handles the case where CLI was updated while app was running.
* @param forceRefresh - If true, bypasses the cache and fetches fresh data from npm.
* Use this when the user explicitly clicks a "Refresh" button.
*/
async function fetchLatestVersion(currentInstalled?: string | null): Promise<string> {
// Check cache first
if (cachedLatestVersion && Date.now() - cachedLatestVersion.timestamp < CACHE_DURATION_MS) {
async function fetchLatestVersion(currentInstalled?: string | null, forceRefresh?: boolean): Promise<string> {
// Check cache first (unless force refresh is requested)
if (!forceRefresh && cachedLatestVersion && Date.now() - cachedLatestVersion.timestamp < CACHE_DURATION_MS) {
const cachedVersion = cachedLatestVersion.version;
// Invalidate cache if installed version is newer than cached latest
@@ -881,7 +883,7 @@ function checkProfileAuthentication(configDir: string): AuthCheckResult {
const data = JSON.parse(content);
// Check for oauthAccount with emailAddress
if (data.oauthAccount && data.oauthAccount.emailAddress) {
if (data.oauthAccount?.emailAddress) {
return {
authenticated: true,
email: data.oauthAccount.emailAddress,
@@ -907,7 +909,7 @@ function checkProfileAuthentication(configDir: string): AuthCheckResult {
};
}
if (data.oauthAccount && data.oauthAccount.emailAddress) {
if (data.oauthAccount?.emailAddress) {
return {
authenticated: true,
email: data.oauthAccount.emailAddress,
@@ -958,9 +960,9 @@ export function registerClaudeCodeHandlers(): void {
// Check Claude Code version
ipcMain.handle(
IPC_CHANNELS.CLAUDE_CODE_CHECK_VERSION,
async (): Promise<IPCResult<ClaudeCodeVersionInfo>> => {
async (_event, forceRefresh?: boolean): Promise<IPCResult<ClaudeCodeVersionInfo>> => {
try {
console.warn('[Claude Code] Checking version...');
console.warn('[Claude Code] Checking version...', forceRefresh ? '(force refresh)' : '');
// Get installed version via cli-tool-manager
let detectionResult;
@@ -977,10 +979,11 @@ export function registerClaudeCodeHandlers(): void {
// Fetch latest version from npm
// Pass installed version to invalidate cache if installed > cached (handles CLI update while app running)
// Pass forceRefresh to bypass cache when user explicitly clicks Refresh
let latest: string;
try {
console.warn('[Claude Code] Fetching latest version from npm...');
latest = await fetchLatestVersion(installed);
latest = await fetchLatestVersion(installed, forceRefresh);
console.warn('[Claude Code] Latest version:', latest);
} catch (error) {
console.warn('[Claude Code] Failed to fetch latest version, continuing with unknown:', error);
@@ -1370,7 +1373,7 @@ export function registerClaudeCodeHandlers(): void {
}
}
// If authenticated, update the profile with the email
// If authenticated, update the profile with metadata from credentials
// NOTE: We intentionally do NOT store the OAuth token in the profile.
// Storing the token causes AutoClaude to use a stale cached token instead of
// letting Claude CLI read fresh tokens from Keychain (which auto-refreshes).
@@ -1384,7 +1387,11 @@ export function registerClaudeCodeHandlers(): void {
profile.email = result.email;
}
// Save profile metadata (email, isAuthenticated) but NOT the OAuth token
// Update subscription metadata from Keychain credentials
// These are needed to display "Max" vs "Pro" in the UI
updateProfileSubscriptionMetadata(profile, expandedConfigDir);
// Save profile metadata (email, isAuthenticated, subscriptionType, rateLimitTier) but NOT the OAuth token
profileManager.saveProfile(profile);
// CRITICAL: Clear keychain cache for this profile's configDir
@@ -12,9 +12,6 @@ import type {
} from '../../../shared/types';
import { projectStore } from '../../project-store';
import { getMemoryService, isKuzuAvailable } from '../../memory-service';
import {
getGraphitiDatabaseDetails
} from './utils';
import { getEffectiveSourcePath } from '../../updater/path-resolver';
import {
loadGraphitiStateFromSpecs,
@@ -32,7 +32,7 @@ type ResolvedClaudeCliInvocation =
| { command: string; env: Record<string, string> }
| { error: string };
function resolveClaudeCliInvocation(): ResolvedClaudeCliInvocation {
function _resolveClaudeCliInvocation(): ResolvedClaudeCliInvocation {
try {
const invocation = getClaudeCliInvocation();
if (!invocation?.command) {
@@ -518,7 +518,7 @@ describe('GitHub OAuth Handlers', () => {
mockFindExecutable.mockReturnValue('/usr/local/bin/gh');
// Mock execFileSync for version check
mockExecFileSync.mockImplementation((cmd: string, args?: string[]) => {
mockExecFileSync.mockImplementation((_cmd: string, args?: string[]) => {
if (args && args[0] === '--version') {
return 'gh version 2.65.0 (2024-01-15)\n';
}
@@ -553,7 +553,7 @@ describe('GitHub OAuth Handlers', () => {
describe('gh Auth Check Handler', () => {
it('should return authenticated: true with username when logged in', async () => {
mockExecFileSync.mockImplementation((cmd: string, args?: string[]) => {
mockExecFileSync.mockImplementation((_cmd: string, args?: string[]) => {
if (args && args[0] === 'auth' && args[1] === 'status') {
return 'Logged in to github.com as testuser\n';
}
@@ -61,7 +61,7 @@ function sendComplete(
* Investigate a GitHub issue and create a task
*/
export function registerInvestigateIssue(
agentManager: AgentManager,
_agentManager: AgentManager,
getMainWindow: () => BrowserWindow | null
): void {
ipcMain.on(
@@ -117,7 +117,7 @@ const GITHUB_DEVICE_URL = 'https://github.com/login/device';
*/
function parseDeviceCode(output: string): string | null {
const match = output.match(DEVICE_CODE_PATTERN);
if (match && match[1]) {
if (match?.[1]) {
// Normalize: replace space with hyphen (GitHub expects XXXX-XXXX format)
const normalizedCode = match[1].replace(' ', '-');
debugLog('Device code extracted successfully (code redacted for security)');
@@ -40,6 +40,23 @@ import {
* GraphQL response type for PR list query
* Note: repository can be null if the repo doesn't exist or user lacks access
*/
interface GraphQLPRNode {
number: number;
title: string;
body: string | null;
state: string;
author: { login: string } | null;
headRefName: string;
baseRefName: string;
additions: number;
deletions: number;
changedFiles: number;
assignees: { nodes: Array<{ login: string }> };
createdAt: string;
updatedAt: string;
url: string;
}
interface GraphQLPRListResponse {
data: {
repository: {
@@ -48,28 +65,37 @@ interface GraphQLPRListResponse {
hasNextPage: boolean;
endCursor: string | null;
};
nodes: Array<{
number: number;
title: string;
body: string | null;
state: string;
author: { login: string } | null;
headRefName: string;
baseRefName: string;
additions: number;
deletions: number;
changedFiles: number;
assignees: { nodes: Array<{ login: string }> };
createdAt: string;
updatedAt: string;
url: string;
}>;
nodes: GraphQLPRNode[];
};
} | null;
};
errors?: Array<{ message: string }>;
}
/**
* Maps a GraphQL PR node to the frontend PRData format.
* Shared between listPRs and listMorePRs handlers.
*/
function mapGraphQLPRToData(pr: GraphQLPRNode): PRData {
return {
number: pr.number,
title: pr.title,
body: pr.body ?? "",
state: pr.state.toLowerCase(),
author: { login: pr.author?.login ?? "unknown" },
headRefName: pr.headRefName,
baseRefName: pr.baseRefName,
additions: pr.additions,
deletions: pr.deletions,
changedFiles: pr.changedFiles,
assignees: pr.assignees.nodes.map((a) => ({ login: a.login })),
files: [],
createdAt: pr.createdAt,
updatedAt: pr.updatedAt,
htmlUrl: pr.url,
};
}
/**
* Make a GraphQL request to GitHub API
*/
@@ -487,6 +513,7 @@ export interface PRData {
export interface PRListResult {
prs: PRData[];
hasNextPage: boolean; // True if more PRs exist beyond the 100 limit
endCursor?: string | null; // Cursor for fetching next page (null if no more pages)
}
/**
@@ -870,6 +897,16 @@ function parseLogLine(line: string): { source: string; content: string; isError:
};
}
// Check for parallel SDK specialist logs (Specialist:name format)
const specialistMatch = line.match(/^\[Specialist:([\w-]+)\]\s*(.*)$/);
if (specialistMatch) {
return {
source: `Specialist:${specialistMatch[1]}`,
content: specialistMatch[2],
isError: false,
};
}
for (const pattern of patterns) {
const match = line.match(pattern);
if (match) {
@@ -970,8 +1007,9 @@ function getPhaseFromSource(source: string): PRLogPhase {
if (contextSources.includes(source)) return "context";
if (analysisSources.includes(source)) return "analysis";
// Specialist agents (Agent:xxx) are part of analysis phase
// Specialist agents (Agent:xxx and Specialist:xxx) are part of analysis phase
if (source.startsWith("Agent:")) return "analysis";
if (source.startsWith("Specialist:")) return "analysis";
if (synthesisSources.includes(source)) return "synthesis";
return "synthesis"; // Default to synthesis for unknown sources
}
@@ -1269,6 +1307,9 @@ async function runPRReview(
// Build environment with project settings
const subprocessEnv = await getRunnerEnv(getClaudeMdEnv(project));
// Create operation ID for this review
const reviewKey = getReviewKey(project.id, prNumber);
const { process: childProcess, promise } = runPythonSubprocess<PRReviewResult>({
pythonPath: getPythonPath(backendPath),
args,
@@ -1303,10 +1344,17 @@ async function runPRReview(
debugLog("Review result loaded", { findingsCount: reviewResult.findings.length });
return reviewResult;
},
// Register with OperationRegistry for proactive swap support
operationRegistration: {
operationId: `pr-review:${reviewKey}`,
operationType: 'pr-review',
metadata: { projectId: project.id, prNumber, repo },
// PR reviews don't support restart (would need to refetch PR data)
// The review will complete or fail, and user can retry manually
},
});
// Register the running process
const reviewKey = getReviewKey(project.id, prNumber);
// Register the running process (keep legacy registry for cancel support)
runningReviews.set(reviewKey, childProcess);
debugLog("Registered review process", { reviewKey, pid: childProcess.pid });
@@ -1336,13 +1384,75 @@ async function runPRReview(
}
}
/**
* Shared helper to fetch PRs via GraphQL API.
* Used by both listPRs and listMorePRs handlers to avoid code duplication.
*/
async function fetchPRsFromGraphQL(
config: { token: string; repo: string },
cursor: string | null,
debugContext: string
): Promise<PRListResult> {
// Parse owner/repo from config - must be exactly "owner/repo" format
const normalizedRepo = normalizeRepoReference(config.repo);
const repoParts = normalizedRepo.split("/");
if (repoParts.length !== 2 || !repoParts[0] || !repoParts[1]) {
debugLog("Invalid repo format - expected 'owner/repo'", {
repo: config.repo,
normalized: normalizedRepo,
context: debugContext,
});
return { prs: [], hasNextPage: false, endCursor: null };
}
const [owner, repo] = repoParts;
try {
// Use GraphQL API to get PRs with diff stats (REST list endpoint doesn't include them)
// Fetches up to 100 open PRs (GitHub GraphQL max per request)
const response = await githubGraphQL<GraphQLPRListResponse>(
config.token,
LIST_PRS_QUERY,
{
owner,
repo,
first: 100, // GitHub GraphQL max is 100
after: cursor,
}
);
// Handle case where repository doesn't exist or user lacks access
if (!response.data.repository) {
debugLog("Repository not found or access denied", { owner, repo, context: debugContext });
return { prs: [], hasNextPage: false, endCursor: null };
}
const { nodes: prNodes, pageInfo } = response.data.repository.pullRequests;
debugLog(`Fetched PRs via GraphQL (${debugContext})`, {
count: prNodes.length,
hasNextPage: pageInfo.hasNextPage,
endCursor: pageInfo.endCursor,
});
return {
prs: prNodes.map(mapGraphQLPRToData),
hasNextPage: pageInfo.hasNextPage,
endCursor: pageInfo.endCursor,
};
} catch (error) {
debugLog(`Failed to fetch PRs (${debugContext})`, {
error: error instanceof Error ? error.message : error,
});
return { prs: [], hasNextPage: false, endCursor: null };
}
}
/**
* Register PR-related handlers
*/
export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): void {
debugLog("Registering PR handlers");
// List open PRs - fetches up to 100 open PRs at once, returns hasNextPage from API
// List open PRs - fetches up to 100 open PRs at once, returns hasNextPage and endCursor from API
ipcMain.handle(
IPC_CHANNELS.GITHUB_PR_LIST,
async (_, projectId: string): Promise<PRListResult> => {
@@ -1351,69 +1461,28 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
const config = getGitHubConfig(project);
if (!config) {
debugLog("No GitHub config found for project");
return { prs: [], hasNextPage: false };
}
try {
// Parse owner/repo from config - must be exactly "owner/repo" format
const normalizedRepo = normalizeRepoReference(config.repo);
const repoParts = normalizedRepo.split("/");
if (repoParts.length !== 2 || !repoParts[0] || !repoParts[1]) {
debugLog("Invalid repo format - expected 'owner/repo'", { repo: config.repo, normalized: normalizedRepo });
return { prs: [], hasNextPage: false };
}
const [owner, repo] = repoParts;
// Use GraphQL API to get PRs with diff stats (REST list endpoint doesn't include them)
// Fetches up to 100 open PRs (GitHub GraphQL max per request)
const response = await githubGraphQL<GraphQLPRListResponse>(
config.token,
LIST_PRS_QUERY,
{
owner,
repo,
first: 100, // GitHub GraphQL max is 100
after: null, // Start from beginning
}
);
// Handle case where repository doesn't exist or user lacks access
if (!response.data.repository) {
debugLog("Repository not found or access denied", { owner, repo });
return { prs: [], hasNextPage: false };
}
const { nodes: prNodes, pageInfo } = response.data.repository.pullRequests;
debugLog("Fetched PRs via GraphQL", { count: prNodes.length, hasNextPage: pageInfo.hasNextPage });
return {
prs: prNodes.map((pr) => ({
number: pr.number,
title: pr.title,
body: pr.body ?? "",
state: pr.state.toLowerCase(),
author: { login: pr.author?.login ?? "unknown" },
headRefName: pr.headRefName,
baseRefName: pr.baseRefName,
additions: pr.additions,
deletions: pr.deletions,
changedFiles: pr.changedFiles,
assignees: pr.assignees.nodes.map((a) => ({ login: a.login })),
files: [],
createdAt: pr.createdAt,
updatedAt: pr.updatedAt,
htmlUrl: pr.url,
})),
hasNextPage: pageInfo.hasNextPage,
};
} catch (error) {
debugLog("Failed to fetch PRs", {
error: error instanceof Error ? error.message : error,
});
return { prs: [], hasNextPage: false };
return { prs: [], hasNextPage: false, endCursor: null };
}
return fetchPRsFromGraphQL(config, null, "initial");
});
return result ?? { prs: [], hasNextPage: false };
return result ?? { prs: [], hasNextPage: false, endCursor: null };
}
);
// Load more PRs (pagination) - fetches next page of PRs using cursor
ipcMain.handle(
IPC_CHANNELS.GITHUB_PR_LIST_MORE,
async (_, projectId: string, cursor: string): Promise<PRListResult> => {
debugLog("listMorePRs handler called", { projectId, cursor });
const result = await withProjectOrNull(projectId, async (project) => {
const config = getGitHubConfig(project);
if (!config) {
debugLog("No GitHub config found for project");
return { prs: [], hasNextPage: false, endCursor: null };
}
return fetchPRsFromGraphQL(config, cursor, "pagination");
});
return result ?? { prs: [], hasNextPage: false, endCursor: null };
}
);
@@ -2689,6 +2758,12 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
});
return reviewResult;
},
// Register with OperationRegistry for proactive swap support
operationRegistration: {
operationId: `pr-followup-review:${reviewKey}`,
operationType: 'pr-review',
metadata: { projectId: project.id, prNumber, repo, isFollowup: true },
},
});
// Update registry with actual process (replacing placeholder)
@@ -19,8 +19,7 @@ import { getWhichCommand } from '../../platform';
*/
function checkGhCli(): { installed: boolean; error?: string } {
try {
const checkCmd = `${getWhichCommand()} gh`;
execSync(checkCmd, { encoding: 'utf-8', stdio: 'pipe' });
execFileSync(getWhichCommand(), ['gh'], { encoding: 'utf-8', stdio: 'pipe' });
return { installed: true };
} catch {
return {
@@ -20,6 +20,7 @@ import type { AuthFailureInfo, BillingFailureInfo } from '../../../../shared/typ
import { parsePythonCommand } from '../../../python-detector';
import { detectAuthFailure, detectBillingFailure } from '../../../rate-limit-detector';
import { getClaudeProfileManager } from '../../../claude-profile-manager';
import { getOperationRegistry, type OperationType } from '../../../claude-profile/operation-registry';
import { isWindows, isMacOS } from '../../../platform';
const execAsync = promisify(exec);
@@ -73,6 +74,23 @@ export interface SubprocessOptions {
progressPattern?: RegExp;
/** Additional environment variables to pass to the subprocess */
env?: Record<string, string>;
/**
* Operation registration for proactive swap support.
* If provided, the operation will be registered with the unified OperationRegistry.
*/
operationRegistration?: {
/** Unique operation ID */
operationId: string;
/** Operation type for categorization */
operationType: OperationType;
/** Optional metadata for the operation */
metadata?: Record<string, unknown>;
/**
* Function to restart the operation with a new profile.
* Should call the original function with refreshed environment.
*/
restartFn?: (newProfileId: string) => boolean | Promise<boolean>;
};
}
/**
@@ -126,6 +144,67 @@ export function runPythonSubprocess<T = unknown>(
detached: !isWindows(),
});
// Register with OperationRegistry for proactive swap support
if (options.operationRegistration) {
const { operationId, operationType, metadata, restartFn } = options.operationRegistration;
const profileManager = getClaudeProfileManager();
const activeProfile = profileManager.getActiveProfile();
if (activeProfile) {
const operationRegistry = getOperationRegistry();
// Create a stop function that kills the subprocess.
// Note: This sends SIGTERM and returns immediately without waiting for process exit.
//
// Timing dependency for restarts:
// - For subprocess-runner operations, restartFn returns false so no race condition
// (operations are non-resumable and won't be restarted, just stopped gracefully)
// - For AgentManager operations, there's a 500ms setTimeout delay in restartTask
// (see agent-manager.ts line 528) that mitigates the race between kill and restart
//
// RestartFn implementations should handle potential overlap between process termination
// and restart initialization if not using the setTimeout pattern.
const stopFn = async () => {
if (child.pid) {
try {
if (!isWindows()) {
process.kill(-child.pid, 'SIGTERM');
} else {
execFile('taskkill', ['/pid', String(child.pid), '/T', '/F'], () => {});
}
} catch {
child.kill('SIGTERM');
}
}
};
// Register with OperationRegistry for tracking and proactive swap support.
// For operations that provide a restartFn, UsageMonitor can restart them with a new profile.
// For operations without restartFn (e.g., PR reviews which are non-resumable due to one-shot workflow),
// we register with a no-op restartFn that returns false. This allows the swap to stop the operation
// gracefully without attempting restart. The operation will be killed when the profile swaps,
// which is the correct behavior for non-resumable operations.
operationRegistry.registerOperation(
operationId,
operationType,
activeProfile.id,
activeProfile.name,
restartFn || (() => false), // Use provided restartFn or a no-op for non-resumable operations
{
stopFn,
metadata: { ...metadata, pythonPath: options.pythonPath, cwd: options.cwd }
}
);
console.log('[SubprocessRunner] Operation registered with OperationRegistry:', {
operationId,
operationType,
profileId: activeProfile.id,
profileName: activeProfile.name
});
}
}
const promise = new Promise<SubprocessResult<T>>((resolve) => {
let stdout = '';
@@ -305,6 +384,11 @@ export function runPythonSubprocess<T = unknown>(
// Treat null exit code (killed with SIGKILL) as failure, not success
const exitCode = code ?? -1;
// Unregister from OperationRegistry when process exits
if (options.operationRegistration) {
getOperationRegistry().unregisterOperation(options.operationRegistration.operationId);
}
// Debug logging only in development mode
if (process.env.NODE_ENV === 'development') {
console.log('[DEBUG] Process exited with code:', exitCode, '(raw:', code, ')');
@@ -21,7 +21,6 @@ import type {
GitLabAutoFixQueueItem,
GitLabAutoFixProgress,
GitLabIssueBatch,
GitLabBatchProgress,
GitLabAnalyzePreviewResult,
} from './types';
@@ -71,7 +71,7 @@ function sendError(
* Register investigation handler
*/
export function registerInvestigateIssue(
agentManager: AgentManager,
_agentManager: AgentManager,
getMainWindow: () => BrowserWindow | null
): void {
ipcMain.on(
@@ -129,16 +129,11 @@ export function registerInvestigateIssue(
message: 'Analyzing issue with AI...'
});
// Build context for investigation
let context = buildIssueContext(issue, config.project, config.instanceUrl);
if (selectedNotes.length > 0) {
context += '\n\n## Selected Comments\n';
for (const note of selectedNotes) {
context += `\n### Comment by ${note.author.username} (${new Date(note.created_at).toLocaleDateString()})\n`;
context += note.body + '\n';
}
}
// Note: Context building previously done here has been moved to createSpecForIssue utility.
// The buildIssueContext() function and selectedNotes processing are now handled internally
// by the spec creation pipeline. This avoids duplicate context generation.
// TODO: If advanced context customization is needed in the future, consider extracting
// context building into a reusable utility function.
// Use agent manager to investigate
// Note: This is a simplified version - full implementation would use Claude SDK
@@ -21,7 +21,6 @@ import { getGitLabConfig, gitlabFetch, encodeProjectPath } from './utils';
import { readSettingsFile } from '../../settings-utils';
import type { Project, AppSettings } from '../../../shared/types';
import type {
MRReviewFinding,
MRReviewResult,
MRReviewProgress,
NewCommitsCheck,
@@ -248,13 +248,13 @@ export function registerStartGlabAuth(): void {
env: getAugmentedEnv()
});
let output = '';
let _output = '';
let errorOutput = '';
let browserOpened = false;
glabProcess.stdout?.on('data', (data) => {
const chunk = data.toString('utf-8');
output += chunk;
_output += chunk;
debugLog('glab stdout:', chunk);
// Try to open browser if URL detected
@@ -8,7 +8,7 @@ import path from 'path';
import type { Project } from '../../../shared/types';
import type { GitLabAPIIssue, GitLabConfig } from './types';
import { labelMatchesWholeWord } from '../shared/label-utils';
import { stripControlChars, sanitizeText, sanitizeStringArray } from '../shared/sanitize';
import { sanitizeText, sanitizeStringArray } from '../shared/sanitize';
/**
* Simplified task info returned when creating a spec from a GitLab issue.
@@ -201,6 +201,10 @@ function getOllamaInstallCommand(): string {
* Spawns a subprocess to run Ollama detection/management commands with a 10-second timeout.
* Used to check Ollama status, list models, and manage downloads.
*
* Includes deduplication: identical command+baseUrl requests within 2s return the cached
* result/promise instead of spawning a new subprocess. This prevents runaway subprocess
* spawning from React re-render loops.
*
* Supported commands:
* - 'check-status': Verify Ollama service is running
* - 'list-models': Get all available models
@@ -212,9 +216,43 @@ function getOllamaInstallCommand(): string {
* @param {string} [baseUrl] - Optional Ollama API base URL (defaults to http://localhost:11434)
* @returns {Promise<{success, data?, error?}>} Result object with success flag and data/error
*/
// Deduplication cache to prevent rapid-fire subprocess spawning (e.g., from React re-render loops)
const ollamaDetectorCache = new Map<string, { promise: Promise<{ success: boolean; data?: unknown; error?: string }>; timestamp: number }>();
const OLLAMA_CACHE_TTL_MS = 2000; // Cache results for 2 seconds
async function executeOllamaDetector(
command: string,
baseUrl?: string
): Promise<{ success: boolean; data?: unknown; error?: string }> {
// Deduplication: return cached promise for identical requests within TTL
const cacheKey = `${command}:${baseUrl || 'default'}`;
const cached = ollamaDetectorCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < OLLAMA_CACHE_TTL_MS) {
if (process.env.DEBUG) {
console.log('[OllamaDetector] Returning cached result for:', command);
}
return cached.promise;
}
const promise = executeOllamaDetectorImpl(command, baseUrl);
ollamaDetectorCache.set(cacheKey, { promise, timestamp: Date.now() });
// Clean up cache entry after TTL
promise.finally(() => {
setTimeout(() => {
const entry = ollamaDetectorCache.get(cacheKey);
if (entry && entry.promise === promise) {
ollamaDetectorCache.delete(cacheKey);
}
}, OLLAMA_CACHE_TTL_MS);
});
return promise;
}
async function executeOllamaDetectorImpl(
command: string,
baseUrl?: string
): Promise<{ success: boolean; data?: unknown; error?: string }> {
// Use configured Python path (venv if ready, otherwise bundled/system)
// Note: ollama_model_detector.py doesn't require dotenv, but using venv is safer
@@ -483,7 +521,7 @@ export function registerMemoryHandlers(): void {
};
} else {
// Basic validation for other providers
llmResult = config.apiKey && config.apiKey.trim()
llmResult = config.apiKey?.trim()
? {
success: true,
message: `${config.llmProvider} API key format appears valid`,
@@ -703,7 +741,7 @@ export function registerMemoryHandlers(): void {
async (
event,
modelName: string,
baseUrl?: string
_baseUrl?: string
): Promise<IPCResult<OllamaPullResult>> => {
try {
// Use configured Python path (venv if ready, otherwise bundled/system)
@@ -16,7 +16,6 @@ import type { IPCResult } from '../../shared/types';
import type { APIProfile, ProfileFormData, ProfilesFile, TestConnectionResult, DiscoverModelsResult } from '@shared/types/profile';
import {
loadProfilesFile,
saveProfilesFile,
validateFilePermissions,
getProfilesFilePath,
atomicModifyProfiles,
@@ -1,8 +1,7 @@
import { ipcMain, app } from 'electron';
import { existsSync, readFileSync } from 'fs';
import { existsSync, } from 'fs';
import path from 'path';
import { execFileSync } from 'child_process';
import { is } from '@electron-toolkit/utils';
import { IPC_CHANNELS } from '../../shared/constants';
import type {
Project,
@@ -10,7 +9,8 @@ import type {
IPCResult,
InitializationResult,
AutoBuildVersionInfo,
GitStatus
GitStatus,
GitBranchDetail
} from '../../shared/types';
import { projectStore } from '../project-store';
import {
@@ -89,6 +89,96 @@ function getGitBranches(projectPath: string): string[] {
}
}
/**
* Get structured branch information for a directory (both local and remote)
* Returns GitBranchDetail[] with type indicators, keeping both local and remote versions
* when a branch exists in both places (no deduplication)
*/
function getGitBranchesWithInfo(projectPath: string): GitBranchDetail[] {
try {
// First fetch to ensure we have latest remote refs
try {
execFileSync(getToolPath('git'), ['fetch', '--prune'], {
cwd: projectPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 10000 // 10 second timeout for fetch
});
} catch {
// Fetch may fail if offline or no remote, continue with local refs
}
// Get current branch for isCurrent indicator
let currentBranch: string | null = null;
try {
const currentResult = execFileSync(getToolPath('git'), ['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd: projectPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
});
currentBranch = currentResult.trim() || null;
} catch {
// Ignore - current branch detection may fail in some edge cases
}
// Get local branches
const localResult = execFileSync(getToolPath('git'), ['branch', '--format=%(refname:short)'], {
cwd: projectPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
});
const localBranches: GitBranchDetail[] = localResult.trim().split('\n')
.filter(b => b.trim())
.map(b => {
const name = b.trim();
return {
name,
type: 'local' as const,
displayName: name,
isCurrent: name === currentBranch
};
});
// Get remote branches
let remoteBranches: GitBranchDetail[] = [];
try {
const remoteResult = execFileSync(getToolPath('git'), ['branch', '-r', '--format=%(refname:short)'], {
cwd: projectPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
});
remoteBranches = remoteResult.trim().split('\n')
.filter(b => b.trim())
.map(b => b.trim())
// Remove HEAD pointer entries like "origin/HEAD"
.filter(b => !b.endsWith('/HEAD'))
.map(name => ({
name,
type: 'remote' as const,
displayName: name,
isCurrent: false
}));
} catch {
// Remote branches may not exist, continue with local only
}
// Combine and sort: local branches first, then remote branches, alphabetically within each group
const allBranches = [...localBranches, ...remoteBranches];
return allBranches.sort((a, b) => {
// Local branches come first
if (a.type === 'local' && b.type === 'remote') return -1;
if (a.type === 'remote' && b.type === 'local') return 1;
// Within same type, sort alphabetically
return a.name.localeCompare(b.name);
});
} catch {
return [];
}
}
/**
* Get the current git branch for a directory
*/
@@ -142,8 +232,6 @@ function detectMainBranch(projectPath: string): string | null {
return branches[0] || null;
}
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
/**
* Configure all Python-dependent services with the managed Python path
*/
@@ -449,7 +537,7 @@ export function registerProjectHandlers(
// Git Operations
// ============================================
// Get all branches for a project
// Get all branches for a project (legacy - returns string[])
ipcMain.handle(
IPC_CHANNELS.GIT_GET_BRANCHES,
async (_, projectPath: string): Promise<IPCResult<string[]>> => {
@@ -468,6 +556,25 @@ export function registerProjectHandlers(
}
);
// Get all branches with structured type information (local vs remote)
ipcMain.handle(
IPC_CHANNELS.GIT_GET_BRANCHES_WITH_INFO,
async (_, projectPath: string): Promise<IPCResult<GitBranchDetail[]>> => {
try {
if (!existsSync(projectPath)) {
return { success: false, error: 'Directory does not exist' };
}
const branches = getGitBranchesWithInfo(projectPath);
return { success: true, data: branches };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
);
// Get current branch for a project
ipcMain.handle(
IPC_CHANNELS.GIT_GET_CURRENT_BRANCH,
@@ -11,7 +11,6 @@ import {
import type {
IPCResult,
Roadmap,
RoadmapFeature,
RoadmapFeatureStatus,
RoadmapGenerationStatus,
PersistedRoadmapProgress,
@@ -511,7 +511,7 @@ export function registerSettingsHandlers(
error: `Path is not a directory: ${resolvedPath}`
};
}
} catch (statError) {
} catch (_statError) {
return {
success: false,
error: `Cannot access path: ${resolvedPath}`
@@ -0,0 +1,157 @@
/**
* Tests for findTaskAndProject cross-project scoping.
* Verifies that projectId prevents cross-project task contamination
* when multiple projects have tasks with the same specId.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { findTaskAndProject } from '../shared';
import type { Task, Project } from '../../../../shared/types';
// Mock projectStore
const mockProjects: Project[] = [];
const mockTasksByProject: Map<string, Task[]> = new Map();
vi.mock('../../../project-store', () => ({
projectStore: {
getProjects: () => mockProjects,
getTasks: (projectId: string) => mockTasksByProject.get(projectId) || []
}
}));
function createTask(overrides: Partial<Task> = {}): Task {
return {
id: `task-${Date.now()}-${Math.random().toString(36).substring(7)}`,
specId: 'test-spec',
projectId: 'project-1',
title: 'Test Task',
description: 'Test',
status: 'backlog',
subtasks: [],
logs: [],
createdAt: new Date(),
updatedAt: new Date(),
...overrides
};
}
function createProject(overrides: Partial<Project> = {}): Project {
return {
id: `project-${Date.now()}`,
name: 'Test Project',
path: '/test/project',
createdAt: new Date().toISOString(),
lastOpenedAt: new Date().toISOString(),
...overrides
} as Project;
}
describe('findTaskAndProject', () => {
beforeEach(() => {
mockProjects.length = 0;
mockTasksByProject.clear();
});
it('should find task by specId without projectId (backward compatibility)', () => {
const project = createProject({ id: 'proj-1' });
const task = createTask({ id: 'task-1', specId: 'write-to-file', projectId: 'proj-1' });
mockProjects.push(project);
mockTasksByProject.set('proj-1', [task]);
const result = findTaskAndProject('write-to-file');
expect(result.task).toBe(task);
expect(result.project).toBe(project);
});
it('should scope search to specified project when projectId is provided', () => {
const projectA = createProject({ id: 'proj-a', name: 'Project A' });
const projectB = createProject({ id: 'proj-b', name: 'Project B' });
const taskA = createTask({ id: 'task-a', specId: 'write-to-file', projectId: 'proj-a' });
const taskB = createTask({ id: 'task-b', specId: 'write-to-file', projectId: 'proj-b' });
mockProjects.push(projectA, projectB);
mockTasksByProject.set('proj-a', [taskA]);
mockTasksByProject.set('proj-b', [taskB]);
// Without projectId - returns first match (Project A)
const resultNoScope = findTaskAndProject('write-to-file');
expect(resultNoScope.task).toBe(taskA);
expect(resultNoScope.project).toBe(projectA);
// With projectId for Project B - returns Project B's task
const resultScopedB = findTaskAndProject('write-to-file', 'proj-b');
expect(resultScopedB.task).toBe(taskB);
expect(resultScopedB.project).toBe(projectB);
// With projectId for Project A - returns Project A's task
const resultScopedA = findTaskAndProject('write-to-file', 'proj-a');
expect(resultScopedA.task).toBe(taskA);
expect(resultScopedA.project).toBe(projectA);
});
it('should NOT fall back to other projects when projectId is provided but task not found', () => {
const projectA = createProject({ id: 'proj-a' });
const projectB = createProject({ id: 'proj-b' });
const taskA = createTask({ id: 'task-a', specId: 'write-to-file', projectId: 'proj-a' });
mockProjects.push(projectA, projectB);
mockTasksByProject.set('proj-a', [taskA]);
mockTasksByProject.set('proj-b', []);
// Search Project B (which has no tasks) — should NOT find Project A's task
const result = findTaskAndProject('write-to-file', 'proj-b');
expect(result.task).toBeUndefined();
expect(result.project).toBeUndefined();
});
it('should return undefined when projectId refers to a non-existent project', () => {
const project = createProject({ id: 'proj-1' });
const task = createTask({ id: 'task-1', specId: 'write-to-file', projectId: 'proj-1' });
mockProjects.push(project);
mockTasksByProject.set('proj-1', [task]);
// Search with a projectId that doesn't exist — should NOT fall back
const result = findTaskAndProject('write-to-file', 'non-existent-project');
expect(result.task).toBeUndefined();
expect(result.project).toBeUndefined();
});
it('should return undefined when task not found in any project', () => {
const project = createProject({ id: 'proj-1' });
mockProjects.push(project);
mockTasksByProject.set('proj-1', []);
const result = findTaskAndProject('nonexistent-task');
expect(result.task).toBeUndefined();
expect(result.project).toBeUndefined();
});
it('should find task by id as well as specId', () => {
const project = createProject({ id: 'proj-1' });
const task = createTask({ id: 'unique-uuid', specId: 'write-to-file', projectId: 'proj-1' });
mockProjects.push(project);
mockTasksByProject.set('proj-1', [task]);
const result = findTaskAndProject('unique-uuid', 'proj-1');
expect(result.task).toBe(task);
expect(result.project).toBe(project);
});
it('should log warning when provided projectId is not found', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
mockProjects.push(createProject({ id: 'proj-1' }));
findTaskAndProject('some-task', 'ghost-project');
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('ghost-project'),
// Flexible match on the rest of the message
);
warnSpy.mockRestore();
});
});
@@ -246,7 +246,7 @@ export function registerTaskExecutionHandlers(
// Start spec creation process - pass the existing spec directory
// so spec_runner uses it instead of creating a new one
// Also pass baseBranch so worktrees are created from the correct branch
agentManager.startSpecCreation(taskId, project.path, taskDescription, specDir, task.metadata, baseBranch);
agentManager.startSpecCreation(taskId, project.path, taskDescription, specDir, task.metadata, baseBranch, project.id);
} else if (needsImplementation) {
// Spec exists but no subtasks - run run.py to create implementation plan and execute
// Read the spec.md to get the task description
@@ -268,8 +268,10 @@ export function registerTaskExecutionHandlers(
parallel: false, // Sequential for planning phase
workers: 1,
baseBranch,
useWorktree: task.metadata?.useWorktree
}
useWorktree: task.metadata?.useWorktree,
useLocalBranch: task.metadata?.useLocalBranch
},
project.id
);
} else {
// Task has subtasks, start normal execution
@@ -284,8 +286,10 @@ export function registerTaskExecutionHandlers(
parallel: false,
workers: 1,
baseBranch,
useWorktree: task.metadata?.useWorktree
}
useWorktree: task.metadata?.useWorktree,
useLocalBranch: task.metadata?.useLocalBranch
},
project.id
);
}
}
@@ -495,7 +499,7 @@ export function registerTaskExecutionHandlers(
// The QA process needs to run where the implementation_plan.json with completed subtasks is
const qaProjectPath = hasWorktree ? worktreePath : project.path;
console.warn('[TASK_REVIEW] Starting QA process with projectPath:', qaProjectPath);
agentManager.startQAProcess(taskId, qaProjectPath, task.specId);
agentManager.startQAProcess(taskId, qaProjectPath, task.specId, project.id);
taskStateManager.handleUiEvent(
taskId,
@@ -727,7 +731,7 @@ export function registerTaskExecutionHandlers(
// No spec file - need to run spec_runner.py to create the spec
const taskDescription = task.description || task.title;
console.warn('[TASK_UPDATE_STATUS] Starting spec creation for:', task.specId);
agentManager.startSpecCreation(taskId, project.path, taskDescription, specDir, task.metadata, baseBranchForUpdate);
agentManager.startSpecCreation(taskId, project.path, taskDescription, specDir, task.metadata, baseBranchForUpdate, project.id);
} else if (needsImplementation) {
// Spec exists but no subtasks - run run.py to create implementation plan and execute
console.warn('[TASK_UPDATE_STATUS] Starting task execution (no subtasks) for:', task.specId);
@@ -739,8 +743,10 @@ export function registerTaskExecutionHandlers(
parallel: false,
workers: 1,
baseBranch: baseBranchForUpdate,
useWorktree: task.metadata?.useWorktree
}
useWorktree: task.metadata?.useWorktree,
useLocalBranch: task.metadata?.useLocalBranch
},
project.id
);
} else {
// Task has subtasks, start normal execution
@@ -754,8 +760,10 @@ export function registerTaskExecutionHandlers(
parallel: false,
workers: 1,
baseBranch: baseBranchForUpdate,
useWorktree: task.metadata?.useWorktree
}
useWorktree: task.metadata?.useWorktree,
useLocalBranch: task.metadata?.useLocalBranch
},
project.id
);
}
@@ -792,6 +800,73 @@ export function registerTaskExecutionHandlers(
}
);
/**
* Resume a paused task (rate limited or auth failure paused)
* This writes a RESUME file to the spec directory to signal the backend to continue
*/
ipcMain.handle(
IPC_CHANNELS.TASK_RESUME_PAUSED,
async (_, taskId: string): Promise<IPCResult> => {
// Find task and project
const { task, project } = findTaskAndProject(taskId);
if (!task || !project) {
return { success: false, error: 'Task not found' };
}
// Get the spec directory - use task.specsPath if available (handles worktree vs main)
const specsBaseDir = getSpecsDir(project.autoBuildPath);
const specDir = task.specsPath || path.join(
project.path,
specsBaseDir,
task.specId
);
// Write RESUME file to signal backend to continue
const resumeFilePath = path.join(specDir, 'RESUME');
try {
const resumeContent = JSON.stringify({
resumed_at: new Date().toISOString(),
resumed_by: 'user'
});
atomicWriteFileSync(resumeFilePath, resumeContent);
console.log(`[TASK_RESUME_PAUSED] Wrote RESUME file to: ${resumeFilePath}`);
// Also write to worktree if it exists (backend may be running inside the worktree)
const worktreePath = findTaskWorktree(project.path, task.specId);
if (worktreePath) {
const worktreeResumeFilePath = path.join(worktreePath, specsBaseDir, task.specId, 'RESUME');
try {
atomicWriteFileSync(worktreeResumeFilePath, resumeContent);
console.log(`[TASK_RESUME_PAUSED] Also wrote RESUME file to worktree: ${worktreeResumeFilePath}`);
} catch (worktreeError) {
// Non-fatal - main spec dir RESUME is sufficient
console.warn(`[TASK_RESUME_PAUSED] Could not write to worktree (non-fatal):`, worktreeError);
}
} else if (
task.executionProgress?.phase === 'rate_limit_paused' ||
task.executionProgress?.phase === 'auth_failure_paused'
) {
// Warn if worktree not found for a paused task - the backend is likely
// running inside the worktree and may not see the RESUME file in the main spec dir
console.warn(
`[TASK_RESUME_PAUSED] Worktree not found for paused task ${task.specId}. ` +
`Backend may not detect the RESUME file if running inside a worktree.`
);
}
return { success: true };
} catch (error) {
console.error('[TASK_RESUME_PAUSED] Failed to write RESUME file:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to signal resume'
};
}
}
);
/**
* Recover a stuck task (status says in_progress but no process running)
*/
@@ -1108,7 +1183,7 @@ export function registerTaskExecutionHandlers(
// No spec file - need to run spec_runner.py to create the spec
const taskDescription = task.description || task.title;
console.warn(`[Recovery] Starting spec creation for: ${task.specId}`);
agentManager.startSpecCreation(taskId, project.path, taskDescription, specDirForWatcher, task.metadata, baseBranchForRecovery);
agentManager.startSpecCreation(taskId, project.path, taskDescription, specDirForWatcher, task.metadata, baseBranchForRecovery, project.id);
} else {
// Spec exists - run task execution
console.warn(`[Recovery] Starting task execution for: ${task.specId}`);
@@ -1120,8 +1195,10 @@ export function registerTaskExecutionHandlers(
parallel: false,
workers: 1,
baseBranch: baseBranchForRecovery,
useWorktree: task.metadata?.useWorktree
}
useWorktree: task.metadata?.useWorktree,
useLocalBranch: task.metadata?.useLocalBranch
},
project.id
);
}
@@ -314,8 +314,8 @@ export function persistPlanPhaseSync(
const phaseToStatus: Record<string, TaskStatus> = {
'planning': 'in_progress',
'coding': 'in_progress',
'qa_review': 'in_progress',
'qa_fixing': 'in_progress',
'qa_review': 'ai_review',
'qa_fixing': 'ai_review',
'complete': 'human_review',
'failed': 'error'
};
@@ -2,21 +2,39 @@ import type { Task, Project } from '../../../shared/types';
import { projectStore } from '../../project-store';
/**
* Helper function to find task and project by taskId
* Helper function to find task and project by taskId.
*
* When projectId is provided, the search is strictly scoped to that project.
* If the task is not found in the specified project, returns undefined (does NOT
* fall back to other projects). This prevents cross-project contamination when
* multiple projects have tasks with the same specId.
*
* When projectId is NOT provided, searches all projects for backward
* compatibility with callers that don't have projectId (e.g., file watcher events).
*/
export const findTaskAndProject = (taskId: string): { task: Task | undefined; project: Project | undefined } => {
export const findTaskAndProject = (taskId: string, projectId?: string): { task: Task | undefined; project: Project | undefined } => {
const projects = projectStore.getProjects();
let task: Task | undefined;
let project: Project | undefined;
// If projectId provided, search ONLY that project (no fallback)
if (projectId) {
const targetProject = projects.find((p) => p.id === projectId);
if (!targetProject) {
console.warn(`[findTaskAndProject] projectId "${projectId}" not found in projects list, returning undefined`);
return { task: undefined, project: undefined };
}
const tasks = projectStore.getTasks(targetProject.id);
const task = tasks.find((t) => t.id === taskId || t.specId === taskId);
return { task, project: task ? targetProject : undefined };
}
// No projectId: search all projects (backward compatibility for file watcher etc.)
for (const p of projects) {
const tasks = projectStore.getTasks(p.id);
task = tasks.find((t) => t.id === taskId || t.specId === taskId);
const task = tasks.find((t) => t.id === taskId || t.specId === taskId);
if (task) {
project = p;
break;
return { task, project: p };
}
}
return { task, project };
return { task: undefined, project: undefined };
};
@@ -4,7 +4,7 @@ import type { IPCResult, WorktreeStatus, WorktreeDiff, WorktreeDiffFile, Worktre
import path from 'path';
import { minimatch } from 'minimatch';
import { existsSync, readdirSync, statSync, readFileSync, promises as fsPromises } from 'fs';
import { execSync, execFileSync, spawn, spawnSync, exec, execFile } from 'child_process';
import { execFileSync, spawn, spawnSync, exec, execFile } from 'child_process';
import { homedir } from 'os';
import { projectStore } from '../../project-store';
import { getConfiguredPythonPath, PythonEnvManager, pythonEnvManager as pythonEnvManagerSingleton } from '../../python-env-manager';
@@ -19,7 +19,7 @@ import {
findTaskWorktree,
} from '../../worktree-paths';
import { persistPlanStatus, updateTaskMetadataPrUrl } from './plan-file-utils';
import { getIsolatedGitEnv, detectWorktreeBranch, refreshGitIndex } from '../../utils/git-isolation';
import { getIsolatedGitEnv, refreshGitIndex } from '../../utils/git-isolation';
import { cleanupWorktree } from '../../utils/worktree-cleanup';
import { killProcessGracefully } from '../../platform';
import { stripAnsiCodes } from '../../../shared/utils/ansi-sanitizer';
@@ -1067,7 +1067,7 @@ async function detectLinuxApps(): Promise<Set<string>> {
function isAppInstalled(
appNames: string[],
specificPaths: string[],
platform: string
_platform: string
): { installed: boolean; foundPath: string } {
// First, check the cached app list (fast)
for (const name of appNames) {
@@ -2568,7 +2568,7 @@ export function registerWorktreeHandlers(
encoding: 'utf-8'
});
if (gitStatus && gitStatus.trim()) {
if (gitStatus?.trim()) {
// Parse the status output to get file names
// Format: XY filename (where X and Y are status chars, then space, then filename)
uncommittedFiles = gitStatus
@@ -8,7 +8,7 @@ import { TerminalManager } from '../terminal-manager';
import { projectStore } from '../project-store';
import { terminalNameGenerator } from '../terminal-name-generator';
import { readSettingsFileAsync } from '../settings-utils';
import { debugLog, debugError } from '../../shared/utils/debug-logger';
import { debugLog, } from '../../shared/utils/debug-logger';
import { migrateSession } from '../claude-profile/session-utils';
import { createProfileDirectory } from '../claude-profile/profile-utils';
import { isValidConfigDir } from '../utils/config-path-validator';
@@ -55,10 +55,11 @@ export function registerTerminalHandlers(
}
);
ipcMain.on(
ipcMain.handle(
IPC_CHANNELS.TERMINAL_RESIZE,
(_, id: string, cols: number, rows: number) => {
terminalManager.resize(id, cols, rows);
async (_, id: string, cols: number, rows: number): Promise<IPCResult<{ success: boolean }>> => {
const success = terminalManager.resize(id, cols, rows);
return { success, data: { success } };
}
);
@@ -345,9 +345,9 @@ function loadWorktreeConfig(projectPath: string, name: string): TerminalWorktree
async function createTerminalWorktree(
request: CreateTerminalWorktreeRequest
): Promise<TerminalWorktreeResult> {
const { terminalId, name, taskId, createGitBranch, projectPath, baseBranch: customBaseBranch } = request;
const { terminalId, name, taskId, createGitBranch, projectPath, baseBranch: customBaseBranch, useLocalBranch } = request;
debugLog('[TerminalWorktree] Creating worktree:', { name, taskId, createGitBranch, projectPath, customBaseBranch });
debugLog('[TerminalWorktree] Creating worktree:', { name, taskId, createGitBranch, projectPath, customBaseBranch, useLocalBranch });
// Validate projectPath against registered projects
if (!isValidProjectPath(projectPath)) {
@@ -418,8 +418,13 @@ async function createTerminalWorktree(
// Already a remote ref, use as-is
baseRef = baseBranch;
debugLog('[TerminalWorktree] Using remote ref directly:', baseRef);
} else if (useLocalBranch) {
// User explicitly requested local branch - skip auto-switch to remote
// This preserves gitignored files (.env, configs) that may not exist on remote
baseRef = baseBranch;
debugLog('[TerminalWorktree] Using local branch (explicit):', baseRef);
} else {
// Check if remote version exists and use it for latest code
// Default behavior: check if remote version exists and use it for latest code
try {
execFileSync(getToolPath('git'), ['rev-parse', '--verify', `origin/${baseBranch}`], {
cwd: projectPath,
+1 -3
View File
@@ -29,8 +29,6 @@ export class LogService {
// Flush logs to disk every 2 seconds to balance performance vs data safety
private readonly FLUSH_INTERVAL_MS = 2000;
// Maximum log file size before rotation (10MB)
private readonly MAX_LOG_SIZE_BYTES = 10 * 1024 * 1024;
// Keep last N sessions
private readonly MAX_SESSIONS_TO_KEEP = 10;
@@ -205,7 +203,7 @@ export class LogService {
const sessionId = file.replace('session-', '').replace('.log', '');
// Parse session ID back to date
const dateStr = sessionId.replace(/-/g, (match, offset) => {
const dateStr = sessionId.replace(/-/g, (_match, offset) => {
// Replace first 2 dashes with actual dashes, rest with colons
if (offset < 10) return '-';
if (offset === 10) return 'T';
+1 -1
View File
@@ -243,7 +243,7 @@ function getWindowsShellConfig(preferredShell?: ShellType): ShellConfig {
/**
* Get Unix shell configuration
*/
function getUnixShellConfig(preferredShell?: ShellType): ShellConfig {
function getUnixShellConfig(_preferredShell?: ShellType): ShellConfig {
const shellPath = process.env.SHELL || '/bin/zsh';
return {
+7 -3
View File
@@ -9,6 +9,7 @@ import * as path from 'path';
import * as os from 'os';
import { existsSync, readdirSync } from 'fs';
import { isWindows, isMacOS, getHomebrewPath, joinPaths, getExecutableExtension } from './index';
import { getWhereExePath } from '../utils/windows-paths';
/**
* Resolve Claude CLI executable path
@@ -174,7 +175,7 @@ export function getWindowsShellPaths(): Record<string, string[]> {
return {};
}
const systemRoot = process.env.SystemRoot || 'C:\\Windows';
const systemRoot = process.env.SystemRoot || process.env.SYSTEMROOT || 'C:\\Windows';
// Note: path.join('C:', 'foo') produces 'C:foo' (relative to C: drive), not 'C:\foo'
// We must use 'C:\\' or raw paths like 'C:\\Program Files' to get absolute paths
@@ -297,11 +298,14 @@ export function getOllamaInstallCommand(): string {
/**
* Get the command to find executables in PATH
*
* Windows: where.exe
* Windows: Full path to where.exe (C:\Windows\System32\where.exe)
* Using full path ensures it works even when System32 isn't in PATH,
* which can happen in restricted environments or when Electron doesn't
* inherit the full system PATH.
* Unix: which
*/
export function getWhichCommand(): string {
return isWindows() ? 'where.exe' : 'which';
return isWindows() ? getWhereExePath() : 'which';
}
/**
+58 -14
View File
@@ -3,10 +3,11 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, Dirent
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import type { Project, ProjectSettings, Task, TaskStatus, TaskMetadata, ImplementationPlan, ReviewReason, PlanSubtask, KanbanPreferences, ExecutionPhase } from '../shared/types';
import { DEFAULT_PROJECT_SETTINGS, AUTO_BUILD_PATHS, getSpecsDir, JSON_ERROR_PREFIX, JSON_ERROR_TITLE_SUFFIX } from '../shared/constants';
import { DEFAULT_PROJECT_SETTINGS, AUTO_BUILD_PATHS, getSpecsDir, JSON_ERROR_PREFIX, JSON_ERROR_TITLE_SUFFIX, TASK_STATUS_PRIORITY } from '../shared/constants';
import { XSTATE_SETTLED_STATES } from '../shared/state-machines';
import { getAutoBuildPath, isInitialized } from './project-initializer';
import { getTaskWorktreeDir } from './worktree-paths';
import { isValidTaskId, findAllSpecPaths } from './utils/spec-path-helpers';
import { findAllSpecPaths } from './utils/spec-path-helpers';
interface TabState {
openProjectIds: string[];
@@ -324,12 +325,37 @@ export class ProjectStore {
}
}
// 3. Deduplicate tasks by ID (prefer worktree version if exists in both)
// 3. Deduplicate tasks by ID
// CRITICAL FIX: Don't blindly prefer worktree - it may be stale!
// If main project task is "done", it should win over worktree's "in_progress".
// Worktrees can linger after completion, containing outdated task data.
const taskMap = new Map<string, Task>();
for (const task of allTasks) {
const existing = taskMap.get(task.id);
if (!existing || task.location === 'worktree') {
if (!existing) {
// First occurrence wins
taskMap.set(task.id, task);
} else {
// PREFER MAIN PROJECT over worktree - main has current user changes
// Only use status priority when both are from same location
const existingIsMain = existing.location === 'main';
const newIsMain = task.location === 'main';
if (existingIsMain && !newIsMain) {
} else if (!existingIsMain && newIsMain) {
// New is main, replace existing worktree
taskMap.set(task.id, task);
} else {
// Same location - use status priority to determine which is more complete
const existingPriority = TASK_STATUS_PRIORITY[existing.status] || 0;
const newPriority = TASK_STATUS_PRIORITY[task.status] || 0;
if (newPriority > existingPriority) {
// New version has higher priority (more complete status)
taskMap.set(task.id, task);
}
// Otherwise keep existing version
}
}
}
@@ -362,10 +388,10 @@ export class ProjectStore {
*/
private loadTasksFromSpecsDir(
specsDir: string,
basePath: string,
_basePath: string,
location: 'main' | 'worktree',
projectId: string,
specsBaseDir: string
_specsBaseDir: string
): Task[] {
const tasks: Task[] = [];
let specDirs: Dirent[] = [];
@@ -492,7 +518,7 @@ export class ProjectStore {
// "# Specification: Title" -> "Title"
// "# Title" -> "Title"
const titleMatch = specContent.match(/^#\s+(?:Quick Spec:|Specification:)?\s*(.+)$/m);
if (titleMatch && titleMatch[1]) {
if (titleMatch?.[1]) {
title = titleMatch[1].trim();
}
} catch {
@@ -500,15 +526,33 @@ export class ProjectStore {
}
}
// Use persisted executionPhase (from text parser) or xstateState for exact restoration
// Priority: executionPhase > xstateState > inferred from status
// Use xstateState as the authoritative source for execution phase when available.
// Only fall back to persistedPhase for active running states where xstateState
// might not reflect the detailed phase yet (e.g., planning vs coding within in_progress).
// Priority: xstateState (when settled) > persistedPhase (when running) > inferred from status
const persistedPhase = (plan as { executionPhase?: string } | null)?.executionPhase as ExecutionPhase | undefined;
const xstateState = (plan as { xstateState?: string } | null)?.xstateState;
const executionProgress = persistedPhase
? { phase: persistedPhase, phaseProgress: 50, overallProgress: 50 }
: xstateState
? this.inferExecutionProgressFromXState(xstateState)
: this.inferExecutionProgress(plan?.status);
// XState settled states take priority - these override any stale persisted phase
// because the task is no longer actively running
const isSettledState = xstateState && XSTATE_SETTLED_STATES.has(xstateState);
// Status-based override: if the task has reached a terminal status (human_review, done, pr_created)
// but the persisted executionPhase is stale (e.g. still "planning"), correct it.
// This happens when the XState machine didn't persist the final phase before being reset to backlog.
const STATUS_IMPLIES_COMPLETE: ReadonlySet<string> = new Set(['human_review', 'done', 'pr_created']);
const phaseIsStale = persistedPhase && persistedPhase !== 'complete' && persistedPhase !== 'failed'
&& STATUS_IMPLIES_COMPLETE.has(finalStatus);
const executionProgress = isSettledState
? this.inferExecutionProgressFromXState(xstateState) // Use XState for settled states
: phaseIsStale
? { phase: 'complete' as ExecutionPhase, phaseProgress: 100, overallProgress: 100 } // Fix stale phase
: persistedPhase
? { phase: persistedPhase, phaseProgress: 50, overallProgress: 50 } // Use persisted for running
: xstateState
? this.inferExecutionProgressFromXState(xstateState)
: this.inferExecutionProgress(plan?.status);
tasks.push({
id: dir.name, // Use spec directory name as ID
+25 -8
View File
@@ -92,6 +92,25 @@ const BILLING_FAILURE_PATTERNS = [
/top\s*up\s*(your\s*)?(account|credits|balance)/i
];
/**
* Maximum length for error messages sent to renderer.
* Truncates to prevent exposing excessive internal details.
*/
const MAX_ERROR_LENGTH = 500;
/**
* Sanitize error output before sending to renderer.
* Truncates long output to prevent exposing excessive internal details
* like full paths, API responses, or stack traces.
*/
function sanitizeErrorOutput(output: string): string {
// Truncate long output to limit exposure of internal details
if (output.length > MAX_ERROR_LENGTH) {
return output.substring(0, MAX_ERROR_LENGTH) + '... (truncated)';
}
return output;
}
/**
* Result of rate limit detection
*/
@@ -109,7 +128,7 @@ export interface RateLimitDetectionResult {
id: string;
name: string;
};
/** Original error message */
/** Original error message (truncated to 500 chars for security) */
originalError?: string;
}
@@ -193,7 +212,7 @@ export function detectRateLimit(
id: bestProfile.id,
name: bestProfile.name
} : undefined,
originalError: output
originalError: sanitizeErrorOutput(output)
};
}
@@ -211,7 +230,7 @@ export function detectRateLimit(
id: bestProfile.id,
name: bestProfile.name
} : undefined,
originalError: output
originalError: sanitizeErrorOutput(output)
};
}
}
@@ -265,7 +284,6 @@ function getAuthFailureMessage(failureType: 'missing' | 'invalid' | 'expired' |
return 'Your Claude session has expired. Please re-authenticate in Settings > Claude Profiles.';
case 'invalid':
return 'Invalid Claude credentials. Please check your OAuth token or re-authenticate in Settings > Claude Profiles.';
case 'unknown':
default:
return 'Claude authentication failed. Please verify your authentication in Settings > Claude Profiles.';
}
@@ -303,7 +321,6 @@ function getBillingFailureMessage(failureType: 'insufficient_credits' | 'payment
return 'A billing error occurred with your Claude API account. Please check your payment method or switch to another profile in Settings > Claude Profiles.';
case 'subscription_inactive':
return 'Your Claude API subscription is inactive or expired. Please renew your subscription or switch to another profile in Settings > Claude Profiles.';
case 'unknown':
default:
return 'A billing issue was detected with your Claude API account. Please check your account status or switch to another profile in Settings > Claude Profiles.';
}
@@ -333,7 +350,7 @@ export function detectAuthFailure(
profileId: effectiveProfileId,
failureType,
message: getAuthFailureMessage(failureType),
originalError: output
originalError: sanitizeErrorOutput(output)
};
}
}
@@ -375,7 +392,7 @@ export function detectBillingFailure(
profileId: effectiveProfileId,
failureType,
message: getBillingFailureMessage(failureType),
originalError: output
originalError: sanitizeErrorOutput(output)
};
}
}
@@ -631,7 +648,7 @@ export interface SDKRateLimitInfo {
};
/** When detected */
detectedAt: Date;
/** Original error message */
/** Original error message (truncated to 500 chars for security) */
originalError?: string;
// Auto-swap information
+1 -4
View File
@@ -24,9 +24,6 @@ import { refreshGitIndex } from './utils/git-isolation';
* If a worktree exists for a task NOT in this release, it won't block the release.
*/
export class ReleaseService extends EventEmitter {
constructor() {
super();
}
/**
* Parse CHANGELOG.md to extract releaseable versions.
@@ -75,7 +72,7 @@ export class ReleaseService extends EventEmitter {
* This allows us to scope worktree checks to only those tasks.
*/
getTasksForVersion(
projectPath: string,
_projectPath: string,
version: string,
tasks: Task[]
): { taskIds: string[]; specIds: string[] } {
+2 -2
View File
@@ -63,7 +63,7 @@ function getTracesSampleRate(): number {
const envValue = buildTimeValue || process.env.SENTRY_TRACES_SAMPLE_RATE;
if (envValue) {
const parsed = parseFloat(envValue);
if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) {
if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 1) {
return parsed;
}
}
@@ -83,7 +83,7 @@ function getProfilesSampleRate(): number {
const envValue = buildTimeValue || process.env.SENTRY_PROFILES_SAMPLE_RATE;
if (envValue) {
const parsed = parseFloat(envValue);
if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) {
if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 1) {
return parsed;
}
}
@@ -14,7 +14,7 @@ import {
getAPIProfileEnv,
testConnection
} from './profile-service';
import type { APIProfile, ProfilesFile, TestConnectionResult } from '../../shared/types/profile';
import type { ProfilesFile, } from '../../shared/types/profile';
// Mock profile-manager
vi.mock('../utils/profile-manager', () => ({
@@ -90,8 +90,6 @@ export async function deleteProfile(id: string): Promise<void> {
throw new Error('Profile not found');
}
const profile = file.profiles[profileIndex];
// Active Profile Check: Cannot delete active profile (AC3)
if (file.activeProfileId === id) {
throw new Error('Cannot delete active profile. Please switch to another profile or OAuth first.');
@@ -13,7 +13,7 @@ import {
testConnection,
discoverModels
} from './profile-service';
import type { APIProfile, ProfilesFile, TestConnectionResult } from '@shared/types/profile';
import type { ProfilesFile, } from '@shared/types/profile';
// Mock Anthropic SDK - use vi.hoisted to properly hoist the mock variable
const { mockModelsList, mockMessagesCreate } = vi.hoisted(() => ({
@@ -8,8 +8,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
SDKSessionRecoveryCoordinator,
getRecoveryCoordinator,
type SDKOperationType,
type RecoveryCoordinatorConfig
} from './sdk-session-recovery-coordinator';
// Mock dependencies
@@ -1,6 +1,25 @@
/**
* SDK Session Recovery Coordinator
*
* @deprecated This module is deprecated in favor of ClaudeOperationRegistry
* (src/main/claude-profile/operation-registry.ts). The OperationRegistry provides
* similar functionality with a simpler API and is actively integrated with
* AgentManager and UsageMonitor. This module is retained for backward compatibility
* but should not be used for new code.
*
* TODO: Target removal in v0.5.0 (Q2 2026). Before removal:
* 1. Identify any remaining usages in the codebase
* 2. Migrate all remaining consumers to ClaudeOperationRegistry
* 3. Remove this file and associated tests
* 4. Update imports across the codebase
*
* Migration guide:
* - Use getOperationRegistry() from '../claude-profile/operation-registry'
* - registerOperation() -> operationRegistry.registerOperation()
* - unregisterOperation() -> operationRegistry.unregisterOperation()
* - getOperationsByProfile() -> operationRegistry.getOperationsByProfile()
*
* Original description:
* Central coordinator for all SDK operations and rate limit recovery.
* Part of the intelligent rate limit recovery system (Phase 9: Unified Coordination).
*
@@ -97,6 +116,9 @@ interface PendingNotification {
/**
* SDKSessionRecoveryCoordinator - Central manager for SDK operations and recovery
*
* @deprecated Use ClaudeOperationRegistry from '../claude-profile/operation-registry' instead.
* This class is retained for backward compatibility but is no longer actively maintained.
*
* This singleton coordinates all SDK operations across the application:
* - Tasks (via AgentManager)
* - Background operations (roadmap, ideation, changelog)
@@ -531,6 +553,7 @@ export class SDKSessionRecoveryCoordinator extends EventEmitter {
/**
* Get the global coordinator instance
* @deprecated Use getOperationRegistry() from '../claude-profile/operation-registry' instead.
*/
export function getRecoveryCoordinator(): SDKSessionRecoveryCoordinator {
return SDKSessionRecoveryCoordinator.getInstance();
+2 -7
View File
@@ -1,5 +1,5 @@
import path from 'path';
import { existsSync, readFileSync, watchFile } from 'fs';
import { existsSync, readFileSync, } from 'fs';
import { EventEmitter } from 'events';
import type { TaskLogs, TaskLogPhase, TaskLogStreamChunk, TaskPhaseLog } from '../shared/types';
import { findTaskWorktree } from './worktree-paths';
@@ -26,7 +26,6 @@ function findWorktreeSpecDir(projectPath: string, specId: string, specsRelPath:
* watches both locations and merges logs from both sources.
*/
export class TaskLogService extends EventEmitter {
private watchers: Map<string, { watcher: ReturnType<typeof watchFile>; specDir: string }> = new Map();
private logCache: Map<string, TaskLogs> = new Map();
private pollIntervals: Map<string, NodeJS.Timeout> = new Map();
// Store paths being watched for each specId (main + worktree)
@@ -35,10 +34,6 @@ export class TaskLogService extends EventEmitter {
// Poll interval for watching log changes (more reliable than fs.watch on some systems)
private readonly POLL_INTERVAL_MS = 1000;
constructor() {
super();
}
/**
* Load task logs from a single spec directory
* Returns cached logs if the file is corrupted (e.g., mid-write by Python backend)
@@ -125,7 +120,7 @@ export class TaskLogService extends EventEmitter {
let worktreeSpecDir: string | null = null;
if (watchedInfo && watchedInfo[1].worktreeSpecDir) {
if (watchedInfo?.[1].worktreeSpecDir) {
worktreeSpecDir = watchedInfo[1].worktreeSpecDir;
} else if (projectPath && specsRelPath && specId) {
// Calculate worktree path from provided params
+27 -63
View File
@@ -3,7 +3,7 @@ import type { ActorRefFrom } from 'xstate';
import type { BrowserWindow } from 'electron';
import type { TaskEventPayload } from './agent/task-event-schema';
import type { Project, Task, TaskStatus, ReviewReason, ExecutionPhase } from '../shared/types';
import { taskMachine, type TaskEvent } from '../shared/state-machines';
import { taskMachine, XSTATE_TO_PHASE, mapStateToLegacy, type TaskEvent } from '../shared/state-machines';
import { IPC_CHANNELS } from '../shared/constants';
import { safeSendToRenderer } from './ipc-handlers/utils';
import { getPlanPath, persistPlanStatusAndReasonSync } from './ipc-handlers/task/plan-file-utils';
@@ -14,21 +14,6 @@ import path from 'path';
type TaskActor = ActorRefFrom<typeof taskMachine>;
/** Maps XState states to execution phases. Shared by mapStateToExecutionPhase and emitPhaseFromState. */
const XSTATE_TO_PHASE: Record<string, ExecutionPhase> = {
'backlog': 'idle',
'planning': 'planning',
'plan_review': 'planning',
'coding': 'coding',
'qa_review': 'qa_review',
'qa_fixing': 'qa_fixing',
'human_review': 'complete',
'error': 'failed',
'creating_pr': 'complete',
'pr_created': 'complete',
'done': 'complete'
};
interface TaskContextEntry {
task: Task;
project: Project;
@@ -36,6 +21,7 @@ interface TaskContextEntry {
const TERMINAL_EVENTS = new Set<string>([
'QA_PASSED',
'PLANNING_COMPLETE',
'PLANNING_FAILED',
'CODING_FAILED',
'QA_MAX_ITERATIONS',
@@ -57,10 +43,10 @@ export class TaskStateManager {
handleTaskEvent(taskId: string, event: TaskEventPayload, task: Task, project: Project): boolean {
const lastSeq = this.lastSequenceByTask.get(taskId);
console.log(`[TaskStateManager] handleTaskEvent: ${event.type} seq=${event.sequence}, lastSeq=${lastSeq}`);
console.debug(`[TaskStateManager] handleTaskEvent: ${event.type} seq=${event.sequence}, lastSeq=${lastSeq}`);
if (!this.isNewSequence(taskId, event.sequence)) {
console.log(`[TaskStateManager] Event ${event.type} DROPPED - sequence ${event.sequence} not newer than ${lastSeq}`);
console.debug(`[TaskStateManager] Event ${event.type} DROPPED - sequence ${event.sequence} not newer than ${lastSeq}`);
return false;
}
this.setTaskContext(taskId, task, project);
@@ -72,10 +58,10 @@ export class TaskStateManager {
const actor = this.getOrCreateActor(taskId);
const stateBefore = String(actor.getSnapshot().value);
console.log(`[TaskStateManager] Sending ${event.type} to actor in state: ${stateBefore}`);
console.debug(`[TaskStateManager] Sending ${event.type} to actor in state: ${stateBefore}`);
actor.send(event as TaskEvent);
const stateAfter = String(actor.getSnapshot().value);
console.log(`[TaskStateManager] After ${event.type}: state ${stateBefore} -> ${stateAfter}`);
console.debug(`[TaskStateManager] After ${event.type}: state ${stateBefore} -> ${stateAfter}`);
return true;
}
@@ -92,22 +78,26 @@ export class TaskStateManager {
return;
}
const actor = this.getOrCreateActor(taskId);
// Only mark as unexpected if the process exited with a non-zero code.
// A code-0 exit is normal (e.g., spec creation finished, plan created, waiting for review).
// Sending unexpected:true for code-0 exits incorrectly transitions plan_review → error.
const isUnexpected = exitCode !== 0;
actor.send({
type: 'PROCESS_EXITED',
exitCode: exitCode ?? -1,
unexpected: true
unexpected: isUnexpected
} satisfies TaskEvent);
}
handleUiEvent(taskId: string, event: TaskEvent, task: Task, project: Project): void {
console.log(`[TaskStateManager] handleUiEvent: ${event.type} for task ${taskId}`);
console.debug(`[TaskStateManager] handleUiEvent: ${event.type} for task ${taskId}`);
this.setTaskContext(taskId, task, project);
const actor = this.getOrCreateActor(taskId);
const stateBefore = String(actor.getSnapshot().value);
console.log(`[TaskStateManager] Sending UI event ${event.type} to actor in state: ${stateBefore}`);
console.debug(`[TaskStateManager] Sending UI event ${event.type} to actor in state: ${stateBefore}`);
actor.send(event);
const stateAfter = String(actor.getSnapshot().value);
console.log(`[TaskStateManager] After UI event ${event.type}: state ${stateBefore} -> ${stateAfter}`);
console.debug(`[TaskStateManager] After UI event ${event.type}: state ${stateBefore} -> ${stateAfter}`);
}
handleManualStatusChange(taskId: string, status: TaskStatus, task: Task, project: Project): boolean {
@@ -133,7 +123,14 @@ export class TaskStateManager {
} else if (!currentState && task.reviewReason === 'plan_review') {
// Fallback: No actor exists (e.g., after app restart), use task data
this.handleUiEvent(taskId, { type: 'PLAN_APPROVED' }, task, project);
} else if (currentState === 'backlog' || !currentState) {
// Fresh start from backlog or no actor - send PLANNING_STARTED
// USER_RESUMED only works from human_review/error states
this.handleUiEvent(taskId, { type: 'PLANNING_STARTED' }, task, project);
} else {
// Already in a running state (planning, coding, qa_*) - send USER_RESUMED
// Note: USER_RESUMED may be ignored if state doesn't handle it, but that's OK
// since the task is already running
this.handleUiEvent(taskId, { type: 'USER_RESUMED' }, task, project);
}
return true;
@@ -220,7 +217,7 @@ export class TaskStateManager {
private getOrCreateActor(taskId: string): TaskActor {
const existing = this.actors.get(taskId);
if (existing) {
console.log(`[TaskStateManager] Using existing actor for ${taskId}, current state:`, String(existing.getSnapshot().value));
console.debug(`[TaskStateManager] Using existing actor for ${taskId}, current state:`, String(existing.getSnapshot().value));
return existing;
}
@@ -230,14 +227,14 @@ export class TaskStateManager {
: undefined;
if (contextEntry) {
console.log(`[TaskStateManager] Creating new actor for ${taskId} from task:`, {
console.debug(`[TaskStateManager] Creating new actor for ${taskId} from task:`, {
status: contextEntry.task.status,
reviewReason: contextEntry.task.reviewReason,
phase: contextEntry.task.executionProgress?.phase,
initialState: snapshot ? String(snapshot.value) : 'default (backlog)'
});
} else {
console.log(`[TaskStateManager] Creating new actor for ${taskId} with default state (no context entry)`);
console.debug(`[TaskStateManager] Creating new actor for ${taskId} with default state (no context entry)`);
}
const actor = snapshot
@@ -247,8 +244,7 @@ export class TaskStateManager {
const stateValue = String(snapshot.value);
const lastState = this.lastStateByTask.get(taskId);
// Debug: Log all state transitions
console.log(`[TaskStateManager] XState transition for ${taskId}:`, {
console.debug(`[TaskStateManager] XState transition for ${taskId}:`, {
from: lastState,
to: stateValue,
contextReviewReason: snapshot.context.reviewReason
@@ -273,8 +269,7 @@ export class TaskStateManager {
// Map XState state to execution phase for persistence
const executionPhase = this.mapStateToExecutionPhase(stateValue);
// Debug: Log the mapped status and reviewReason
console.log(`[TaskStateManager] Emitting status for ${taskId}:`, {
console.debug(`[TaskStateManager] Emitting status for ${taskId}:`, {
status,
reviewReason,
xstateState: stateValue,
@@ -338,7 +333,7 @@ export class TaskStateManager {
console.warn(`[TaskStateManager] emitStatus: No main window, cannot emit status ${status} for ${taskId}`);
return;
}
console.log(`[TaskStateManager] emitStatus: Sending TASK_STATUS_CHANGE for ${taskId}:`, { status, reviewReason, projectId });
console.debug(`[TaskStateManager] emitStatus: Sending TASK_STATUS_CHANGE for ${taskId}:`, { status, reviewReason, projectId });
safeSendToRenderer(
this.getMainWindow,
IPC_CHANNELS.TASK_STATUS_CHANGE,
@@ -425,7 +420,6 @@ export class TaskStateManager {
stateValue = 'error';
contextReviewReason = reviewReason ?? 'errors';
break;
case 'backlog':
default:
stateValue = 'backlog';
break;
@@ -441,33 +435,3 @@ export class TaskStateManager {
}
export const taskStateManager = new TaskStateManager();
function mapStateToLegacy(
state: string,
reviewReason?: ReviewReason
): { status: TaskStatus; reviewReason?: ReviewReason } {
switch (state) {
case 'backlog':
return { status: 'backlog' };
case 'planning':
case 'coding':
return { status: 'in_progress' };
case 'plan_review':
return { status: 'human_review', reviewReason: 'plan_review' };
case 'qa_review':
case 'qa_fixing':
return { status: 'ai_review' };
case 'human_review':
return { status: 'human_review', reviewReason };
case 'error':
return { status: 'human_review', reviewReason: 'errors' };
case 'creating_pr':
return { status: 'human_review', reviewReason: 'completed' };
case 'pr_created':
return { status: 'pr_created' };
case 'done':
return { status: 'done' };
default:
return { status: 'backlog' };
}
}

Some files were not shown because too many files have changed in this diff Show More