48 Commits

Author SHA1 Message Date
Web Dev Cody
d37ced1c6e Merge pull request #841 from AutoMaker-Org/v1.0.0rc
V1.0.0rc
2026-03-15 12:58:24 -04:00
gsxdsm
0311130a8a fix: Update @github/copilot-sdk version and enhance version utility to locate package.json (#840)
- Changed @github/copilot-sdk dependency from "^0.1.16" to "0.1.16" in package.json and package-lock.json.
- Improved version utility in version.ts to check multiple candidate paths for package.json, ensuring better reliability in locating the file.
2026-03-12 19:30:17 -07:00
gsxdsm
7be8163b84 Preserve pipeline work by using waiting_approval on post-completion errors (#836)
* Changes from fix/pipeline-not-finishing

* fix: Prevent overwriting merge_conflict status in pipeline error handlers

* fix: Handle feature loading failures gracefully in error recovery

* ```
fix: Add logging when feature reload fails during status update
```
2026-03-04 20:22:44 -08:00
gsxdsm
26b73df097 Fix feature deep link with project path handling (#834)
* Changes from fix/feature-deeplink-worktree

* Update apps/ui/src/components/views/board-view.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-04 10:13:24 -08:00
gsxdsm
20e7c74b17 Fix event endpoint persistence (#831)
* Changes from fix/event-hook-endpoint

* fix: Allow empty eventHooks/ntfyEndpoints to reconcile from server

Remove the `length > 0` guards in fast-hydrate reconciliation that
prevented intentional empty-array clears from syncing across clients.
Server-side wipe protection (`__allowEmpty*` escape hatches) already
ensures empty arrays in the server are intentional.

Addresses PR #831 review feedback from CodeRabbit and Gemini.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:33:12 -08:00
gsxdsm
dd7108a7a0 Fixes critical React crash on the Kanban board view (#830)
* Changes from fix/board-react-crash

* fix: Prevent cascading re-renders and crashes from high-frequency WS events
2026-03-03 19:23:44 -08:00
gsxdsm
ae48065820 Fix dev server hang by reducing log spam and event frequency (#828)
* Changes from fix/dev-server-hang

* fix: Address PR #828 review feedback

- Reset RAF buffer on context changes (worktree switch, dev-server restart)
  to prevent stale output from flushing into new sessions
- Fix high-frequency WebSocket filter to catch auto-mode:event wrapping
  (auto_mode_progress is wrapped in auto-mode:event) and add feature:progress
- Reorder Vite aliases so explicit jsx-runtime entries aren't shadowed by
  the broad /^react(\/|$)/ regex (Vite uses first-match-wins)

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

* feat: Batch dev server logs and fix React module resolution order

* feat: Add fallback timer for flushing dev server logs in background tabs

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:52:44 -08:00
gsxdsm
b2915f4de1 refactor: Simplify click URL resolution logic 2026-03-02 21:36:33 -08:00
gsxdsm
cf3d312eef fix: Remove overly restrictive pattern from summary extraction regex 2026-03-02 21:36:33 -08:00
gsxdsm
341a6534e6 refactor: extract shared isBacklogLikeStatus helper and improve comments
Address PR #825 review feedback:
- Extract duplicated backlog-like status check into shared helper in constants.ts
- Improve spec-parser regex comment to clarify subsection preservation
- Add file path reference in row-actions.tsx comment for traceability

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:36:33 -08:00
gsxdsm
4a128efbf4 Changes from fix/board-crash-new-feat 2026-03-02 21:36:33 -08:00
gsxdsm
54d69e907b fix: E2E test stability and UI performance improvements (#823) 2026-03-02 18:46:28 -08:00
gsxdsm
1c3d6434a8 fix: reduce excessive POST /api/auto-mode/context-exists requests
Two changes:
1. Server: Skip morgan logging for context-exists endpoint (like health check)
2. UI: Use a stable fingerprint (feature IDs + statuses) instead of the
   unstable features array reference as the useEffect dependency. This
   prevents re-checking context for all features on every React Query
   refetch when the actual feature set hasn't changed.
2026-03-02 11:14:46 -08:00
gsxdsm
8218c48e67 fix: use object destructuring in Playwright beforeEach callback
Playwright requires the first argument to use object destructuring pattern.
Changed `_` to `{}` in feature-deep-link.spec.ts beforeEach.
2026-03-02 07:50:05 -08:00
gsxdsm
59b100b5cc feat: Add conflict source branch tracking and fix auto-mode subscription cascade 2026-03-02 07:43:00 -08:00
gsxdsm
c11f390764 feat: Add conflict source branch detection and fix re-render cascade in BoardView 2026-03-02 07:20:11 -08:00
gsxdsm
33a2e04bf0 feat: Add settingsService integration for feature defaults and improve worktree handling 2026-03-02 03:28:37 -08:00
gsxdsm
34161ccc08 Changes from fix/bug-fixes-1rc 2026-03-01 21:59:02 -08:00
gsxdsm
57bcb2802d Improve auto-loop event emission and add ntfy notifications (#821) 2026-03-01 00:12:22 -08:00
gsxdsm
63b0a4fb38 Fix orphaned features when deleting worktrees (#820)
* Changes from fix/orphaned-features

* fix: Handle feature migration failures and improve UI accessibility

* feat: Add event emission for worktree deletion and feature migration

* fix: Handle OpenCode model errors and prevent duplicate model IDs

* feat: Add summary dialog and async verify with loading state

* fix: Add type attributes to buttons and improve OpenCode model selection

* fix: Add null checks for onVerify callback and opencode model selection
2026-02-28 15:42:10 -08:00
gsxdsm
1c0e460dd1 Add orphaned features management routes and UI integration (#819)
* test(copilot): add edge case test for error with code field

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Changes from fix/bug-fixes-1-0

* refactor(auto-mode): enhance orphaned feature detection and improve project initialization

- Updated detectOrphanedFeatures method to accept preloaded features, reducing redundant disk reads.
- Improved project initialization by creating required directories and files in parallel for better performance.
- Adjusted planning mode handling in UI components to clarify approval requirements for different modes.
- Added refresh functionality for file editor tabs to ensure content consistency with disk state.

These changes enhance performance, maintainability, and user experience across the application.

* feat(orphaned-features): add orphaned features management routes and UI integration

- Introduced new routes for managing orphaned features, including listing, resolving, and bulk resolving.
- Updated the UI to include an Orphaned Features section in project settings and navigation.
- Enhanced the execution service to support new orphaned feature functionalities.

These changes improve the application's capability to handle orphaned features effectively, enhancing user experience and project management.

* fix: Normalize line endings and resolve stale dirty states in file editor

* chore: Update .gitignore and enhance orphaned feature handling

- Added a blank line in .gitignore for better readability.
- Introduced a hash to worktree paths in orphaned feature resolution to prevent conflicts.
- Added validation for target branch existence during orphaned feature resolution.
- Improved prompt formatting in execution service for clarity.
- Enhanced error handling in project selector for project initialization failures.
- Refactored orphaned features section to improve state management and UI responsiveness.

These changes improve code maintainability and user experience when managing orphaned features.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 22:14:41 -08:00
gsxdsm
0196911d59 Bug fixes and stability improvements (#815)
* fix(copilot): correct tool.execution_complete event handling

The CopilotProvider was using incorrect event type and data structure
for tool execution completion events from the @github/copilot-sdk,
causing tool call outputs to be empty.

Changes:
- Update event type from 'tool.execution_end' to 'tool.execution_complete'
- Fix data structure to use nested result.content instead of flat result
- Fix error structure to use error.message instead of flat error
- Add success field to match SDK event structure
- Add tests for empty and missing result handling

This aligns with the official @github/copilot-sdk v0.1.16 types
defined in session-events.d.ts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test(copilot): add edge case test for error with code field

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor(copilot): improve error handling and code quality

Code review improvements:
- Extract magic string '[ERROR]' to TOOL_ERROR_PREFIX constant
- Add null-safe error handling with direct error variable assignment
- Include error codes in error messages for better debugging
- Add JSDoc documentation for tool.execution_complete handler
- Update tests to verify error codes are displayed
- Add missing tool_use_id assertion in error test

These changes improve:
- Code maintainability (no magic strings)
- Debugging experience (error codes now visible)
- Type safety (explicit null checks)
- Test coverage (verify error code formatting)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Changes from fix/bug-fixes-1-0

* test(copilot): add edge case test for error with code field

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Changes from fix/bug-fixes-1-0

* fix: Handle detached HEAD state in worktree discovery and recovery

* fix: Remove unused isDevServerStarting prop and md: breakpoint classes

* fix: Add missing dependency and sanitize persisted cache data

* feat: Ensure NODE_ENV is set to test in vitest configs

* feat: Configure Playwright to run only E2E tests

* fix: Improve PR tracking and dev server lifecycle management

* feat: Add settings-based defaults for planning mode, model config, and custom providers. Fixes #816

* feat: Add worktree and branch selector to graph view

* fix: Add timeout and error handling for worktree HEAD ref resolution

* fix: use absolute icon path and place icon outside asar on Linux

The hicolor icon theme index only lists sizes up to 512x512, so an icon
installed only at 1024x1024 is invisible to GNOME/KDE's theme resolver,
causing both the app launcher and taskbar to show a generic icon.
Additionally, BrowserWindow.icon cannot be read by the window manager
when the file is inside app.asar.

- extraResources: copy logo_larger.png to resources/ (outside asar) so
  it lands at /opt/Automaker/resources/logo_larger.png on install
- linux.desktop.Icon: set to the absolute resources path, bypassing the
  hicolor theme lookup and its size constraints entirely
- icon-manager.ts: on Linux production use process.resourcesPath so
  BrowserWindow receives a real filesystem path the WM can read directly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: use linux.desktop.entry for custom desktop Icon field

electron-builder v26 rejects arbitrary keys in linux.desktop — the
correct schema wraps custom .desktop overrides inside desktop.entry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: set desktop name on Linux so taskbar uses the correct app icon

Without app.setDesktopName(), the window manager cannot associate the
running Electron process with automaker.desktop. GNOME/KDE fall back to
_NET_WM_ICON which defaults to Electron's own bundled icon.

Calling app.setDesktopName('automaker.desktop') before any window is
created sets the _GTK_APPLICATION_ID hint and XDG app_id so the WM
picks up the desktop entry's Icon for the taskbar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix: memory and context views mobile friendly (#818)

* Changes from fix/memory-and-context-mobile-friendly

* fix: Improve file extension detection and add path traversal protection

* refactor: Extract file extension utilities and add path traversal guards

Code review improvements:
- Extract isMarkdownFilename and isImageFilename to shared image-utils.ts
- Remove duplicated code from context-view.tsx and memory-view.tsx
- Add path traversal guard for context fixture utilities (matching memory)
- Add 7 new tests for context fixture path traversal protection
- Total 61 tests pass

Addresses code review feedback from PR #813

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

* test: Add e2e tests for profiles crud and board background persistence

* Update apps/ui/playwright.config.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: Add robust test navigation handling and file filtering

* fix: Format NODE_OPTIONS configuration on single line

* test: Update profiles and board background persistence tests

* test: Replace iPhone 13 Pro with Pixel 5 for mobile test consistency

* Update apps/ui/src/components/views/context-view.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* chore: Remove test project directory

* feat: Filter context files by type and improve mobile menu visibility

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: Improve test reliability and localhost handling

* chore: Use explicit TEST_USE_EXTERNAL_BACKEND env var for server cleanup

* feat: Add E2E/CI mock mode for provider factory and auth verification

* feat: Add remoteBranch parameter to pull and rebase operations

* chore: Enhance E2E testing setup with worker isolation and auth state management

- Updated .gitignore to include worker-specific test fixtures.
- Modified e2e-tests.yml to implement test sharding for improved CI performance.
- Refactored global setup to authenticate once and save session state for reuse across tests.
- Introduced worker-isolated fixture paths to prevent conflicts during parallel test execution.
- Improved test navigation and loading handling for better reliability.
- Updated various test files to utilize new auth state management and fixture paths.

* fix: Update Playwright configuration and improve test reliability

- Increased the number of workers in Playwright configuration for better parallelism in CI environments.
- Enhanced the board background persistence test to ensure dropdown stability by waiting for the list to populate before interaction, improving test reliability.

* chore: Simplify E2E test configuration and enhance mock implementations

- Updated e2e-tests.yml to run tests in a single shard for streamlined CI execution.
- Enhanced unit tests for worktree list handling by introducing a mock for execGitCommand, improving test reliability and coverage.
- Refactored setup functions to better manage command mocks for git operations in tests.
- Improved error handling in mkdirSafe function to account for undefined stats in certain environments.

* refactor: Improve test configurations and enhance error handling

- Updated Playwright configuration to clear VITE_SERVER_URL, ensuring the frontend uses the Vite proxy and preventing cookie domain mismatches.
- Enhanced MergeRebaseDialog logic to normalize selectedBranch for better handling of various ref formats.
- Improved global setup with a more robust backend health check, throwing an error if the backend is not healthy after retries.
- Refactored project creation tests to handle file existence checks more reliably.
- Added error handling for missing E2E source fixtures to guide setup process.
- Enhanced memory navigation to handle sandbox dialog visibility more effectively.

* refactor: Enhance Git command execution and improve test configurations

- Updated Git command execution to merge environment paths correctly, ensuring proper command execution context.
- Refactored the Git initialization process to handle errors more gracefully and ensure user configuration is set before creating the initial commit.
- Improved test configurations by updating Playwright test identifiers for better clarity and consistency across different project states.
- Enhanced cleanup functions in tests to handle directory removal more robustly, preventing errors during test execution.

* fix: Resolve React hooks errors from duplicate instances in dependency tree

* style: Format alias configuration for improved readability

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: DhanushSantosh <dhanushsantoshs05@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-27 17:03:29 -08:00
gsxdsm
70d400793b Fix: memory and context views mobile friendly (#818)
* Changes from fix/memory-and-context-mobile-friendly

* fix: Improve file extension detection and add path traversal protection

* refactor: Extract file extension utilities and add path traversal guards

Code review improvements:
- Extract isMarkdownFilename and isImageFilename to shared image-utils.ts
- Remove duplicated code from context-view.tsx and memory-view.tsx
- Add path traversal guard for context fixture utilities (matching memory)
- Add 7 new tests for context fixture path traversal protection
- Total 61 tests pass

Addresses code review feedback from PR #813

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

* test: Add e2e tests for profiles crud and board background persistence

* Update apps/ui/playwright.config.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: Add robust test navigation handling and file filtering

* fix: Format NODE_OPTIONS configuration on single line

* test: Update profiles and board background persistence tests

* test: Replace iPhone 13 Pro with Pixel 5 for mobile test consistency

* Update apps/ui/src/components/views/context-view.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* chore: Remove test project directory

* feat: Filter context files by type and improve mobile menu visibility

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-26 08:37:33 -08:00
DhanushSantosh
dd7654c254 fix: set desktop name on Linux so taskbar uses the correct app icon
Without app.setDesktopName(), the window manager cannot associate the
running Electron process with automaker.desktop. GNOME/KDE fall back to
_NET_WM_ICON which defaults to Electron's own bundled icon.

Calling app.setDesktopName('automaker.desktop') before any window is
created sets the _GTK_APPLICATION_ID hint and XDG app_id so the WM
picks up the desktop entry's Icon for the taskbar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 18:14:08 +05:30
DhanushSantosh
6a824a9ff0 fix: use linux.desktop.entry for custom desktop Icon field
electron-builder v26 rejects arbitrary keys in linux.desktop — the
correct schema wraps custom .desktop overrides inside desktop.entry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 17:53:15 +05:30
DhanushSantosh
5e15f4120c Merge remote-tracking branch 'upstream/v1.0.0rc' into patchcraft 2026-02-26 17:49:45 +05:30
DhanushSantosh
82e9396cb8 fix: use absolute icon path and place icon outside asar on Linux
The hicolor icon theme index only lists sizes up to 512x512, so an icon
installed only at 1024x1024 is invisible to GNOME/KDE's theme resolver,
causing both the app launcher and taskbar to show a generic icon.
Additionally, BrowserWindow.icon cannot be read by the window manager
when the file is inside app.asar.

- extraResources: copy logo_larger.png to resources/ (outside asar) so
  it lands at /opt/Automaker/resources/logo_larger.png on install
- linux.desktop.Icon: set to the absolute resources path, bypassing the
  hicolor theme lookup and its size constraints entirely
- icon-manager.ts: on Linux production use process.resourcesPath so
  BrowserWindow receives a real filesystem path the WM can read directly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 17:49:26 +05:30
gsxdsm
9747faf1b9 Fix agent output summary for pipeline steps (#812)
* Changes from fix/agent-output-summary-for-pipeline-steps

* feat: Optimize pipeline summary extraction and fix regex vulnerability

* fix: Use fallback summary for pipeline steps when extraction fails

* fix: Strip follow-up session scaffold from pipeline step fallback summaries
2026-02-25 22:13:38 -08:00
DhanushSantosh
70c9fd77f6 fix: correct production icon path and refresh icon cache on RPM install
- icon-manager.ts: fix production path from '../dist/public' to '../dist'
  Vite copies public/ assets to the root of dist/, not dist/public/, so
  the old path caused electronAppExists() to return null in packaged
  builds — falling through to Electron's default icon in the taskbar
- rpm-after-install.sh: add gtk-update-icon-cache and
  update-desktop-database so GNOME/KDE picks up the installed icon and
  desktop entry immediately without a session restart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 11:20:03 +05:30
DhanushSantosh
46ee34d499 fix: resolve desktop entry launch failure and hide Electron menu bar on Linux
- Add --ozone-platform-hint=auto before app.whenReady() so Electron
  auto-detects X11 vs Wayland when launched from a desktop entry where
  the display protocol is not guaranteed (fixes immediate crash on
  Fedora/GNOME Wayland sessions)
- Add autoHideMenuBar: true to BrowserWindow so the default Chromium
  File/Edit/View/Help menu bar is hidden by default (still accessible
  via Alt); removes visible Electron significance from the packaged app
- Add scripts/rpm-after-install.sh and wire it via afterInstall in the
  electron-builder RPM config to chmod 4755 chrome-sandbox, ensuring
  the setuid sandbox works on kernels with restricted user namespaces

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 10:53:58 +05:30
Web Dev Cody
6408f514a4 Merge pull request #810 from AutoMaker-Org/v0.15.0rc
V0.15.0rc
2026-02-25 08:34:55 -05:00
Patrick Patel
6b97219f55 fix: Add dev-server:url-detected to EventType (#808)
* fix: Add dev-server:url-detected to EventType

The dev-server-service emits this event when a dev server URL is
detected from output; the type was missing from the EventType union
and caused a TypeScript build error.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Update libs/types/src/event.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: gsxdsm <gsxdsm@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-24 20:25:38 -08:00
Patrick Patel
09a4d3f15a fix: Resolve Claude-compatible provider for backlog plan when client sends model (#809)
When the Plan dialog sends a model (e.g. MiniMax-M2.1 from phase
settings), the server now:

- Calls getProviderByModelId() so the correct provider config
  (baseUrl, credentials) is used for backlog plan generation.
- Falls back to getPhaseModelWithOverrides('backlogPlanningModel')
  when model lookup finds no provider, so the phase's provider is
  used when the model matches.
- Uses a plain system prompt instead of the claude_code preset when
  a Claude-compatible provider is set; the preset is for native
  Claude CLI and can break requests to MiniMax/GLM APIs.

Previously the request was sent to the default Anthropic endpoint
and/or used the preset, causing plan generation to fail for
MiniMax/GLM users.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 20:21:05 -08:00
gsxdsm
51e9a23ba1 Fix agent output validation to prevent false verified status (#807)
* Changes from fix/cursor-fix

* feat: Enhance provider error messages with diagnostic context, address test failure, fix port change, move playwright tests to different port

* Update apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* ci: Update test server port from 3008 to 3108 and add environment configuration

* fix: Correct typo in health endpoint URL and standardize port env vars

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-24 20:18:40 -08:00
gsxdsm
0330c70261 Feature: worktree view customization and stability fixes (#805)
* Changes from feature/worktree-view-customization

* Feature: Git sync, set-tracking, and push divergence handling (#796)

* Add quick-add feature with improved workflows (#802)

* Changes from feature/quick-add

* feat: Clarify system prompt and improve error handling across services. Address PR Feedback

* feat: Improve PR description parsing and refactor event handling

* feat: Add context options to pipeline orchestrator initialization

* fix: Deduplicate React and handle CJS interop for use-sync-external-store

Resolve "Cannot read properties of null (reading 'useState')" errors by
deduplicating React/react-dom and ensuring use-sync-external-store is
bundled together with React to prevent CJS packages from resolving to
different React instances.

* Changes from feature/worktree-view-customization

* refactor: Remove unused worktree swap and highlight props

* refactor: Consolidate feature completion logic and improve thinking level defaults

* feat: Increase max turn limit to 10000

- Update DEFAULT_MAX_TURNS from 1000 to 10000 in settings-helpers.ts and agent-executor.ts
- Update MAX_ALLOWED_TURNS from 2000 to 10000 in settings-helpers.ts
- Update UI clamping logic from 2000 to 10000 in app-store.ts
- Update fallback values from 1000 to 10000 in use-settings-sync.ts
- Update default value from 1000 to 10000 in DEFAULT_GLOBAL_SETTINGS
- Update documentation to reflect new range: 1-10000

Allows agents to perform up to 10000 turns for complex feature execution.

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

* feat: Add model resolution, improve session handling, and enhance UI stability

* refactor: Remove unused sync and tracking branch props from worktree components

* feat: Add PR number update functionality to worktrees. Address pr feedback

* feat: Optimize Gemini CLI startup and add tool result tracking

* refactor: Improve error handling and simplify worktree task cleanup

---------

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-23 20:31:25 -08:00
gsxdsm
e7504b247f Add quick-add feature with improved workflows (#802)
* Changes from feature/quick-add

* feat: Clarify system prompt and improve error handling across services. Address PR Feedback

* feat: Improve PR description parsing and refactor event handling

* feat: Add context options to pipeline orchestrator initialization

* fix: Deduplicate React and handle CJS interop for use-sync-external-store

Resolve "Cannot read properties of null (reading 'useState')" errors by
deduplicating React/react-dom and ensuring use-sync-external-store is
bundled together with React to prevent CJS packages from resolving to
different React instances.
2026-02-22 20:48:09 -08:00
gsxdsm
9305ecc242 Fix: Restore views properly, model selection for commit and pr and speed up some cli models with session resume (#801)
* Changes from fix/restoring-view

* feat: Add resume query safety checks and optimize store selectors

* feat: Improve session management and model normalization

* refactor: Extract prompt building logic and handle file path parsing for renames
2026-02-22 10:45:45 -08:00
gsxdsm
2f071a1ba3 Fix deleting worktree crash and improve UX (#798)
* Changes from fix/deleting-worktree

* fix: Improve worktree deletion safety and branch cleanup logic

* fix: Improve error handling and async operations across auto-mode and worktree services

* Update apps/server/src/routes/auto-mode/routes/analyze-project.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-22 00:58:00 -08:00
gsxdsm
1d732916f1 Fix event hooks not persisting across server syncs (#799)
* Changes from fix/event-hook-persistence

* feat: Add explicit permission escape hatch for clearing eventHooks and improve error handling in UI
2026-02-22 00:36:08 -08:00
gsxdsm
629fd24d9f Improve pull request prompt and generation handling (#800)
* Changes from fix/improve-pull-request-prompt

* Update apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-22 00:27:39 -08:00
gsxdsm
72cb942788 Fix Codex CLI timeout handling and improve CI workflows (#797)
* Changes from fix/codex-cli-timeout

* test: Clarify timeout values and multipliers in codex-provider tests

* refactor: Rename useWorktreesEnabled to worktreesEnabled for clarity
2026-02-21 23:58:09 -08:00
gsxdsm
91bff21d58 Feature: Git sync, set-tracking, and push divergence handling (#796) 2026-02-21 18:54:16 -08:00
gsxdsm
dfa719079f Changes from fix/manual-crash (#795) 2026-02-21 17:32:34 -08:00
gsxdsm
28becb177b Fix Docker Compose CORS issues with nginx API proxying (#793)
* Changes from fix/docker-compose-cors-error

* Update apps/server/src/index.ts

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Fix: Delete Worktree Crash + PR Comments + Dev Server UX Improvements (#792)

* Changes from fix/delete-worktree-hotifx

* fix: Improve bot detection and prevent UI overflow issues

- Include GitHub app-initiated comments in bot detection
- Wrap handleQuickCreateSession with useCallback to fix dependency issues
- Truncate long branch names in agent header to prevent layout overflow

* feat: Support GitHub App comments in PR review and fix session filtering

* feat: Return invalidation result from delete session handler

* fix: Improve CORS origin validation to handle wildcard correctly

* fix: Correct IPv6 localhost parsing and improve responsive UI layouts

* Changes from fix/pwa-cache-fix (#794)

* fix: Add type checking to prevent crashes from malformed cache entries

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-21 13:56:48 -08:00
gsxdsm
f785f1204b Changes from fix/pwa-cache-fix (#794) 2026-02-21 12:45:18 -08:00
gsxdsm
f3edfbf24e Fix: Delete Worktree Crash + PR Comments + Dev Server UX Improvements (#792)
* Changes from fix/delete-worktree-hotifx

* fix: Improve bot detection and prevent UI overflow issues

- Include GitHub app-initiated comments in bot detection
- Wrap handleQuickCreateSession with useCallback to fix dependency issues
- Truncate long branch names in agent header to prevent layout overflow

* feat: Support GitHub App comments in PR review and fix session filtering

* feat: Return invalidation result from delete session handler
2026-02-21 11:07:16 -08:00
gsxdsm
3ddf26f666 Fix: Dev server detection bug fixes. Settings sync bug fixes. Cli provider fixes. Terminal background/foreground colors (#791)
* Changes from fix/dev-server-state-bug

* feat: Add configurable max turns setting with user overrides. Address pr comments

* fix: Update default behaviors and improve state management across server and UI

* feat: Extract branch sync logic to separate service. Fix settings sync bug. Address pr comments

* refactor: Extract magic numbers to named constants and improve branch tracking logic

- Add DEFAULT_MAX_TURNS (1000) and MAX_ALLOWED_TURNS (2000) constants to settings-helpers
- Replace hardcoded 1000 values with DEFAULT_MAX_TURNS constant throughout codebase
- Improve max turns validation with explicit Number.isFinite check
- Update getTrackingBranch to split on first slash instead of last for better remote parsing
- Change isBranchCheckedOut return type from boolean to string|null to return worktree path
- Add comments explaining skipFetch parameter in worktree creation
- Fix cleanup order in AgentExecutor finally block to run before logging
```

* feat: Add comment refresh and improve model sync in PR dialog
2026-02-21 08:57:04 -08:00
gsxdsm
c81ea768a7 Feature: Add PR review comments and resolution, improve AI prompt handling (#790)
* feat: Add PR review comments and resolution endpoints, improve prompt handling

* Feature: File Editor (#789)

* feat: Add file management feature

* feat: Add auto-save functionality to file editor

* fix: Replace HardDriveDownload icon with Save icon for consistency

* fix: Prevent recursive copy/move and improve shell injection prevention

* refactor: Extract editor settings form into separate component

* ```
fix: Improve error handling and stabilize async operations

- Add error event handlers to GraphQL process spawns to prevent unhandled rejections
- Replace execAsync with execFile for safer command execution and better control
- Fix timeout cleanup in withTimeout generator to prevent memory leaks
- Improve outdated comment detection logic by removing redundant condition
- Use resolveModelString for consistent model string handling
- Replace || with ?? for proper falsy value handling in dialog initialization
- Add comments clarifying branch name resolution logic for local branches with slashes
- Add catch handler for project selection to handle async errors gracefully
```

* refactor: Extract PR review comments logic to dedicated service

* fix: Improve robustness and UX for PR review and file operations

* fix: Consolidate exec utilities and improve type safety

* refactor: Replace ScrollArea with div and improve file tree layout
2026-02-20 21:34:40 -08:00
439 changed files with 51670 additions and 5006 deletions

14
.geminiignore Normal file
View File

@@ -0,0 +1,14 @@
# Auto-generated by Automaker to speed up Gemini CLI startup
# Prevents Gemini CLI from scanning large directories during context discovery
.git
node_modules
dist
build
.next
.nuxt
coverage
.automaker
.worktrees
.vscode
.idea
*.lock

View File

@@ -13,6 +13,13 @@ jobs:
e2e: e2e:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
strategy:
fail-fast: false
matrix:
# shardIndex: [1, 2, 3]
# shardTotal: [3]
shardIndex: [1]
shardTotal: [1]
steps: steps:
- name: Checkout code - name: Checkout code
@@ -46,7 +53,8 @@ jobs:
echo "SERVER_PID=$SERVER_PID" >> $GITHUB_ENV echo "SERVER_PID=$SERVER_PID" >> $GITHUB_ENV
env: env:
PORT: 3008 PORT: 3108
TEST_SERVER_PORT: 3108
NODE_ENV: test NODE_ENV: test
# Use a deterministic API key so Playwright can log in reliably # Use a deterministic API key so Playwright can log in reliably
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
@@ -81,13 +89,13 @@ jobs:
# Wait for health endpoint # Wait for health endpoint
for i in {1..60}; do for i in {1..60}; do
if curl -s -f http://localhost:3008/api/health > /dev/null 2>&1; then if curl -s -f http://localhost:3108/api/health > /dev/null 2>&1; then
echo "Backend server is ready!" echo "Backend server is ready!"
echo "=== Backend logs ===" echo "=== Backend logs ==="
cat backend.log cat backend.log
echo "" echo ""
echo "Health check response:" echo "Health check response:"
curl -s http://localhost:3008/api/health | jq . 2>/dev/null || echo "Health check: $(curl -s http://localhost:3008/api/health 2>/dev/null || echo 'No response')" curl -s http://localhost:3108/api/health | jq . 2>/dev/null || echo "Health check: $(curl -s http://localhost:3108/api/health 2>/dev/null || echo 'No response')"
exit 0 exit 0
fi fi
@@ -111,11 +119,11 @@ jobs:
ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found" ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
echo "" echo ""
echo "=== Port status ===" echo "=== Port status ==="
netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening" netstat -tlnp 2>/dev/null | grep :3108 || echo "Port 3108 not listening"
lsof -i :3008 2>/dev/null || echo "lsof not available or port not in use" lsof -i :3108 2>/dev/null || echo "lsof not available or port not in use"
echo "" echo ""
echo "=== Health endpoint test ===" echo "=== Health endpoint test ==="
curl -v http://localhost:3008/api/health 2>&1 || echo "Health endpoint failed" curl -v http://localhost:3108/api/health 2>&1 || echo "Health endpoint failed"
# Kill the server process if it's still hanging # Kill the server process if it's still hanging
if kill -0 $SERVER_PID 2>/dev/null; then if kill -0 $SERVER_PID 2>/dev/null; then
@@ -126,17 +134,23 @@ jobs:
exit 1 exit 1
- name: Run E2E tests - name: Run E2E tests (shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
# Playwright automatically starts the Vite frontend via webServer config # Playwright automatically starts the Vite frontend via webServer config
# (see apps/ui/playwright.config.ts) - no need to start it manually # (see apps/ui/playwright.config.ts) - no need to start it manually
run: npm run test --workspace=apps/ui run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
working-directory: apps/ui
env: env:
CI: true CI: true
VITE_SERVER_URL: http://localhost:3008
SERVER_URL: http://localhost:3008
VITE_SKIP_SETUP: 'true' VITE_SKIP_SETUP: 'true'
# Keep UI-side login/defaults consistent # Keep UI-side login/defaults consistent
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
# Backend is already started above - Playwright config sets
# AUTOMAKER_SERVER_PORT so the Vite proxy forwards /api/* to the backend.
# Do NOT set VITE_SERVER_URL here: it bypasses the Vite proxy and causes
# a cookie domain mismatch (cookies are bound to 127.0.0.1, but
# VITE_SERVER_URL=http://localhost:3108 makes the frontend call localhost).
TEST_USE_EXTERNAL_BACKEND: 'true'
TEST_SERVER_PORT: 3108
- name: Print backend logs on failure - name: Print backend logs on failure
if: failure() if: failure()
@@ -148,13 +162,13 @@ jobs:
ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found" ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
echo "" echo ""
echo "=== Port status ===" echo "=== Port status ==="
netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening" netstat -tlnp 2>/dev/null | grep :3108 || echo "Port 3108 not listening"
- name: Upload Playwright report - name: Upload Playwright report
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
if: always() if: always()
with: with:
name: playwright-report name: playwright-report-shard-${{ matrix.shardIndex }}-of-${{ matrix.shardTotal }}
path: apps/ui/playwright-report/ path: apps/ui/playwright-report/
retention-days: 7 retention-days: 7
@@ -162,12 +176,21 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
if: always() if: always()
with: with:
name: test-results name: test-results-shard-${{ matrix.shardIndex }}-of-${{ matrix.shardTotal }}
path: | path: |
apps/ui/test-results/ apps/ui/test-results/
retention-days: 7 retention-days: 7
if-no-files-found: ignore if-no-files-found: ignore
- name: Upload blob report for merging
uses: actions/upload-artifact@v4
if: always()
with:
name: blob-report-shard-${{ matrix.shardIndex }}-of-${{ matrix.shardTotal }}
path: apps/ui/blob-report/
retention-days: 1
if-no-files-found: ignore
- name: Cleanup - Kill backend server - name: Cleanup - Kill backend server
if: always() if: always()
run: | run: |

12
.gitignore vendored
View File

@@ -65,6 +65,17 @@ coverage/
*.lcov *.lcov
playwright-report/ playwright-report/
blob-report/ blob-report/
test/**/test-project-[0-9]*/
test/opus-thinking-*/
test/agent-session-test-*/
test/feature-backlog-test-*/
test/running-task-display-test-*/
test/agent-output-modal-responsive-*/
test/fixtures/
test/board-bg-test-*/
test/edit-feature-test-*/
test/open-project-test-*/
# Environment files (keep .example) # Environment files (keep .example)
.env .env
@@ -102,3 +113,4 @@ data/
.planning/ .planning/
.mcp.json .mcp.json
.planning .planning
.bg-shell/

View File

@@ -209,9 +209,10 @@ COPY libs ./libs
COPY apps/ui ./apps/ui COPY apps/ui ./apps/ui
# Build packages in dependency order, then build UI # Build packages in dependency order, then build UI
# VITE_SERVER_URL tells the UI where to find the API server # When VITE_SERVER_URL is empty, the UI uses relative URLs (e.g., /api/...) which nginx proxies
# Use ARG to allow overriding at build time: --build-arg VITE_SERVER_URL=http://api.example.com # to the server container. This avoids CORS issues entirely in Docker Compose setups.
ARG VITE_SERVER_URL=http://localhost:3008 # Override at build time if needed: --build-arg VITE_SERVER_URL=http://api.example.com
ARG VITE_SERVER_URL=
ENV VITE_SKIP_ELECTRON=true ENV VITE_SKIP_ELECTRON=true
ENV VITE_SERVER_URL=${VITE_SERVER_URL} ENV VITE_SERVER_URL=${VITE_SERVER_URL}
RUN npm run build:packages && npm run build --workspace=apps/ui RUN npm run build:packages && npm run build --workspace=apps/ui

View File

@@ -52,6 +52,12 @@ HOST=0.0.0.0
# Port to run the server on # Port to run the server on
PORT=3008 PORT=3008
# Port to run the server on for testing
TEST_SERVER_PORT=3108
# Port to run the UI on for testing
TEST_PORT=3107
# Data directory for sessions and metadata # Data directory for sessions and metadata
DATA_DIR=./data DATA_DIR=./data

View File

@@ -1,6 +1,6 @@
{ {
"name": "@automaker/server", "name": "@automaker/server",
"version": "0.13.0", "version": "1.0.0",
"description": "Backend server for Automaker - provides API for both web and Electron modes", "description": "Backend server for Automaker - provides API for both web and Electron modes",
"author": "AutoMaker Team", "author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE", "license": "SEE LICENSE IN LICENSE",
@@ -32,7 +32,7 @@
"@automaker/prompts": "1.0.0", "@automaker/prompts": "1.0.0",
"@automaker/types": "1.0.0", "@automaker/types": "1.0.0",
"@automaker/utils": "1.0.0", "@automaker/utils": "1.0.0",
"@github/copilot-sdk": "^0.1.16", "@github/copilot-sdk": "0.1.16",
"@modelcontextprotocol/sdk": "1.25.2", "@modelcontextprotocol/sdk": "1.25.2",
"@openai/codex-sdk": "^0.98.0", "@openai/codex-sdk": "^0.98.0",
"cookie-parser": "1.4.7", "cookie-parser": "1.4.7",

View File

@@ -261,12 +261,35 @@ morgan.token('status-colored', (_req, res) => {
app.use( app.use(
morgan(':method :url :status-colored', { morgan(':method :url :status-colored', {
// Skip when request logging is disabled or for health check endpoints // Skip when request logging is disabled or for health check endpoints
skip: (req) => !requestLoggingEnabled || req.url === '/api/health', skip: (req) =>
!requestLoggingEnabled ||
req.url === '/api/health' ||
req.url === '/api/auto-mode/context-exists',
}) })
); );
// CORS configuration // CORS configuration
// When using credentials (cookies), origin cannot be '*' // When using credentials (cookies), origin cannot be '*'
// We dynamically allow the requesting origin for local development // We dynamically allow the requesting origin for local development
// Check if origin is a local/private network address
function isLocalOrigin(origin: string): boolean {
try {
const url = new URL(origin);
const hostname = url.hostname;
return (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '[::1]' ||
hostname === '0.0.0.0' ||
hostname.startsWith('192.168.') ||
hostname.startsWith('10.') ||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname)
);
} catch {
return false;
}
}
app.use( app.use(
cors({ cors({
origin: (origin, callback) => { origin: (origin, callback) => {
@@ -277,36 +300,26 @@ app.use(
} }
// If CORS_ORIGIN is set, use it (can be comma-separated list) // If CORS_ORIGIN is set, use it (can be comma-separated list)
const allowedOrigins = process.env.CORS_ORIGIN?.split(',').map((o) => o.trim()); const allowedOrigins = process.env.CORS_ORIGIN?.split(',')
if (allowedOrigins && allowedOrigins.length > 0 && allowedOrigins[0] !== '*') { .map((o) => o.trim())
.filter(Boolean);
if (allowedOrigins && allowedOrigins.length > 0) {
if (allowedOrigins.includes('*')) {
callback(null, true);
return;
}
if (allowedOrigins.includes(origin)) { if (allowedOrigins.includes(origin)) {
callback(null, origin); callback(null, origin);
} else {
callback(new Error('Not allowed by CORS'));
}
return; return;
} }
// Fall through to local network check below
}
// For local development, allow all localhost/loopback origins (any port) // Allow all localhost/loopback/private network origins (any port)
try { if (isLocalOrigin(origin)) {
const url = new URL(origin);
const hostname = url.hostname;
if (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1' ||
hostname === '0.0.0.0' ||
hostname.startsWith('192.168.') ||
hostname.startsWith('10.') ||
hostname.startsWith('172.')
) {
callback(null, origin); callback(null, origin);
return; return;
} }
} catch {
// Ignore URL parsing errors
}
// Reject other origins by default for security // Reject other origins by default for security
callback(new Error('Not allowed by CORS')); callback(new Error('Not allowed by CORS'));
@@ -339,7 +352,9 @@ const ideationService = new IdeationService(events, settingsService, featureLoad
// Initialize DevServerService with event emitter for real-time log streaming // Initialize DevServerService with event emitter for real-time log streaming
const devServerService = getDevServerService(); const devServerService = getDevServerService();
devServerService.setEventEmitter(events); devServerService.initialize(DATA_DIR, events).catch((err) => {
logger.error('Failed to initialize DevServerService:', err);
});
// Initialize Notification Service with event emitter for real-time updates // Initialize Notification Service with event emitter for real-time updates
const notificationService = getNotificationService(); const notificationService = getNotificationService();
@@ -424,11 +439,9 @@ eventHookService.initialize(events, settingsService, eventHistoryService, featur
logger.info('[STARTUP] Feature state reconciliation complete - no stale states found'); logger.info('[STARTUP] Feature state reconciliation complete - no stale states found');
} }
// Resume interrupted features in the background after reconciliation. // Resume interrupted features in the background for all projects.
// This uses the saved execution state to identify features that were running // This handles features stuck in transient states (in_progress, pipeline_*)
// before the restart (their statuses have been reset to ready/backlog by // or explicitly marked as interrupted. Running in background so it doesn't block startup.
// reconciliation above). Running in background so it doesn't block startup.
if (totalReconciled > 0) {
for (const project of globalSettings.projects) { for (const project of globalSettings.projects) {
autoModeService.resumeInterruptedFeatures(project.path).catch((err) => { autoModeService.resumeInterruptedFeatures(project.path).catch((err) => {
logger.warn( logger.warn(
@@ -439,7 +452,6 @@ eventHookService.initialize(events, settingsService, eventHistoryService, featur
} }
logger.info('[STARTUP] Initiated background resume of interrupted features'); logger.info('[STARTUP] Initiated background resume of interrupted features');
} }
}
} catch (err) { } catch (err) {
logger.warn('[STARTUP] Failed to reconcile feature states:', err); logger.warn('[STARTUP] Failed to reconcile feature states:', err);
} }
@@ -484,7 +496,7 @@ app.use(
); );
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService)); app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService)); app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
app.use('/api/worktree', createWorktreeRoutes(events, settingsService)); app.use('/api/worktree', createWorktreeRoutes(events, settingsService, featureLoader));
app.use('/api/git', createGitRoutes()); app.use('/api/git', createGitRoutes());
app.use('/api/models', createModelsRoutes()); app.use('/api/models', createModelsRoutes());
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService)); app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));
@@ -586,24 +598,23 @@ wss.on('connection', (ws: WebSocket) => {
// Subscribe to all events and forward to this client // Subscribe to all events and forward to this client
const unsubscribe = events.subscribe((type, payload) => { const unsubscribe = events.subscribe((type, payload) => {
logger.info('Event received:', { // Use debug level for high-frequency events to avoid log spam
// that causes progressive memory growth and server slowdown
const isHighFrequency =
type === 'dev-server:output' || type === 'test-runner:output' || type === 'feature:progress';
const log = isHighFrequency ? logger.debug.bind(logger) : logger.info.bind(logger);
log('Event received:', {
type, type,
hasPayload: !!payload, hasPayload: !!payload,
payloadKeys: payload ? Object.keys(payload) : [],
wsReadyState: ws.readyState, wsReadyState: ws.readyState,
wsOpen: ws.readyState === WebSocket.OPEN,
}); });
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
const message = JSON.stringify({ type, payload }); const message = JSON.stringify({ type, payload });
logger.info('Sending event to client:', {
type,
messageLength: message.length,
sessionId: (payload as Record<string, unknown>)?.sessionId,
});
ws.send(message); ws.send(message);
} else { } else {
logger.info('WARNING: Cannot send event, WebSocket not open. ReadyState:', ws.readyState); logger.warn('Cannot send event, WebSocket not open. ReadyState:', ws.readyState);
} }
}); });

View File

@@ -0,0 +1,37 @@
/**
* Shared execution utilities
*
* Common helpers for spawning child processes with the correct environment.
* Used by both route handlers and service layers.
*/
import { createLogger } from '@automaker/utils';
const logger = createLogger('ExecUtils');
// Extended PATH to include common tool installation locations
export const extendedPath = [
process.env.PATH,
'/opt/homebrew/bin',
'/usr/local/bin',
'/home/linuxbrew/.linuxbrew/bin',
`${process.env.HOME}/.local/bin`,
]
.filter(Boolean)
.join(':');
export const execEnv = {
...process.env,
PATH: extendedPath,
};
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
export function logError(error: unknown, context: string): void {
logger.error(`${context}:`, error);
}

View File

@@ -13,6 +13,27 @@ import { createLogger } from '@automaker/utils';
const logger = createLogger('GitLib'); const logger = createLogger('GitLib');
// Extended PATH so git is found when the process does not inherit a full shell PATH
// (e.g. Electron, some CI, or IDE-launched processes).
const pathSeparator = process.platform === 'win32' ? ';' : ':';
const extraPaths: string[] =
process.platform === 'win32'
? ([
process.env.LOCALAPPDATA && `${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`,
process.env.PROGRAMFILES && `${process.env.PROGRAMFILES}\\Git\\cmd`,
process.env['ProgramFiles(x86)'] && `${process.env['ProgramFiles(x86)']}\\Git\\cmd`,
].filter(Boolean) as string[])
: [
'/opt/homebrew/bin',
'/usr/local/bin',
'/usr/bin',
'/home/linuxbrew/.linuxbrew/bin',
process.env.HOME ? `${process.env.HOME}/.local/bin` : '',
].filter(Boolean);
const extendedPath = [process.env.PATH, ...extraPaths].filter(Boolean).join(pathSeparator);
const gitEnv = { ...process.env, PATH: extendedPath };
// ============================================================================ // ============================================================================
// Secure Command Execution // Secure Command Execution
// ============================================================================ // ============================================================================
@@ -65,7 +86,14 @@ export async function execGitCommand(
command: 'git', command: 'git',
args, args,
cwd, cwd,
...(env !== undefined ? { env } : {}), env:
env !== undefined
? {
...gitEnv,
...env,
PATH: [gitEnv.PATH, env.PATH].filter(Boolean).join(pathSeparator),
}
: gitEnv,
...(abortController !== undefined ? { abortController } : {}), ...(abortController !== undefined ? { abortController } : {}),
}); });

View File

@@ -133,12 +133,16 @@ export const TOOL_PRESETS = {
'Read', 'Read',
'Write', 'Write',
'Edit', 'Edit',
'MultiEdit',
'Glob', 'Glob',
'Grep', 'Grep',
'LS',
'Bash', 'Bash',
'WebSearch', 'WebSearch',
'WebFetch', 'WebFetch',
'TodoWrite', 'TodoWrite',
'Task',
'Skill',
] as const, ] as const,
/** Tools for chat/interactive mode */ /** Tools for chat/interactive mode */
@@ -146,12 +150,16 @@ export const TOOL_PRESETS = {
'Read', 'Read',
'Write', 'Write',
'Edit', 'Edit',
'MultiEdit',
'Glob', 'Glob',
'Grep', 'Grep',
'LS',
'Bash', 'Bash',
'WebSearch', 'WebSearch',
'WebFetch', 'WebFetch',
'TodoWrite', 'TodoWrite',
'Task',
'Skill',
] as const, ] as const,
} as const; } as const;
@@ -282,11 +290,15 @@ function buildThinkingOptions(thinkingLevel?: ThinkingLevel): Partial<Options> {
} }
/** /**
* Build system prompt configuration based on autoLoadClaudeMd setting. * Build system prompt and settingSources based on two independent settings:
* When autoLoadClaudeMd is true: * - useClaudeCodeSystemPrompt: controls whether to use the 'claude_code' preset as the base prompt
* - Uses preset mode with 'claude_code' to enable CLAUDE.md auto-loading * - autoLoadClaudeMd: controls whether to add settingSources for SDK to load CLAUDE.md files
* - If there's a custom systemPrompt, appends it to the preset *
* - Sets settingSources to ['project'] for SDK to load CLAUDE.md files * These combine independently (4 possible states):
* 1. Both ON: preset + settingSources (full Claude Code experience)
* 2. useClaudeCodeSystemPrompt ON, autoLoadClaudeMd OFF: preset only (no CLAUDE.md auto-loading)
* 3. useClaudeCodeSystemPrompt OFF, autoLoadClaudeMd ON: plain string + settingSources
* 4. Both OFF: plain string only
* *
* @param config - The SDK options config * @param config - The SDK options config
* @returns Object with systemPrompt and settingSources for SDK options * @returns Object with systemPrompt and settingSources for SDK options
@@ -295,27 +307,34 @@ function buildClaudeMdOptions(config: CreateSdkOptionsConfig): {
systemPrompt?: string | SystemPromptConfig; systemPrompt?: string | SystemPromptConfig;
settingSources?: Array<'user' | 'project' | 'local'>; settingSources?: Array<'user' | 'project' | 'local'>;
} { } {
if (!config.autoLoadClaudeMd) {
// Standard mode - just pass through the system prompt as-is
return config.systemPrompt ? { systemPrompt: config.systemPrompt } : {};
}
// Auto-load CLAUDE.md mode - use preset with settingSources
const result: { const result: {
systemPrompt: SystemPromptConfig; systemPrompt?: string | SystemPromptConfig;
settingSources: Array<'user' | 'project' | 'local'>; settingSources?: Array<'user' | 'project' | 'local'>;
} = { } = {};
systemPrompt: {
// Determine system prompt format based on useClaudeCodeSystemPrompt
if (config.useClaudeCodeSystemPrompt) {
// Use Claude Code's built-in system prompt as the base
const presetConfig: SystemPromptConfig = {
type: 'preset', type: 'preset',
preset: 'claude_code', preset: 'claude_code',
},
// Load both user (~/.claude/CLAUDE.md) and project (.claude/CLAUDE.md) settings
settingSources: ['user', 'project'],
}; };
// If there's a custom system prompt, append it to the preset // If there's a custom system prompt, append it to the preset
if (config.systemPrompt) { if (config.systemPrompt) {
result.systemPrompt.append = config.systemPrompt; presetConfig.append = config.systemPrompt;
}
result.systemPrompt = presetConfig;
} else {
// Standard mode - just pass through the system prompt as-is
if (config.systemPrompt) {
result.systemPrompt = config.systemPrompt;
}
}
// Determine settingSources based on autoLoadClaudeMd
if (config.autoLoadClaudeMd) {
// Load both user (~/.claude/CLAUDE.md) and project (.claude/CLAUDE.md) settings
result.settingSources = ['user', 'project'];
} }
return result; return result;
@@ -323,12 +342,14 @@ function buildClaudeMdOptions(config: CreateSdkOptionsConfig): {
/** /**
* System prompt configuration for SDK options * System prompt configuration for SDK options
* When using preset mode with claude_code, CLAUDE.md files are automatically loaded * The 'claude_code' preset provides the system prompt only — it does NOT auto-load
* CLAUDE.md files. CLAUDE.md auto-loading is controlled independently by
* settingSources (set via autoLoadClaudeMd). These two settings are orthogonal.
*/ */
export interface SystemPromptConfig { export interface SystemPromptConfig {
/** Use preset mode with claude_code to enable CLAUDE.md auto-loading */ /** Use preset mode to select the base system prompt */
type: 'preset'; type: 'preset';
/** The preset to use - 'claude_code' enables CLAUDE.md loading */ /** The preset to use - 'claude_code' uses the Claude Code system prompt */
preset: 'claude_code'; preset: 'claude_code';
/** Optional additional prompt to append to the preset */ /** Optional additional prompt to append to the preset */
append?: string; append?: string;
@@ -362,11 +383,19 @@ export interface CreateSdkOptionsConfig {
/** Enable auto-loading of CLAUDE.md files via SDK's settingSources */ /** Enable auto-loading of CLAUDE.md files via SDK's settingSources */
autoLoadClaudeMd?: boolean; autoLoadClaudeMd?: boolean;
/** Use Claude Code's built-in system prompt (claude_code preset) as the base prompt */
useClaudeCodeSystemPrompt?: boolean;
/** MCP servers to make available to the agent */ /** MCP servers to make available to the agent */
mcpServers?: Record<string, McpServerConfig>; mcpServers?: Record<string, McpServerConfig>;
/** Extended thinking level for Claude models */ /** Extended thinking level for Claude models */
thinkingLevel?: ThinkingLevel; thinkingLevel?: ThinkingLevel;
/** Optional user-configured max turns override (from settings).
* When provided, overrides the preset MAX_TURNS for the use case.
* Range: 1-2000. */
maxTurns?: number;
} }
// Re-export MCP types from @automaker/types for convenience // Re-export MCP types from @automaker/types for convenience
@@ -403,7 +432,7 @@ export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Opt
// See: https://github.com/AutoMaker-Org/automaker/issues/149 // See: https://github.com/AutoMaker-Org/automaker/issues/149
permissionMode: 'default', permissionMode: 'default',
model: getModelForUseCase('spec', config.model), model: getModelForUseCase('spec', config.model),
maxTurns: MAX_TURNS.maximum, maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
cwd: config.cwd, cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.specGeneration], allowedTools: [...TOOL_PRESETS.specGeneration],
...claudeMdOptions, ...claudeMdOptions,
@@ -437,7 +466,7 @@ export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig):
// Override permissionMode - feature generation only needs read-only tools // Override permissionMode - feature generation only needs read-only tools
permissionMode: 'default', permissionMode: 'default',
model: getModelForUseCase('features', config.model), model: getModelForUseCase('features', config.model),
maxTurns: MAX_TURNS.quick, maxTurns: config.maxTurns ?? MAX_TURNS.quick,
cwd: config.cwd, cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.readOnly], allowedTools: [...TOOL_PRESETS.readOnly],
...claudeMdOptions, ...claudeMdOptions,
@@ -468,7 +497,7 @@ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Option
return { return {
...getBaseOptions(), ...getBaseOptions(),
model: getModelForUseCase('suggestions', config.model), model: getModelForUseCase('suggestions', config.model),
maxTurns: MAX_TURNS.extended, maxTurns: config.maxTurns ?? MAX_TURNS.extended,
cwd: config.cwd, cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.readOnly], allowedTools: [...TOOL_PRESETS.readOnly],
...claudeMdOptions, ...claudeMdOptions,
@@ -506,7 +535,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
return { return {
...getBaseOptions(), ...getBaseOptions(),
model: getModelForUseCase('chat', effectiveModel), model: getModelForUseCase('chat', effectiveModel),
maxTurns: MAX_TURNS.standard, maxTurns: config.maxTurns ?? MAX_TURNS.standard,
cwd: config.cwd, cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.chat], allowedTools: [...TOOL_PRESETS.chat],
...claudeMdOptions, ...claudeMdOptions,
@@ -541,7 +570,7 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
return { return {
...getBaseOptions(), ...getBaseOptions(),
model: getModelForUseCase('auto', config.model), model: getModelForUseCase('auto', config.model),
maxTurns: MAX_TURNS.maximum, maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
cwd: config.cwd, cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.fullAccess], allowedTools: [...TOOL_PRESETS.fullAccess],
...claudeMdOptions, ...claudeMdOptions,

View File

@@ -33,9 +33,16 @@ import {
const logger = createLogger('SettingsHelper'); const logger = createLogger('SettingsHelper');
/** Default number of agent turns used when no value is configured. */
export const DEFAULT_MAX_TURNS = 10000;
/** Upper bound for the max-turns clamp; values above this are capped here. */
export const MAX_ALLOWED_TURNS = 10000;
/** /**
* Get the autoLoadClaudeMd setting, with project settings taking precedence over global. * Get the autoLoadClaudeMd setting, with project settings taking precedence over global.
* Returns false if settings service is not available. * Falls back to global settings and defaults to true when unset.
* Returns true if settings service is not available.
* *
* @param projectPath - Path to the project * @param projectPath - Path to the project
* @param settingsService - Optional settings service instance * @param settingsService - Optional settings service instance
@@ -48,8 +55,8 @@ export async function getAutoLoadClaudeMdSetting(
logPrefix = '[SettingsHelper]' logPrefix = '[SettingsHelper]'
): Promise<boolean> { ): Promise<boolean> {
if (!settingsService) { if (!settingsService) {
logger.info(`${logPrefix} SettingsService not available, autoLoadClaudeMd disabled`); logger.info(`${logPrefix} SettingsService not available, autoLoadClaudeMd defaulting to true`);
return false; return true;
} }
try { try {
@@ -64,7 +71,7 @@ export async function getAutoLoadClaudeMdSetting(
// Fall back to global settings // Fall back to global settings
const globalSettings = await settingsService.getGlobalSettings(); const globalSettings = await settingsService.getGlobalSettings();
const result = globalSettings.autoLoadClaudeMd ?? false; const result = globalSettings.autoLoadClaudeMd ?? true;
logger.info(`${logPrefix} autoLoadClaudeMd from global settings: ${result}`); logger.info(`${logPrefix} autoLoadClaudeMd from global settings: ${result}`);
return result; return result;
} catch (error) { } catch (error) {
@@ -73,6 +80,84 @@ export async function getAutoLoadClaudeMdSetting(
} }
} }
/**
* Get the useClaudeCodeSystemPrompt setting, with project settings taking precedence over global.
* Falls back to global settings and defaults to true when unset.
* Returns true if settings service is not available.
*
* @param projectPath - Path to the project
* @param settingsService - Optional settings service instance
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
* @returns Promise resolving to the useClaudeCodeSystemPrompt setting value
*/
export async function getUseClaudeCodeSystemPromptSetting(
projectPath: string,
settingsService?: SettingsService | null,
logPrefix = '[SettingsHelper]'
): Promise<boolean> {
if (!settingsService) {
logger.info(
`${logPrefix} SettingsService not available, useClaudeCodeSystemPrompt defaulting to true`
);
return true;
}
try {
// Check project settings first (takes precedence)
const projectSettings = await settingsService.getProjectSettings(projectPath);
if (projectSettings.useClaudeCodeSystemPrompt !== undefined) {
logger.info(
`${logPrefix} useClaudeCodeSystemPrompt from project settings: ${projectSettings.useClaudeCodeSystemPrompt}`
);
return projectSettings.useClaudeCodeSystemPrompt;
}
// Fall back to global settings
const globalSettings = await settingsService.getGlobalSettings();
const result = globalSettings.useClaudeCodeSystemPrompt ?? true;
logger.info(`${logPrefix} useClaudeCodeSystemPrompt from global settings: ${result}`);
return result;
} catch (error) {
logger.error(`${logPrefix} Failed to load useClaudeCodeSystemPrompt setting:`, error);
throw error;
}
}
/**
* Get the default max turns setting from global settings.
*
* Reads the user's configured `defaultMaxTurns` setting, which controls the maximum
* number of agent turns (tool-call round-trips) for feature execution.
*
* @param settingsService - Settings service instance (may be null)
* @param logPrefix - Logging prefix for debugging
* @returns The user's configured max turns, or {@link DEFAULT_MAX_TURNS} as default
*/
export async function getDefaultMaxTurnsSetting(
settingsService?: SettingsService | null,
logPrefix = '[SettingsHelper]'
): Promise<number> {
if (!settingsService) {
logger.info(
`${logPrefix} SettingsService not available, using default maxTurns=${DEFAULT_MAX_TURNS}`
);
return DEFAULT_MAX_TURNS;
}
try {
const globalSettings = await settingsService.getGlobalSettings();
const raw = globalSettings.defaultMaxTurns;
const result = Number.isFinite(raw) ? (raw as number) : DEFAULT_MAX_TURNS;
// Clamp to valid range
const clamped = Math.max(1, Math.min(MAX_ALLOWED_TURNS, Math.floor(result)));
logger.debug(`${logPrefix} defaultMaxTurns from global settings: ${clamped}`);
return clamped;
} catch (error) {
logger.error(`${logPrefix} Failed to load defaultMaxTurns setting:`, error);
return DEFAULT_MAX_TURNS;
}
}
/** /**
* Filters out CLAUDE.md from context files when autoLoadClaudeMd is enabled * Filters out CLAUDE.md from context files when autoLoadClaudeMd is enabled
* and rebuilds the formatted prompt without it. * and rebuilds the formatted prompt without it.
@@ -604,6 +689,145 @@ export interface ProviderByModelIdResult {
resolvedModel: string | undefined; resolvedModel: string | undefined;
} }
/** Result from resolveProviderContext */
export interface ProviderContextResult {
/** The provider configuration */
provider: ClaudeCompatibleProvider | undefined;
/** Credentials for API key resolution */
credentials: Credentials | undefined;
/** The resolved Claude model ID for SDK configuration */
resolvedModel: string | undefined;
/** The original model config from the provider if found */
modelConfig: import('@automaker/types').ProviderModel | undefined;
}
/**
* Checks if a provider is enabled.
* Providers with enabled: undefined are treated as enabled (default state).
* Only explicitly set enabled: false means the provider is disabled.
*/
function isProviderEnabled(provider: ClaudeCompatibleProvider): boolean {
return provider.enabled !== false;
}
/**
* Finds a model config in a provider's models array by ID (case-insensitive).
*/
function findModelInProvider(
provider: ClaudeCompatibleProvider,
modelId: string
): import('@automaker/types').ProviderModel | undefined {
return provider.models?.find(
(m) => m.id === modelId || m.id.toLowerCase() === modelId.toLowerCase()
);
}
/**
* Resolves the provider and Claude-compatible model configuration.
*
* This is the central logic for resolving provider context, supporting:
* 1. Explicit lookup by providerId (most reliable for persistence)
* 2. Fallback lookup by modelId across all enabled providers
* 3. Resolution of mapsToClaudeModel for SDK configuration
*
* @param settingsService - Settings service instance
* @param modelId - The model ID to resolve
* @param providerId - Optional explicit provider ID
* @param logPrefix - Prefix for log messages
* @returns Promise resolving to the provider context
*/
export async function resolveProviderContext(
settingsService: SettingsService,
modelId: string,
providerId?: string,
logPrefix = '[SettingsHelper]'
): Promise<ProviderContextResult> {
try {
const globalSettings = await settingsService.getGlobalSettings();
const credentials = await settingsService.getCredentials();
const providers = globalSettings.claudeCompatibleProviders || [];
logger.debug(
`${logPrefix} Resolving provider context: modelId="${modelId}", providerId="${providerId ?? 'none'}", providers count=${providers.length}`
);
let provider: ClaudeCompatibleProvider | undefined;
let modelConfig: import('@automaker/types').ProviderModel | undefined;
// 1. Try resolving by explicit providerId first (most reliable)
if (providerId) {
provider = providers.find((p) => p.id === providerId);
if (provider) {
if (!isProviderEnabled(provider)) {
logger.warn(
`${logPrefix} Explicitly requested provider "${provider.name}" (${providerId}) is disabled (enabled=${provider.enabled})`
);
} else {
logger.debug(
`${logPrefix} Found provider "${provider.name}" (${providerId}), enabled=${provider.enabled ?? 'undefined (treated as enabled)'}`
);
// Find the model config within this provider to check for mappings
modelConfig = findModelInProvider(provider, modelId);
if (!modelConfig && provider.models && provider.models.length > 0) {
logger.debug(
`${logPrefix} Model "${modelId}" not found in provider "${provider.name}". Available models: ${provider.models.map((m) => m.id).join(', ')}`
);
}
}
} else {
logger.warn(
`${logPrefix} Explicitly requested provider "${providerId}" not found. Available providers: ${providers.map((p) => p.id).join(', ')}`
);
}
}
// 2. Fallback to model-based lookup across all providers if modelConfig not found
// Note: We still search even if provider was found, to get the modelConfig for mapping
if (!modelConfig) {
for (const p of providers) {
if (!isProviderEnabled(p) || p.id === providerId) continue; // Skip disabled or already checked
const config = findModelInProvider(p, modelId);
if (config) {
// Only override provider if we didn't find one by explicit ID
if (!provider) {
provider = p;
}
modelConfig = config;
logger.debug(`${logPrefix} Found model "${modelId}" in provider "${p.name}" (fallback)`);
break;
}
}
}
// 3. Resolve the mapped Claude model if specified
let resolvedModel: string | undefined;
if (modelConfig?.mapsToClaudeModel) {
const { resolveModelString } = await import('@automaker/model-resolver');
resolvedModel = resolveModelString(modelConfig.mapsToClaudeModel);
logger.debug(
`${logPrefix} Model "${modelId}" maps to Claude model "${modelConfig.mapsToClaudeModel}" -> "${resolvedModel}"`
);
}
// Log final result for debugging
logger.debug(
`${logPrefix} Provider context resolved: provider=${provider?.name ?? 'none'}, modelConfig=${modelConfig ? 'found' : 'not found'}, resolvedModel=${resolvedModel ?? modelId}`
);
return { provider, credentials, resolvedModel, modelConfig };
} catch (error) {
logger.error(`${logPrefix} Failed to resolve provider context:`, error);
return {
provider: undefined,
credentials: undefined,
resolvedModel: undefined,
modelConfig: undefined,
};
}
}
/** /**
* Find a ClaudeCompatibleProvider by one of its model IDs. * Find a ClaudeCompatibleProvider by one of its model IDs.
* Searches through all enabled providers to find one that contains the specified model. * Searches through all enabled providers to find one that contains the specified model.

View File

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

View File

@@ -33,8 +33,23 @@ const logger = createLogger('ClaudeProvider');
*/ */
type ProviderConfig = ClaudeApiProfile | ClaudeCompatibleProvider; type ProviderConfig = ClaudeApiProfile | ClaudeCompatibleProvider;
// System vars are always passed from process.env regardless of profile // System vars are always passed from process.env regardless of profile.
const SYSTEM_ENV_VARS = ['PATH', 'HOME', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL']; // Includes filesystem, locale, and temp directory vars that the Claude CLI
// needs internally for config resolution and temp file creation.
const SYSTEM_ENV_VARS = [
'PATH',
'HOME',
'SHELL',
'TERM',
'USER',
'LANG',
'LC_ALL',
'TMPDIR',
'XDG_CONFIG_HOME',
'XDG_DATA_HOME',
'XDG_CACHE_HOME',
'XDG_STATE_HOME',
];
/** /**
* Check if the config is a ClaudeCompatibleProvider (new system) * Check if the config is a ClaudeCompatibleProvider (new system)
@@ -173,6 +188,7 @@ export class ClaudeProvider extends BaseProvider {
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> { async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
// Validate that model doesn't have a provider prefix // Validate that model doesn't have a provider prefix
// AgentService should strip prefixes before passing to providers // AgentService should strip prefixes before passing to providers
// Claude doesn't use a provider prefix, so we don't need to specify an expected provider
validateBareModelId(options.model, 'ClaudeProvider'); validateBareModelId(options.model, 'ClaudeProvider');
const { const {
@@ -180,7 +196,7 @@ export class ClaudeProvider extends BaseProvider {
model, model,
cwd, cwd,
systemPrompt, systemPrompt,
maxTurns = 100, maxTurns = 1000,
allowedTools, allowedTools,
abortController, abortController,
conversationHistory, conversationHistory,
@@ -213,6 +229,8 @@ export class ClaudeProvider extends BaseProvider {
env: buildEnv(providerConfig, credentials), env: buildEnv(providerConfig, credentials),
// Pass through allowedTools if provided by caller (decided by sdk-options.ts) // Pass through allowedTools if provided by caller (decided by sdk-options.ts)
...(allowedTools && { allowedTools }), ...(allowedTools && { allowedTools }),
// Restrict available built-in tools if specified (tools: [] disables all tools)
...(options.tools && { tools: options.tools }),
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation // AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
permissionMode: 'bypassPermissions', permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true, allowDangerouslySkipPermissions: true,

View File

@@ -33,7 +33,6 @@ import {
supportsReasoningEffort, supportsReasoningEffort,
validateBareModelId, validateBareModelId,
calculateReasoningTimeout, calculateReasoningTimeout,
DEFAULT_TIMEOUT_MS,
type CodexApprovalPolicy, type CodexApprovalPolicy,
type CodexSandboxMode, type CodexSandboxMode,
type CodexAuthStatus, type CodexAuthStatus,
@@ -52,6 +51,7 @@ import { CODEX_MODELS } from './codex-models.js';
const CODEX_COMMAND = 'codex'; const CODEX_COMMAND = 'codex';
const CODEX_EXEC_SUBCOMMAND = 'exec'; const CODEX_EXEC_SUBCOMMAND = 'exec';
const CODEX_RESUME_SUBCOMMAND = 'resume';
const CODEX_JSON_FLAG = '--json'; const CODEX_JSON_FLAG = '--json';
const CODEX_MODEL_FLAG = '--model'; const CODEX_MODEL_FLAG = '--model';
const CODEX_VERSION_FLAG = '--version'; const CODEX_VERSION_FLAG = '--version';
@@ -98,7 +98,7 @@ const TEXT_ENCODING = 'utf-8';
* *
* @see calculateReasoningTimeout from @automaker/types * @see calculateReasoningTimeout from @automaker/types
*/ */
const CODEX_CLI_TIMEOUT_MS = DEFAULT_TIMEOUT_MS; const CODEX_CLI_TIMEOUT_MS = 120000; // 2 minutes — matches CLI provider base timeout
const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000; // 5 minutes for feature generation const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000; // 5 minutes for feature generation
const SYSTEM_PROMPT_SEPARATOR = '\n\n'; const SYSTEM_PROMPT_SEPARATOR = '\n\n';
const CODEX_INSTRUCTIONS_DIR = '.codex'; const CODEX_INSTRUCTIONS_DIR = '.codex';
@@ -127,11 +127,16 @@ const DEFAULT_ALLOWED_TOOLS = [
'Read', 'Read',
'Write', 'Write',
'Edit', 'Edit',
'MultiEdit',
'Glob', 'Glob',
'Grep', 'Grep',
'LS',
'Bash', 'Bash',
'WebSearch', 'WebSearch',
'WebFetch', 'WebFetch',
'TodoWrite',
'Task',
'Skill',
] as const; ] as const;
const SEARCH_TOOL_NAMES = new Set(['WebSearch', 'WebFetch']); const SEARCH_TOOL_NAMES = new Set(['WebSearch', 'WebFetch']);
const MIN_MAX_TURNS = 1; const MIN_MAX_TURNS = 1;
@@ -356,9 +361,14 @@ function resolveSystemPrompt(systemPrompt?: unknown): string | null {
return null; return null;
} }
function buildPromptText(options: ExecuteOptions): string {
return typeof options.prompt === 'string'
? options.prompt
: extractTextFromContent(options.prompt);
}
function buildCombinedPrompt(options: ExecuteOptions, systemPromptText?: string | null): string { function buildCombinedPrompt(options: ExecuteOptions, systemPromptText?: string | null): string {
const promptText = const promptText = buildPromptText(options);
typeof options.prompt === 'string' ? options.prompt : extractTextFromContent(options.prompt);
const historyText = options.conversationHistory const historyText = options.conversationHistory
? formatHistoryAsText(options.conversationHistory) ? formatHistoryAsText(options.conversationHistory)
: ''; : '';
@@ -371,6 +381,11 @@ function buildCombinedPrompt(options: ExecuteOptions, systemPromptText?: string
return `${historyText}${systemSection}${HISTORY_HEADER}${promptText}`; return `${historyText}${systemSection}${HISTORY_HEADER}${promptText}`;
} }
function buildResumePrompt(options: ExecuteOptions): string {
const promptText = buildPromptText(options);
return `${HISTORY_HEADER}${promptText}`;
}
function formatConfigValue(value: string | number | boolean): string { function formatConfigValue(value: string | number | boolean): string {
return String(value); return String(value);
} }
@@ -724,9 +739,9 @@ export class CodexProvider extends BaseProvider {
} }
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> { async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
// Validate that model doesn't have a provider prefix // Validate that model doesn't have a provider prefix (except codex- which should already be stripped)
// AgentService should strip prefixes before passing to providers // AgentService should strip prefixes before passing to providers
validateBareModelId(options.model, 'CodexProvider'); validateBareModelId(options.model, 'CodexProvider', 'codex');
try { try {
const mcpServers = options.mcpServers ?? {}; const mcpServers = options.mcpServers ?? {};
@@ -738,6 +753,16 @@ export class CodexProvider extends BaseProvider {
); );
const baseSystemPrompt = resolveSystemPrompt(options.systemPrompt); const baseSystemPrompt = resolveSystemPrompt(options.systemPrompt);
const resolvedMaxTurns = resolveMaxTurns(options.maxTurns); const resolvedMaxTurns = resolveMaxTurns(options.maxTurns);
if (resolvedMaxTurns === null && options.maxTurns === undefined) {
logger.warn(
`[executeQuery] maxTurns not provided — Codex CLI will use its internal default. ` +
`This may cause premature completion. Model: ${options.model}`
);
} else {
logger.info(
`[executeQuery] maxTurns: requested=${options.maxTurns}, resolved=${resolvedMaxTurns}, model=${options.model}`
);
}
const resolvedAllowedTools = options.allowedTools ?? Array.from(DEFAULT_ALLOWED_TOOLS); const resolvedAllowedTools = options.allowedTools ?? Array.from(DEFAULT_ALLOWED_TOOLS);
const restrictTools = !hasMcpServers || options.mcpUnrestrictedTools === false; const restrictTools = !hasMcpServers || options.mcpUnrestrictedTools === false;
const wantsOutputSchema = Boolean( const wantsOutputSchema = Boolean(
@@ -784,16 +809,22 @@ export class CodexProvider extends BaseProvider {
} }
const searchEnabled = const searchEnabled =
codexSettings.enableWebSearch || resolveSearchEnabled(resolvedAllowedTools, restrictTools); codexSettings.enableWebSearch || resolveSearchEnabled(resolvedAllowedTools, restrictTools);
const schemaPath = await writeOutputSchemaFile(options.cwd, options.outputFormat); const isResumeQuery = Boolean(options.sdkSessionId);
const imageBlocks = codexSettings.enableImages ? extractImageBlocks(options.prompt) : []; const schemaPath = isResumeQuery
const imagePaths = await writeImageFiles(options.cwd, imageBlocks); ? null
: await writeOutputSchemaFile(options.cwd, options.outputFormat);
const imageBlocks =
!isResumeQuery && codexSettings.enableImages ? extractImageBlocks(options.prompt) : [];
const imagePaths = isResumeQuery ? [] : await writeImageFiles(options.cwd, imageBlocks);
const approvalPolicy = const approvalPolicy =
hasMcpServers && options.mcpAutoApproveTools !== undefined hasMcpServers && options.mcpAutoApproveTools !== undefined
? options.mcpAutoApproveTools ? options.mcpAutoApproveTools
? 'never' ? 'never'
: 'on-request' : 'on-request'
: codexSettings.approvalPolicy; : codexSettings.approvalPolicy;
const promptText = buildCombinedPrompt(options, combinedSystemPrompt); const promptText = isResumeQuery
? buildResumePrompt(options)
: buildCombinedPrompt(options, combinedSystemPrompt);
const commandPath = executionPlan.cliPath || CODEX_COMMAND; const commandPath = executionPlan.cliPath || CODEX_COMMAND;
// Build config overrides for max turns and reasoning effort // Build config overrides for max turns and reasoning effort
@@ -823,21 +854,30 @@ export class CodexProvider extends BaseProvider {
const preExecArgs: string[] = []; const preExecArgs: string[] = [];
// Add additional directories with write access // Add additional directories with write access
if (codexSettings.additionalDirs && codexSettings.additionalDirs.length > 0) { if (
!isResumeQuery &&
codexSettings.additionalDirs &&
codexSettings.additionalDirs.length > 0
) {
for (const dir of codexSettings.additionalDirs) { for (const dir of codexSettings.additionalDirs) {
preExecArgs.push(CODEX_ADD_DIR_FLAG, dir); preExecArgs.push(CODEX_ADD_DIR_FLAG, dir);
} }
} }
// If images were written to disk, add the image directory so the CLI can access them // If images were written to disk, add the image directory so the CLI can access them.
// Note: imagePaths is set to [] when isResumeQuery is true, so this check is sufficient.
if (imagePaths.length > 0) { if (imagePaths.length > 0) {
const imageDir = path.join(options.cwd, CODEX_INSTRUCTIONS_DIR, IMAGE_TEMP_DIR); const imageDir = path.join(options.cwd, CODEX_INSTRUCTIONS_DIR, IMAGE_TEMP_DIR);
preExecArgs.push(CODEX_ADD_DIR_FLAG, imageDir); preExecArgs.push(CODEX_ADD_DIR_FLAG, imageDir);
} }
// Model is already bare (no prefix) - validated by executeQuery // Model is already bare (no prefix) - validated by executeQuery
const codexCommand = isResumeQuery
? [CODEX_EXEC_SUBCOMMAND, CODEX_RESUME_SUBCOMMAND]
: [CODEX_EXEC_SUBCOMMAND];
const args = [ const args = [
CODEX_EXEC_SUBCOMMAND, ...codexCommand,
CODEX_YOLO_FLAG, CODEX_YOLO_FLAG,
CODEX_SKIP_GIT_REPO_CHECK_FLAG, CODEX_SKIP_GIT_REPO_CHECK_FLAG,
...preExecArgs, ...preExecArgs,
@@ -846,6 +886,7 @@ export class CodexProvider extends BaseProvider {
CODEX_JSON_FLAG, CODEX_JSON_FLAG,
...configOverrideArgs, ...configOverrideArgs,
...(schemaPath ? [CODEX_OUTPUT_SCHEMA_FLAG, schemaPath] : []), ...(schemaPath ? [CODEX_OUTPUT_SCHEMA_FLAG, schemaPath] : []),
...(options.sdkSessionId ? [options.sdkSessionId] : []),
'-', // Read prompt from stdin to avoid shell escaping issues '-', // Read prompt from stdin to avoid shell escaping issues
]; ];

View File

@@ -30,6 +30,7 @@ import {
type CopilotRuntimeModel, type CopilotRuntimeModel,
} from '@automaker/types'; } from '@automaker/types';
import { createLogger, isAbortError } from '@automaker/utils'; import { createLogger, isAbortError } from '@automaker/utils';
import { resolveModelString } from '@automaker/model-resolver';
import { CopilotClient, type PermissionRequest } from '@github/copilot-sdk'; import { CopilotClient, type PermissionRequest } from '@github/copilot-sdk';
import { import {
normalizeTodos, normalizeTodos,
@@ -75,13 +76,18 @@ interface SdkToolExecutionStartEvent extends SdkEvent {
}; };
} }
interface SdkToolExecutionEndEvent extends SdkEvent { interface SdkToolExecutionCompleteEvent extends SdkEvent {
type: 'tool.execution_end'; type: 'tool.execution_complete';
data: { data: {
toolName: string;
toolCallId: string; toolCallId: string;
result?: string; success: boolean;
error?: string; result?: {
content: string;
};
error?: {
message: string;
code?: string;
};
}; };
} }
@@ -93,6 +99,16 @@ interface SdkSessionErrorEvent extends SdkEvent {
}; };
} }
// =============================================================================
// Constants
// =============================================================================
/**
* Prefix for error messages in tool results
* Consistent with GeminiProvider's error formatting
*/
const TOOL_ERROR_PREFIX = '[ERROR]' as const;
// ============================================================================= // =============================================================================
// Error Codes // Error Codes
// ============================================================================= // =============================================================================
@@ -116,6 +132,12 @@ export interface CopilotError extends Error {
suggestion?: string; suggestion?: string;
} }
type CopilotSession = Awaited<ReturnType<CopilotClient['createSession']>>;
type CopilotSessionOptions = Parameters<CopilotClient['createSession']>[0];
type ResumableCopilotClient = CopilotClient & {
resumeSession?: (sessionId: string, options: CopilotSessionOptions) => Promise<CopilotSession>;
};
// ============================================================================= // =============================================================================
// Tool Name Normalization // Tool Name Normalization
// ============================================================================= // =============================================================================
@@ -350,12 +372,19 @@ export class CopilotProvider extends CliProvider {
}; };
} }
case 'tool.execution_end': { /**
const toolResultEvent = sdkEvent as SdkToolExecutionEndEvent; * Tool execution completed event
const isError = !!toolResultEvent.data.error; * Handles both successful results and errors from tool executions
const content = isError * Error messages optionally include error codes for better debugging
? `[ERROR] ${toolResultEvent.data.error}` */
: toolResultEvent.data.result || ''; case 'tool.execution_complete': {
const toolResultEvent = sdkEvent as SdkToolExecutionCompleteEvent;
const error = toolResultEvent.data.error;
// Format error message with optional code for better debugging
const content = error
? `${TOOL_ERROR_PREFIX} ${error.message}${error.code ? ` (${error.code})` : ''}`
: toolResultEvent.data.result?.content || '';
return { return {
type: 'assistant', type: 'assistant',
@@ -382,9 +411,14 @@ export class CopilotProvider extends CliProvider {
case 'session.error': { case 'session.error': {
const errorEvent = sdkEvent as SdkSessionErrorEvent; const errorEvent = sdkEvent as SdkSessionErrorEvent;
const enrichedError =
errorEvent.data.message ||
(errorEvent.data.code
? `Copilot agent error (code: ${errorEvent.data.code})`
: 'Copilot agent error');
return { return {
type: 'error', type: 'error',
error: errorEvent.data.message || 'Unknown error', error: enrichedError,
}; };
} }
@@ -516,7 +550,11 @@ export class CopilotProvider extends CliProvider {
} }
const promptText = this.extractPromptText(options); const promptText = this.extractPromptText(options);
const bareModel = options.model || DEFAULT_BARE_MODEL; // resolveModelString may return dash-separated canonical names (e.g. "claude-sonnet-4-6"),
// but the Copilot SDK expects dot-separated version suffixes (e.g. "claude-sonnet-4.6").
// Normalize by converting the last dash-separated numeric pair to dot notation.
const resolvedModel = resolveModelString(options.model || DEFAULT_BARE_MODEL);
const bareModel = resolvedModel.replace(/-(\d+)-(\d+)$/, '-$1.$2');
const workingDirectory = options.cwd || process.cwd(); const workingDirectory = options.cwd || process.cwd();
logger.debug( logger.debug(
@@ -554,12 +592,14 @@ export class CopilotProvider extends CliProvider {
}); });
}; };
// Declare session outside try so it's accessible in the catch block for cleanup.
let session: CopilotSession | undefined;
try { try {
await client.start(); await client.start();
logger.debug(`CopilotClient started with cwd: ${workingDirectory}`); logger.debug(`CopilotClient started with cwd: ${workingDirectory}`);
// Create session with streaming enabled for real-time events const sessionOptions: CopilotSessionOptions = {
const session = await client.createSession({
model: bareModel, model: bareModel,
streaming: true, streaming: true,
// AUTONOMOUS MODE: Auto-approve all permission requests. // AUTONOMOUS MODE: Auto-approve all permission requests.
@@ -572,13 +612,33 @@ export class CopilotProvider extends CliProvider {
logger.debug(`Permission request: ${request.kind}`); logger.debug(`Permission request: ${request.kind}`);
return { kind: 'approved' }; return { kind: 'approved' };
}, },
}); };
const sessionId = session.sessionId; // Resume the previous Copilot session when possible; otherwise create a fresh one.
logger.debug(`Session created: ${sessionId}`); const resumableClient = client as ResumableCopilotClient;
let sessionResumed = false;
if (options.sdkSessionId && typeof resumableClient.resumeSession === 'function') {
try {
session = await resumableClient.resumeSession(options.sdkSessionId, sessionOptions);
sessionResumed = true;
logger.debug(`Resumed Copilot session: ${session.sessionId}`);
} catch (resumeError) {
logger.warn(
`Failed to resume Copilot session "${options.sdkSessionId}", creating a new session: ${resumeError}`
);
session = await client.createSession(sessionOptions);
}
} else {
session = await client.createSession(sessionOptions);
}
// session is always assigned by this point (both branches above assign it)
const activeSession = session!;
const sessionId = activeSession.sessionId;
logger.debug(`Session ${sessionResumed ? 'resumed' : 'created'}: ${sessionId}`);
// Set up event handler to push events to queue // Set up event handler to push events to queue
session.on((event: SdkEvent) => { activeSession.on((event: SdkEvent) => {
logger.debug(`SDK event: ${event.type}`); logger.debug(`SDK event: ${event.type}`);
if (event.type === 'session.idle') { if (event.type === 'session.idle') {
@@ -590,13 +650,13 @@ export class CopilotProvider extends CliProvider {
sessionComplete = true; sessionComplete = true;
pushEvent(event); pushEvent(event);
} else { } else {
// Push all other events (tool.execution_start, tool.execution_end, assistant.message, etc.) // Push all other events (tool.execution_start, tool.execution_complete, assistant.message, etc.)
pushEvent(event); pushEvent(event);
} }
}); });
// Send the prompt (non-blocking) // Send the prompt (non-blocking)
await session.send({ prompt: promptText }); await activeSession.send({ prompt: promptText });
// Process events as they arrive // Process events as they arrive
while (!sessionComplete || eventQueue.length > 0) { while (!sessionComplete || eventQueue.length > 0) {
@@ -604,7 +664,7 @@ export class CopilotProvider extends CliProvider {
// Check for errors first (before processing events to avoid race condition) // Check for errors first (before processing events to avoid race condition)
if (sessionError) { if (sessionError) {
await session.destroy(); await activeSession.destroy();
await client.stop(); await client.stop();
throw sessionError; throw sessionError;
} }
@@ -624,11 +684,19 @@ export class CopilotProvider extends CliProvider {
} }
// Cleanup // Cleanup
await session.destroy(); await activeSession.destroy();
await client.stop(); await client.stop();
logger.debug('CopilotClient stopped successfully'); logger.debug('CopilotClient stopped successfully');
} catch (error) { } catch (error) {
// Ensure client is stopped on error // Ensure session is destroyed and client is stopped on error to prevent leaks.
// The session may have been created/resumed before the error occurred.
if (session) {
try {
await session.destroy();
} catch (sessionCleanupError) {
logger.debug(`Failed to destroy session during cleanup: ${sessionCleanupError}`);
}
}
try { try {
await client.stop(); await client.stop();
} catch (cleanupError) { } catch (cleanupError) {

View File

@@ -450,6 +450,11 @@ export class CursorProvider extends CliProvider {
cliArgs.push('--model', model); cliArgs.push('--model', model);
} }
// Resume an existing chat when a provider session ID is available
if (options.sdkSessionId) {
cliArgs.push('--resume', options.sdkSessionId);
}
// Use '-' to indicate reading prompt from stdin // Use '-' to indicate reading prompt from stdin
cliArgs.push('-'); cliArgs.push('-');
@@ -557,10 +562,14 @@ export class CursorProvider extends CliProvider {
const resultEvent = cursorEvent as CursorResultEvent; const resultEvent = cursorEvent as CursorResultEvent;
if (resultEvent.is_error) { if (resultEvent.is_error) {
const errorText = resultEvent.error || resultEvent.result || '';
const enrichedError =
errorText ||
`Cursor agent failed (duration: ${resultEvent.duration_ms}ms, subtype: ${resultEvent.subtype}, session: ${resultEvent.session_id ?? 'none'})`;
return { return {
type: 'error', type: 'error',
session_id: resultEvent.session_id, session_id: resultEvent.session_id,
error: resultEvent.error || resultEvent.result || 'Unknown error', error: enrichedError,
}; };
} }
@@ -834,9 +843,10 @@ export class CursorProvider extends CliProvider {
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> { async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
this.ensureCliDetected(); this.ensureCliDetected();
// Validate that model doesn't have a provider prefix // Validate that model doesn't have a provider prefix (except cursor- which should already be stripped)
// AgentService should strip prefixes before passing to providers // AgentService should strip prefixes before passing to providers
validateBareModelId(options.model, 'CursorProvider'); // Note: Cursor's Gemini models (e.g., "gemini-3-pro") legitimately start with "gemini-"
validateBareModelId(options.model, 'CursorProvider', 'cursor');
if (!this.cliPath) { if (!this.cliPath) {
throw this.createError( throw this.createError(

View File

@@ -24,7 +24,7 @@ import type {
import { validateBareModelId } from '@automaker/types'; import { validateBareModelId } from '@automaker/types';
import { GEMINI_MODEL_MAP, type GeminiAuthStatus } from '@automaker/types'; import { GEMINI_MODEL_MAP, type GeminiAuthStatus } from '@automaker/types';
import { createLogger, isAbortError } from '@automaker/utils'; import { createLogger, isAbortError } from '@automaker/utils';
import { spawnJSONLProcess } from '@automaker/platform'; import { spawnJSONLProcess, type SubprocessOptions } from '@automaker/platform';
import { normalizeTodos } from './tool-normalization.js'; import { normalizeTodos } from './tool-normalization.js';
// Create logger for this module // Create logger for this module
@@ -263,6 +263,14 @@ export class GeminiProvider extends CliProvider {
// Use explicit approval-mode for clearer semantics // Use explicit approval-mode for clearer semantics
cliArgs.push('--approval-mode', 'yolo'); cliArgs.push('--approval-mode', 'yolo');
// Force headless (non-interactive) mode with --prompt flag.
// The actual prompt content is passed via stdin (see buildSubprocessOptions()),
// but we MUST include -p to trigger headless mode. Without it, Gemini CLI
// starts in interactive mode which adds significant startup overhead
// (interactive REPL setup, extra context loading, etc.).
// Per Gemini CLI docs: stdin content is "appended to" the -p value.
cliArgs.push('--prompt', '');
// Explicitly include the working directory in allowed workspace directories // Explicitly include the working directory in allowed workspace directories
// This ensures Gemini CLI allows file operations in the project directory, // This ensures Gemini CLI allows file operations in the project directory,
// even if it has a different workspace cached from a previous session // even if it has a different workspace cached from a previous session
@@ -270,13 +278,15 @@ export class GeminiProvider extends CliProvider {
cliArgs.push('--include-directories', options.cwd); cliArgs.push('--include-directories', options.cwd);
} }
// Resume an existing Gemini session when one is available
if (options.sdkSessionId) {
cliArgs.push('--resume', options.sdkSessionId);
}
// Note: Gemini CLI doesn't have a --thinking-level flag. // Note: Gemini CLI doesn't have a --thinking-level flag.
// Thinking capabilities are determined by the model selection (e.g., gemini-2.5-pro). // Thinking capabilities are determined by the model selection (e.g., gemini-2.5-pro).
// The model handles thinking internally based on the task complexity. // The model handles thinking internally based on the task complexity.
// The prompt will be passed as the last positional argument
// We'll append it in executeQuery after extracting the text
return cliArgs; return cliArgs;
} }
@@ -371,10 +381,13 @@ export class GeminiProvider extends CliProvider {
const resultEvent = geminiEvent as GeminiResultEvent; const resultEvent = geminiEvent as GeminiResultEvent;
if (resultEvent.status === 'error') { if (resultEvent.status === 'error') {
const enrichedError =
resultEvent.error ||
`Gemini agent failed (duration: ${resultEvent.stats?.duration_ms ?? 'unknown'}ms, session: ${resultEvent.session_id ?? 'none'})`;
return { return {
type: 'error', type: 'error',
session_id: resultEvent.session_id, session_id: resultEvent.session_id,
error: resultEvent.error || 'Unknown error', error: enrichedError,
}; };
} }
@@ -391,10 +404,12 @@ export class GeminiProvider extends CliProvider {
case 'error': { case 'error': {
const errorEvent = geminiEvent as GeminiResultEvent; const errorEvent = geminiEvent as GeminiResultEvent;
const enrichedError =
errorEvent.error || `Gemini agent failed (session: ${errorEvent.session_id ?? 'none'})`;
return { return {
type: 'error', type: 'error',
session_id: errorEvent.session_id, session_id: errorEvent.session_id,
error: errorEvent.error || 'Unknown error', error: enrichedError,
}; };
} }
@@ -408,6 +423,32 @@ export class GeminiProvider extends CliProvider {
// CliProvider Overrides // CliProvider Overrides
// ========================================================================== // ==========================================================================
/**
* Build subprocess options with stdin data for prompt and speed-optimized env vars.
*
* Passes the prompt via stdin instead of --prompt CLI arg to:
* - Avoid shell argument size limits with large prompts (system prompt + context)
* - Avoid shell escaping issues with special characters in prompts
* - Match the pattern used by Cursor, OpenCode, and Codex providers
*
* Also injects environment variables to reduce Gemini CLI startup overhead:
* - GEMINI_TELEMETRY_ENABLED=false: Disables OpenTelemetry collection
*/
protected buildSubprocessOptions(options: ExecuteOptions, cliArgs: string[]): SubprocessOptions {
const subprocessOptions = super.buildSubprocessOptions(options, cliArgs);
// Pass prompt via stdin to avoid shell interpretation of special characters
// and shell argument size limits with large system prompts + context files
subprocessOptions.stdinData = this.extractPromptText(options);
// Disable telemetry to reduce startup overhead
if (subprocessOptions.env) {
subprocessOptions.env['GEMINI_TELEMETRY_ENABLED'] = 'false';
}
return subprocessOptions;
}
/** /**
* Override error mapping for Gemini-specific error codes * Override error mapping for Gemini-specific error codes
*/ */
@@ -505,8 +546,8 @@ export class GeminiProvider extends CliProvider {
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> { async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
this.ensureCliDetected(); this.ensureCliDetected();
// Validate that model doesn't have a provider prefix // Validate that model doesn't have a provider prefix (except gemini- which should already be stripped)
validateBareModelId(options.model, 'GeminiProvider'); validateBareModelId(options.model, 'GeminiProvider', 'gemini');
if (!this.cliPath) { if (!this.cliPath) {
throw this.createError( throw this.createError(
@@ -517,14 +558,21 @@ export class GeminiProvider extends CliProvider {
); );
} }
// Extract prompt text to pass as positional argument // Ensure .geminiignore exists in the working directory to prevent Gemini CLI
const promptText = this.extractPromptText(options); // from scanning .git and node_modules directories during startup. This reduces
// startup time significantly (reported: 35s → 11s) by skipping large directories
// that Gemini CLI would otherwise traverse for context discovery.
await this.ensureGeminiIgnore(options.cwd || process.cwd());
// Build CLI args and append the prompt as the last positional argument // Embed system prompt into the user prompt so Gemini CLI receives
const cliArgs = this.buildCliArgs(options); // project context (CLAUDE.md, CODE_QUALITY.md, etc.) that would
cliArgs.push(promptText); // Gemini CLI uses positional args for the prompt // otherwise be silently dropped since Gemini CLI has no --system-prompt flag.
const effectiveOptions = this.embedSystemPromptIntoPrompt(options);
const subprocessOptions = this.buildSubprocessOptions(options, cliArgs); // Build CLI args for headless execution.
const cliArgs = this.buildCliArgs(effectiveOptions);
const subprocessOptions = this.buildSubprocessOptions(effectiveOptions, cliArgs);
let sessionId: string | undefined; let sessionId: string | undefined;
@@ -577,6 +625,49 @@ export class GeminiProvider extends CliProvider {
// Gemini-Specific Methods // Gemini-Specific Methods
// ========================================================================== // ==========================================================================
/**
* Ensure a .geminiignore file exists in the working directory.
*
* Gemini CLI scans the working directory for context discovery during startup.
* Excluding .git and node_modules dramatically reduces startup time by preventing
* traversal of large directories (reported improvement: 35s → 11s).
*
* Only creates the file if it doesn't already exist to avoid overwriting user config.
*/
private async ensureGeminiIgnore(cwd: string): Promise<void> {
const ignorePath = path.join(cwd, '.geminiignore');
const content = [
'# Auto-generated by Automaker to speed up Gemini CLI startup',
'# Prevents Gemini CLI from scanning large directories during context discovery',
'.git',
'node_modules',
'dist',
'build',
'.next',
'.nuxt',
'coverage',
'.automaker',
'.worktrees',
'.vscode',
'.idea',
'*.lock',
'',
].join('\n');
try {
// Use 'wx' flag for atomic creation - fails if file exists (EEXIST)
await fs.writeFile(ignorePath, content, { encoding: 'utf-8', flag: 'wx' });
logger.debug(`Created .geminiignore at ${ignorePath}`);
} catch (writeError) {
// EEXIST means file already exists - that's fine, preserve user's file
if ((writeError as NodeJS.ErrnoException).code === 'EEXIST') {
logger.debug(`.geminiignore already exists at ${ignorePath}, preserving existing file`);
return;
}
// Non-fatal: startup will just be slower without the ignore file
logger.debug(`Failed to create .geminiignore: ${writeError}`);
}
}
/** /**
* Create a GeminiError with details * Create a GeminiError with details
*/ */

View File

@@ -0,0 +1,53 @@
/**
* Mock Provider - No-op AI provider for E2E and CI testing
*
* When AUTOMAKER_MOCK_AGENT=true, the server uses this provider instead of
* real backends (Claude, Codex, etc.) so tests never call external APIs.
*/
import type { ExecuteOptions } from '@automaker/types';
import { BaseProvider } from './base-provider.js';
import type { ProviderMessage, InstallationStatus, ModelDefinition } from './types.js';
const MOCK_TEXT = 'Mock agent output for testing.';
export class MockProvider extends BaseProvider {
getName(): string {
return 'mock';
}
async *executeQuery(_options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
yield {
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'text', text: MOCK_TEXT }],
},
};
yield {
type: 'result',
subtype: 'success',
};
}
async detectInstallation(): Promise<InstallationStatus> {
return {
installed: true,
method: 'sdk',
hasApiKey: true,
authenticated: true,
};
}
getAvailableModels(): ModelDefinition[] {
return [
{
id: 'mock-model',
name: 'Mock Model',
modelString: 'mock-model',
provider: 'mock',
description: 'Mock model for testing',
},
];
}
}

View File

@@ -1189,8 +1189,26 @@ export class OpencodeProvider extends CliProvider {
* Format a display name for a model * Format a display name for a model
*/ */
private formatModelDisplayName(model: OpenCodeModelInfo): string { private formatModelDisplayName(model: OpenCodeModelInfo): string {
// Extract the last path segment for nested model IDs
// e.g., "arcee-ai/trinity-large-preview:free" → "trinity-large-preview:free"
let rawName = model.name;
if (rawName.includes('/')) {
rawName = rawName.split('/').pop()!;
}
// Strip tier/pricing suffixes like ":free", ":extended"
const colonIdx = rawName.indexOf(':');
let suffix = '';
if (colonIdx !== -1) {
const tierPart = rawName.slice(colonIdx + 1);
if (/^(free|extended|beta|preview)$/i.test(tierPart)) {
suffix = ` (${tierPart.charAt(0).toUpperCase() + tierPart.slice(1)})`;
}
rawName = rawName.slice(0, colonIdx);
}
// Capitalize and format the model name // Capitalize and format the model name
const formattedName = model.name const formattedName = rawName
.split('-') .split('-')
.map((part) => { .map((part) => {
// Handle version numbers like "4-5" -> "4.5" // Handle version numbers like "4-5" -> "4.5"
@@ -1218,7 +1236,7 @@ export class OpencodeProvider extends CliProvider {
}; };
const providerDisplay = providerNames[model.provider] || model.provider; const providerDisplay = providerNames[model.provider] || model.provider;
return `${formattedName} (${providerDisplay})`; return `${formattedName}${suffix} (${providerDisplay})`;
} }
/** /**

View File

@@ -67,6 +67,16 @@ export function registerProvider(name: string, registration: ProviderRegistratio
providerRegistry.set(name.toLowerCase(), registration); providerRegistry.set(name.toLowerCase(), registration);
} }
/** Cached mock provider instance when AUTOMAKER_MOCK_AGENT is set (E2E/CI). */
let mockProviderInstance: BaseProvider | null = null;
function getMockProvider(): BaseProvider {
if (!mockProviderInstance) {
mockProviderInstance = new MockProvider();
}
return mockProviderInstance;
}
export class ProviderFactory { export class ProviderFactory {
/** /**
* Determine which provider to use for a given model * Determine which provider to use for a given model
@@ -75,6 +85,9 @@ export class ProviderFactory {
* @returns Provider name (ModelProvider type) * @returns Provider name (ModelProvider type)
*/ */
static getProviderNameForModel(model: string): ModelProvider { static getProviderNameForModel(model: string): ModelProvider {
if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
return 'claude' as ModelProvider; // Name only; getProviderForModel returns MockProvider
}
const lowerModel = model.toLowerCase(); const lowerModel = model.toLowerCase();
// Get all registered providers sorted by priority (descending) // Get all registered providers sorted by priority (descending)
@@ -113,6 +126,9 @@ export class ProviderFactory {
modelId: string, modelId: string,
options: { throwOnDisconnected?: boolean } = {} options: { throwOnDisconnected?: boolean } = {}
): BaseProvider { ): BaseProvider {
if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
return getMockProvider();
}
const { throwOnDisconnected = true } = options; const { throwOnDisconnected = true } = options;
const providerName = this.getProviderForModelName(modelId); const providerName = this.getProviderForModelName(modelId);
@@ -142,6 +158,9 @@ export class ProviderFactory {
* Get the provider name for a given model ID (without creating provider instance) * Get the provider name for a given model ID (without creating provider instance)
*/ */
static getProviderForModelName(modelId: string): string { static getProviderForModelName(modelId: string): string {
if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
return 'claude';
}
const lowerModel = modelId.toLowerCase(); const lowerModel = modelId.toLowerCase();
// Get all registered providers sorted by priority (descending) // Get all registered providers sorted by priority (descending)
@@ -272,6 +291,7 @@ export class ProviderFactory {
// ============================================================================= // =============================================================================
// Import providers for registration side-effects // Import providers for registration side-effects
import { MockProvider } from './mock-provider.js';
import { ClaudeProvider } from './claude-provider.js'; import { ClaudeProvider } from './claude-provider.js';
import { CursorProvider } from './cursor-provider.js'; import { CursorProvider } from './cursor-provider.js';
import { CodexProvider } from './codex-provider.js'; import { CodexProvider } from './codex-provider.js';

View File

@@ -323,7 +323,7 @@ Your entire response should be valid JSON starting with { and ending with }. No
} }
} }
await parseAndCreateFeatures(projectPath, contentForParsing, events); await parseAndCreateFeatures(projectPath, contentForParsing, events, settingsService);
logger.debug('========== generateFeaturesFromSpec() completed =========='); logger.debug('========== generateFeaturesFromSpec() completed ==========');
} }

View File

@@ -9,13 +9,16 @@ import { createLogger, atomicWriteJson, DEFAULT_BACKUP_COUNT } from '@automaker/
import { getFeaturesDir } from '@automaker/platform'; import { getFeaturesDir } from '@automaker/platform';
import { extractJsonWithArray } from '../../lib/json-extractor.js'; import { extractJsonWithArray } from '../../lib/json-extractor.js';
import { getNotificationService } from '../../services/notification-service.js'; import { getNotificationService } from '../../services/notification-service.js';
import type { SettingsService } from '../../services/settings-service.js';
import { resolvePhaseModel } from '@automaker/model-resolver';
const logger = createLogger('SpecRegeneration'); const logger = createLogger('SpecRegeneration');
export async function parseAndCreateFeatures( export async function parseAndCreateFeatures(
projectPath: string, projectPath: string,
content: string, content: string,
events: EventEmitter events: EventEmitter,
settingsService?: SettingsService
): Promise<void> { ): Promise<void> {
logger.info('========== parseAndCreateFeatures() started =========='); logger.info('========== parseAndCreateFeatures() started ==========');
logger.info(`Content length: ${content.length} chars`); logger.info(`Content length: ${content.length} chars`);
@@ -23,6 +26,37 @@ export async function parseAndCreateFeatures(
logger.info(content); logger.info(content);
logger.info('========== END CONTENT =========='); logger.info('========== END CONTENT ==========');
// Load default model and planning settings from settingsService
let defaultModel: string | undefined;
let defaultPlanningMode: string = 'skip';
let defaultRequirePlanApproval = false;
if (settingsService) {
try {
const globalSettings = await settingsService.getGlobalSettings();
const projectSettings = await settingsService.getProjectSettings(projectPath);
const defaultModelEntry =
projectSettings.defaultFeatureModel ?? globalSettings.defaultFeatureModel;
if (defaultModelEntry) {
const resolved = resolvePhaseModel(defaultModelEntry);
defaultModel = resolved.model;
}
defaultPlanningMode = globalSettings.defaultPlanningMode ?? 'skip';
defaultRequirePlanApproval = globalSettings.defaultRequirePlanApproval ?? false;
logger.info(
`[parseAndCreateFeatures] Using defaults: model=${defaultModel ?? 'none'}, planningMode=${defaultPlanningMode}, requirePlanApproval=${defaultRequirePlanApproval}`
);
} catch (settingsError) {
logger.warn(
'[parseAndCreateFeatures] Failed to load settings, using defaults:',
settingsError
);
}
}
try { try {
// Extract JSON from response using shared utility // Extract JSON from response using shared utility
logger.info('Extracting JSON from response using extractJsonWithArray...'); logger.info('Extracting JSON from response using extractJsonWithArray...');
@@ -61,7 +95,7 @@ export async function parseAndCreateFeatures(
const featureDir = path.join(featuresDir, feature.id); const featureDir = path.join(featuresDir, feature.id);
await secureFs.mkdir(featureDir, { recursive: true }); await secureFs.mkdir(featureDir, { recursive: true });
const featureData = { const featureData: Record<string, unknown> = {
id: feature.id, id: feature.id,
category: feature.category || 'Uncategorized', category: feature.category || 'Uncategorized',
title: feature.title, title: feature.title,
@@ -70,10 +104,20 @@ export async function parseAndCreateFeatures(
priority: feature.priority || 2, priority: feature.priority || 2,
complexity: feature.complexity || 'moderate', complexity: feature.complexity || 'moderate',
dependencies: feature.dependencies || [], dependencies: feature.dependencies || [],
planningMode: defaultPlanningMode,
requirePlanApproval:
defaultPlanningMode === 'skip' || defaultPlanningMode === 'lite'
? false
: defaultRequirePlanApproval,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
// Apply default model if available from settings
if (defaultModel) {
featureData.model = defaultModel;
}
// Use atomic write with backup support for crash protection // Use atomic write with backup support for crash protection
await atomicWriteJson(path.join(featureDir, 'feature.json'), featureData, { await atomicWriteJson(path.join(featureDir, 'feature.json'), featureData, {
backupCount: DEFAULT_BACKUP_COUNT, backupCount: DEFAULT_BACKUP_COUNT,

View File

@@ -19,10 +19,11 @@ export function createAnalyzeProjectHandler(autoModeService: AutoModeServiceComp
return; return;
} }
// Start analysis in background // Kick off analysis in the background; attach a rejection handler so
autoModeService.analyzeProject(projectPath).catch((error) => { // unhandled-promise warnings don't surface and errors are at least logged.
logger.error(`[AutoMode] Project analysis error:`, error); // Synchronous throws (e.g. "not implemented") still propagate here.
}); const analysisPromise = autoModeService.analyzeProject(projectPath);
analysisPromise.catch((err) => logError(err, 'Background analyzeProject failed'));
res.json({ success: true, message: 'Project analysis started' }); res.json({ success: true, message: 'Project analysis started' });
} catch (error) { } catch (error) {

View File

@@ -114,9 +114,20 @@ export function mapBacklogPlanError(rawMessage: string): string {
return 'Claude CLI could not be launched. Make sure the Claude CLI is installed and available in PATH, or check that Node.js is correctly installed. Try running "which claude" or "claude --version" in your terminal to verify.'; return 'Claude CLI could not be launched. Make sure the Claude CLI is installed and available in PATH, or check that Node.js is correctly installed. Try running "which claude" or "claude --version" in your terminal to verify.';
} }
// Claude Code process crash // Claude Code process crash - extract exit code for diagnostics
if (rawMessage.includes('Claude Code process exited')) { if (rawMessage.includes('Claude Code process exited')) {
return 'Claude exited unexpectedly. Try again. If it keeps happening, re-run `claude login` or update your API key in Setup.'; const exitCodeMatch = rawMessage.match(/exited with code (\d+)/);
const exitCode = exitCodeMatch ? exitCodeMatch[1] : 'unknown';
logger.error(`[BacklogPlan] Claude process exit code: ${exitCode}`);
return `Claude exited unexpectedly (exit code: ${exitCode}). This is usually a transient issue. Try again. If it keeps happening, re-run \`claude login\` or update your API key in Setup.`;
}
// Claude Code process killed by signal
if (rawMessage.includes('Claude Code process terminated by signal')) {
const signalMatch = rawMessage.match(/terminated by signal (\w+)/);
const signal = signalMatch ? signalMatch[1] : 'unknown';
logger.error(`[BacklogPlan] Claude process terminated by signal: ${signal}`);
return `Claude was terminated by signal ${signal}. This may indicate a resource issue. Try again.`;
} }
// Rate limiting // Rate limiting

View File

@@ -3,6 +3,9 @@
* *
* Model is configurable via phaseModels.backlogPlanningModel in settings * Model is configurable via phaseModels.backlogPlanningModel in settings
* (defaults to Sonnet). Can be overridden per-call via model parameter. * (defaults to Sonnet). Can be overridden per-call via model parameter.
*
* Includes automatic retry for transient CLI failures (e.g., "Claude Code
* process exited unexpectedly") to improve reliability.
*/ */
import type { EventEmitter } from '../../lib/events.js'; import type { EventEmitter } from '../../lib/events.js';
@@ -12,8 +15,10 @@ import {
isCursorModel, isCursorModel,
stripProviderPrefix, stripProviderPrefix,
type ThinkingLevel, type ThinkingLevel,
type SystemPromptPreset,
} from '@automaker/types'; } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver'; import { resolvePhaseModel } from '@automaker/model-resolver';
import { getCurrentBranch } from '@automaker/git-utils';
import { FeatureLoader } from '../../services/feature-loader.js'; import { FeatureLoader } from '../../services/feature-loader.js';
import { ProviderFactory } from '../../providers/provider-factory.js'; import { ProviderFactory } from '../../providers/provider-factory.js';
import { extractJsonWithArray } from '../../lib/json-extractor.js'; import { extractJsonWithArray } from '../../lib/json-extractor.js';
@@ -27,10 +32,28 @@ import {
import type { SettingsService } from '../../services/settings-service.js'; import type { SettingsService } from '../../services/settings-service.js';
import { import {
getAutoLoadClaudeMdSetting, getAutoLoadClaudeMdSetting,
getUseClaudeCodeSystemPromptSetting,
getPromptCustomization, getPromptCustomization,
getPhaseModelWithOverrides, getPhaseModelWithOverrides,
getProviderByModelId,
} from '../../lib/settings-helpers.js'; } from '../../lib/settings-helpers.js';
/** Maximum number of retry attempts for transient CLI failures */
const MAX_RETRIES = 2;
/** Delay between retries in milliseconds */
const RETRY_DELAY_MS = 2000;
/**
* Check if an error is retryable (transient CLI process failure)
*/
function isRetryableError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes('Claude Code process exited') ||
message.includes('Claude Code process terminated by signal')
);
}
const featureLoader = new FeatureLoader(); const featureLoader = new FeatureLoader();
/** /**
@@ -84,6 +107,53 @@ function parsePlanResponse(response: string): BacklogPlanResult {
}; };
} }
/**
* Try to parse a valid plan response without fallback behavior.
* Returns null if parsing fails.
*/
function tryParsePlanResponse(response: string): BacklogPlanResult | null {
if (!response || response.trim().length === 0) {
return null;
}
return extractJsonWithArray<BacklogPlanResult>(response, 'changes', { logger });
}
/**
* Choose the most reliable response text between streamed assistant chunks
* and provider final result payload.
*/
function selectBestResponseText(accumulatedText: string, providerResultText: string): string {
const hasAccumulated = accumulatedText.trim().length > 0;
const hasProviderResult = providerResultText.trim().length > 0;
if (!hasProviderResult) {
return accumulatedText;
}
if (!hasAccumulated) {
return providerResultText;
}
const accumulatedParsed = tryParsePlanResponse(accumulatedText);
const providerParsed = tryParsePlanResponse(providerResultText);
if (providerParsed && !accumulatedParsed) {
logger.info('[BacklogPlan] Using provider result (parseable JSON)');
return providerResultText;
}
if (accumulatedParsed && !providerParsed) {
logger.info('[BacklogPlan] Keeping accumulated text (parseable JSON)');
return accumulatedText;
}
if (providerResultText.length > accumulatedText.length) {
logger.info('[BacklogPlan] Using provider result (longer content)');
return providerResultText;
}
logger.info('[BacklogPlan] Keeping accumulated text (longer content)');
return accumulatedText;
}
/** /**
* Generate a backlog modification plan based on user prompt * Generate a backlog modification plan based on user prompt
*/ */
@@ -93,11 +163,40 @@ export async function generateBacklogPlan(
events: EventEmitter, events: EventEmitter,
abortController: AbortController, abortController: AbortController,
settingsService?: SettingsService, settingsService?: SettingsService,
model?: string model?: string,
branchName?: string
): Promise<BacklogPlanResult> { ): Promise<BacklogPlanResult> {
try { try {
// Load current features // Load current features
const features = await featureLoader.getAll(projectPath); const allFeatures = await featureLoader.getAll(projectPath);
// Filter features by branch if specified (worktree-scoped backlog)
let features: Feature[];
if (branchName) {
// Determine the primary branch so unassigned features show for the main worktree
let primaryBranch: string | null = null;
try {
primaryBranch = await getCurrentBranch(projectPath);
} catch {
// If git fails, fall back to 'main' so unassigned features are visible
// when branchName matches a common default branch name
primaryBranch = 'main';
}
const isMainBranch = branchName === primaryBranch;
features = allFeatures.filter((f) => {
if (!f.branchName) {
// Unassigned features belong to the main/primary worktree
return isMainBranch;
}
return f.branchName === branchName;
});
logger.info(
`[BacklogPlan] Filtered to ${features.length}/${allFeatures.length} features for branch: ${branchName}`
);
} else {
features = allFeatures;
}
events.emit('backlog-plan:event', { events.emit('backlog-plan:event', {
type: 'backlog_plan_progress', type: 'backlog_plan_progress',
@@ -133,6 +232,35 @@ export async function generateBacklogPlan(
effectiveModel = resolved.model; effectiveModel = resolved.model;
thinkingLevel = resolved.thinkingLevel; thinkingLevel = resolved.thinkingLevel;
credentials = await settingsService?.getCredentials(); credentials = await settingsService?.getCredentials();
// Resolve Claude-compatible provider when client sends a model (e.g. MiniMax, GLM)
if (settingsService) {
const providerResult = await getProviderByModelId(
effectiveModel,
settingsService,
'[BacklogPlan]'
);
if (providerResult.provider) {
claudeCompatibleProvider = providerResult.provider;
if (providerResult.credentials) {
credentials = providerResult.credentials;
}
}
// Fallback: use phase settings provider if model lookup found nothing (e.g. model
// string format differs from provider's model id, but backlog planning phase has providerId).
if (!claudeCompatibleProvider) {
const phaseResult = await getPhaseModelWithOverrides(
'backlogPlanningModel',
settingsService,
projectPath,
'[BacklogPlan]'
);
const phaseResolved = resolvePhaseModel(phaseResult.phaseModel);
if (phaseResult.provider && phaseResolved.model === effectiveModel) {
claudeCompatibleProvider = phaseResult.provider;
credentials = phaseResult.credentials ?? credentials;
}
}
}
} else if (settingsService) { } else if (settingsService) {
// Use settings-based model with provider info // Use settings-based model with provider info
const phaseResult = await getPhaseModelWithOverrides( const phaseResult = await getPhaseModelWithOverrides(
@@ -162,17 +290,23 @@ export async function generateBacklogPlan(
// Strip provider prefix - providers expect bare model IDs // Strip provider prefix - providers expect bare model IDs
const bareModel = stripProviderPrefix(effectiveModel); const bareModel = stripProviderPrefix(effectiveModel);
// Get autoLoadClaudeMd setting // Get autoLoadClaudeMd and useClaudeCodeSystemPrompt settings
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath, projectPath,
settingsService, settingsService,
'[BacklogPlan]' '[BacklogPlan]'
); );
const useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting(
projectPath,
settingsService,
'[BacklogPlan]'
);
// For Cursor models, we need to combine prompts with explicit instructions // For Cursor models, we need to combine prompts with explicit instructions
// because Cursor doesn't support systemPrompt separation like Claude SDK // because Cursor doesn't support systemPrompt separation like Claude SDK
let finalPrompt = userPrompt; let finalPrompt = userPrompt;
let finalSystemPrompt: string | undefined = systemPrompt; let finalSystemPrompt: string | SystemPromptPreset | undefined = systemPrompt;
let finalSettingSources: Array<'user' | 'project' | 'local'> | undefined;
if (isCursorModel(effectiveModel)) { if (isCursorModel(effectiveModel)) {
logger.info('[BacklogPlan] Using Cursor model - adding explicit no-file-write instructions'); logger.info('[BacklogPlan] Using Cursor model - adding explicit no-file-write instructions');
@@ -187,25 +321,65 @@ CRITICAL INSTRUCTIONS:
${userPrompt}`; ${userPrompt}`;
finalSystemPrompt = undefined; // System prompt is now embedded in the user prompt finalSystemPrompt = undefined; // System prompt is now embedded in the user prompt
} else if (claudeCompatibleProvider) {
// Claude-compatible providers (MiniMax, GLM, etc.) use a plain API; do not use
// the claude_code preset (which is for Claude CLI/subprocess and can break the request).
finalSystemPrompt = systemPrompt;
} else if (useClaudeCodeSystemPrompt) {
// Use claude_code preset for native Claude so the SDK subprocess
// authenticates via CLI OAuth or API key the same way all other SDK calls do.
finalSystemPrompt = {
type: 'preset',
preset: 'claude_code',
append: systemPrompt,
};
}
// Include settingSources when autoLoadClaudeMd is enabled
if (autoLoadClaudeMd) {
finalSettingSources = ['user', 'project'];
} }
// Execute the query // Execute the query with retry logic for transient CLI failures
const stream = provider.executeQuery({ const queryOptions = {
prompt: finalPrompt, prompt: finalPrompt,
model: bareModel, model: bareModel,
cwd: projectPath, cwd: projectPath,
systemPrompt: finalSystemPrompt, systemPrompt: finalSystemPrompt,
maxTurns: 1, maxTurns: 1,
allowedTools: [], // No tools needed for this tools: [] as string[], // Disable all built-in tools - plan generation only needs text output
abortController, abortController,
settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined, settingSources: finalSettingSources,
readOnly: true, // Plan generation only generates text, doesn't write files
thinkingLevel, // Pass thinking level for extended thinking thinkingLevel, // Pass thinking level for extended thinking
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource credentials, // Pass credentials for resolving 'credentials' apiKeySource
}); };
let responseText = ''; let responseText = '';
let bestResponseText = ''; // Preserve best response across all retry attempts
let recoveredResult: BacklogPlanResult | null = null;
let lastError: unknown = null;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
if (abortController.signal.aborted) {
throw new Error('Generation aborted');
}
if (attempt > 0) {
logger.info(
`[BacklogPlan] Retry attempt ${attempt}/${MAX_RETRIES} after transient failure`
);
events.emit('backlog-plan:event', {
type: 'backlog_plan_progress',
content: `Retrying... (attempt ${attempt + 1}/${MAX_RETRIES + 1})`,
});
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
}
let accumulatedText = '';
let providerResultText = '';
try {
const stream = provider.executeQuery(queryOptions);
for await (const msg of stream) { for await (const msg of stream) {
if (abortController.signal.aborted) { if (abortController.signal.aborted) {
@@ -216,25 +390,76 @@ ${userPrompt}`;
if (msg.message?.content) { if (msg.message?.content) {
for (const block of msg.message.content) { for (const block of msg.message.content) {
if (block.type === 'text') { if (block.type === 'text') {
responseText += block.text; accumulatedText += block.text;
} }
} }
} }
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) { } else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
// Use result if it's a final accumulated message (from Cursor provider) providerResultText = msg.result;
logger.info('[BacklogPlan] Received result from Cursor, length:', msg.result.length); logger.info(
logger.info('[BacklogPlan] Previous responseText length:', responseText.length); '[BacklogPlan] Received result from provider, length:',
if (msg.result.length > responseText.length) { providerResultText.length
logger.info('[BacklogPlan] Using Cursor result (longer than accumulated text)'); );
responseText = msg.result; logger.info('[BacklogPlan] Accumulated response length:', accumulatedText.length);
} else {
logger.info('[BacklogPlan] Keeping accumulated text (longer than Cursor result)');
}
} }
} }
responseText = selectBestResponseText(accumulatedText, providerResultText);
// If we got here, the stream completed successfully
lastError = null;
break;
} catch (error) {
lastError = error;
const errorMessage = error instanceof Error ? error.message : String(error);
responseText = selectBestResponseText(accumulatedText, providerResultText);
// Preserve the best response text across all attempts so that if a retry
// crashes immediately (empty response), we can still recover from an earlier attempt
bestResponseText = selectBestResponseText(bestResponseText, responseText);
// Claude SDK can occasionally exit non-zero after emitting a complete response.
// If we already have valid JSON, recover instead of failing the entire planning flow.
if (isRetryableError(error)) {
const parsed = tryParsePlanResponse(bestResponseText);
if (parsed) {
logger.warn(
'[BacklogPlan] Recovered from transient CLI exit using accumulated valid response'
);
recoveredResult = parsed;
lastError = null;
break;
}
// On final retryable failure, degrade gracefully if we have text from any attempt.
if (attempt >= MAX_RETRIES && bestResponseText.trim().length > 0) {
logger.warn(
'[BacklogPlan] Final retryable CLI failure with non-empty response, attempting fallback parse'
);
recoveredResult = parsePlanResponse(bestResponseText);
lastError = null;
break;
}
}
// Only retry on transient CLI failures, not on user aborts or other errors
if (!isRetryableError(error) || attempt >= MAX_RETRIES) {
throw error;
}
logger.warn(
`[BacklogPlan] Transient CLI failure (attempt ${attempt + 1}/${MAX_RETRIES + 1}): ${errorMessage}`
);
}
}
// If we exhausted retries, throw the last error
if (lastError) {
throw lastError;
}
// Parse the response // Parse the response
const result = parsePlanResponse(responseText); const result = recoveredResult ?? parsePlanResponse(responseText);
await saveBacklogPlan(projectPath, { await saveBacklogPlan(projectPath, {
savedAt: new Date().toISOString(), savedAt: new Date().toISOString(),

View File

@@ -25,7 +25,7 @@ export function createBacklogPlanRoutes(
); );
router.post('/stop', createStopHandler()); router.post('/stop', createStopHandler());
router.get('/status', validatePathParams('projectPath'), createStatusHandler()); router.get('/status', validatePathParams('projectPath'), createStatusHandler());
router.post('/apply', validatePathParams('projectPath'), createApplyHandler()); router.post('/apply', validatePathParams('projectPath'), createApplyHandler(settingsService));
router.post('/clear', validatePathParams('projectPath'), createClearHandler()); router.post('/clear', validatePathParams('projectPath'), createClearHandler());
return router; return router;

View File

@@ -3,13 +3,23 @@
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import type { BacklogPlanResult } from '@automaker/types'; import { resolvePhaseModel } from '@automaker/model-resolver';
import type { BacklogPlanResult, PhaseModelEntry, PlanningMode } from '@automaker/types';
import { FeatureLoader } from '../../../services/feature-loader.js'; import { FeatureLoader } from '../../../services/feature-loader.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { clearBacklogPlan, getErrorMessage, logError, logger } from '../common.js'; import { clearBacklogPlan, getErrorMessage, logError, logger } from '../common.js';
const featureLoader = new FeatureLoader(); const featureLoader = new FeatureLoader();
export function createApplyHandler() { function normalizePhaseModelEntry(
entry: PhaseModelEntry | string | undefined | null
): PhaseModelEntry | undefined {
if (!entry) return undefined;
if (typeof entry === 'string') return { model: entry };
return entry;
}
export function createApplyHandler(settingsService?: SettingsService) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { const {
@@ -38,6 +48,23 @@ export function createApplyHandler() {
return; return;
} }
let defaultPlanningMode: PlanningMode = 'skip';
let defaultRequirePlanApproval = false;
let defaultModelEntry: PhaseModelEntry | undefined;
if (settingsService) {
const globalSettings = await settingsService.getGlobalSettings();
const projectSettings = await settingsService.getProjectSettings(projectPath);
defaultPlanningMode = globalSettings.defaultPlanningMode ?? 'skip';
defaultRequirePlanApproval = globalSettings.defaultRequirePlanApproval ?? false;
defaultModelEntry = normalizePhaseModelEntry(
projectSettings.defaultFeatureModel ?? globalSettings.defaultFeatureModel
);
}
const resolvedDefaultModel = resolvePhaseModel(defaultModelEntry);
const appliedChanges: string[] = []; const appliedChanges: string[] = [];
// Load current features for dependency validation // Load current features for dependency validation
@@ -88,6 +115,12 @@ export function createApplyHandler() {
if (!change.feature) continue; if (!change.feature) continue;
try { try {
const effectivePlanningMode = change.feature.planningMode ?? defaultPlanningMode;
const effectiveRequirePlanApproval =
effectivePlanningMode === 'skip' || effectivePlanningMode === 'lite'
? false
: (change.feature.requirePlanApproval ?? defaultRequirePlanApproval);
// Create the new feature - use the AI-generated ID if provided // Create the new feature - use the AI-generated ID if provided
const newFeature = await featureLoader.create(projectPath, { const newFeature = await featureLoader.create(projectPath, {
id: change.feature.id, // Use descriptive ID from AI if provided id: change.feature.id, // Use descriptive ID from AI if provided
@@ -97,6 +130,12 @@ export function createApplyHandler() {
dependencies: change.feature.dependencies, dependencies: change.feature.dependencies,
priority: change.feature.priority, priority: change.feature.priority,
status: 'backlog', status: 'backlog',
model: change.feature.model ?? resolvedDefaultModel.model,
thinkingLevel: change.feature.thinkingLevel ?? resolvedDefaultModel.thinkingLevel,
reasoningEffort: change.feature.reasoningEffort ?? resolvedDefaultModel.reasoningEffort,
providerId: change.feature.providerId ?? resolvedDefaultModel.providerId,
planningMode: effectivePlanningMode,
requirePlanApproval: effectiveRequirePlanApproval,
branchName, branchName,
}); });

View File

@@ -17,10 +17,11 @@ import type { SettingsService } from '../../../services/settings-service.js';
export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) { export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { projectPath, prompt, model } = req.body as { const { projectPath, prompt, model, branchName } = req.body as {
projectPath: string; projectPath: string;
prompt: string; prompt: string;
model?: string; model?: string;
branchName?: string;
}; };
if (!projectPath) { if (!projectPath) {
@@ -42,27 +43,29 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
return; return;
} }
setRunningState(true); const abortController = new AbortController();
setRunningState(true, abortController);
setRunningDetails({ setRunningDetails({
projectPath, projectPath,
prompt, prompt,
model, model,
startedAt: new Date().toISOString(), startedAt: new Date().toISOString(),
}); });
const abortController = new AbortController();
setRunningState(true, abortController);
// Start generation in background // Start generation in background
// Note: generateBacklogPlan handles its own error event emission, // Note: generateBacklogPlan handles its own error event emission
// so we only log here to avoid duplicate error toasts // and state cleanup in its finally block, so we only log here
generateBacklogPlan(projectPath, prompt, events, abortController, settingsService, model) generateBacklogPlan(
.catch((error) => { projectPath,
prompt,
events,
abortController,
settingsService,
model,
branchName
).catch((error) => {
// Just log - error event already emitted by generateBacklogPlan // Just log - error event already emitted by generateBacklogPlan
logError(error, 'Generate backlog plan failed (background)'); logError(error, 'Generate backlog plan failed (background)');
})
.finally(() => {
setRunningState(false, null);
setRunningDetails(null);
}); });
res.json({ success: true }); res.json({ success: true });

View File

@@ -142,11 +142,33 @@ function mapDescribeImageError(rawMessage: string | undefined): {
if (!rawMessage) return baseResponse; if (!rawMessage) return baseResponse;
if (rawMessage.includes('Claude Code process exited')) { if (
rawMessage.includes('Claude Code process exited') ||
rawMessage.includes('Claude Code process terminated by signal')
) {
const exitCodeMatch = rawMessage.match(/exited with code (\d+)/);
const signalMatch = rawMessage.match(/terminated by signal (\w+)/);
const detail = exitCodeMatch
? ` (exit code: ${exitCodeMatch[1]})`
: signalMatch
? ` (signal: ${signalMatch[1]})`
: '';
// Crash/OS-kill signals suggest a process crash, not an auth failure —
// omit auth recovery advice and suggest retry/reporting instead.
const crashSignals = ['SIGSEGV', 'SIGABRT', 'SIGKILL', 'SIGBUS', 'SIGTRAP'];
const isCrashSignal = signalMatch ? crashSignals.includes(signalMatch[1]) : false;
if (isCrashSignal) {
return { return {
statusCode: 503, statusCode: 503,
userMessage: userMessage: `Claude crashed unexpectedly${detail} while describing the image. This may be a transient condition. Please try again. If the problem persists, collect logs and report the issue.`,
'Claude exited unexpectedly while describing the image. Try again. If it keeps happening, re-run `claude login` or update your API key in Setup so Claude can restart cleanly.', };
}
return {
statusCode: 503,
userMessage: `Claude exited unexpectedly${detail} while describing the image. This is usually a transient issue. Try again. If it keeps happening, re-run \`claude login\` or update your API key in Setup.`,
}; };
} }

View File

@@ -19,6 +19,11 @@ import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent
import { createGenerateTitleHandler } from './routes/generate-title.js'; import { createGenerateTitleHandler } from './routes/generate-title.js';
import { createExportHandler } from './routes/export.js'; import { createExportHandler } from './routes/export.js';
import { createImportHandler, createConflictCheckHandler } from './routes/import.js'; import { createImportHandler, createConflictCheckHandler } from './routes/import.js';
import {
createOrphanedListHandler,
createOrphanedResolveHandler,
createOrphanedBulkResolveHandler,
} from './routes/orphaned.js';
export function createFeaturesRoutes( export function createFeaturesRoutes(
featureLoader: FeatureLoader, featureLoader: FeatureLoader,
@@ -44,7 +49,11 @@ export function createFeaturesRoutes(
validatePathParams('projectPath'), validatePathParams('projectPath'),
createCreateHandler(featureLoader, events) createCreateHandler(featureLoader, events)
); );
router.post('/update', validatePathParams('projectPath'), createUpdateHandler(featureLoader)); router.post(
'/update',
validatePathParams('projectPath'),
createUpdateHandler(featureLoader, events)
);
router.post( router.post(
'/bulk-update', '/bulk-update',
validatePathParams('projectPath'), validatePathParams('projectPath'),
@@ -66,6 +75,21 @@ export function createFeaturesRoutes(
validatePathParams('projectPath'), validatePathParams('projectPath'),
createConflictCheckHandler(featureLoader) createConflictCheckHandler(featureLoader)
); );
router.post(
'/orphaned',
validatePathParams('projectPath'),
createOrphanedListHandler(featureLoader, autoModeService)
);
router.post(
'/orphaned/resolve',
validatePathParams('projectPath'),
createOrphanedResolveHandler(featureLoader, autoModeService)
);
router.post(
'/orphaned/bulk-resolve',
validatePathParams('projectPath'),
createOrphanedBulkResolveHandler(featureLoader)
);
return router; return router;
} }

View File

@@ -46,7 +46,7 @@ export function createListHandler(
// Note: detectOrphanedFeatures handles errors internally and always resolves // Note: detectOrphanedFeatures handles errors internally and always resolves
if (autoModeService) { if (autoModeService) {
autoModeService autoModeService
.detectOrphanedFeatures(projectPath) .detectOrphanedFeatures(projectPath, features)
.then((orphanedFeatures) => { .then((orphanedFeatures) => {
if (orphanedFeatures.length > 0) { if (orphanedFeatures.length > 0) {
logger.info( logger.info(

View File

@@ -0,0 +1,287 @@
/**
* POST /orphaned endpoint - Detect orphaned features (features with missing branches)
* POST /orphaned/resolve endpoint - Resolve an orphaned feature (delete, create-worktree, or move-to-branch)
* POST /orphaned/bulk-resolve endpoint - Resolve multiple orphaned features at once
*/
import crypto from 'crypto';
import path from 'path';
import type { Request, Response } from 'express';
import { FeatureLoader } from '../../../services/feature-loader.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { getErrorMessage, logError } from '../common.js';
import { execGitCommand } from '../../../lib/git.js';
import { deleteWorktreeMetadata } from '../../../lib/worktree-metadata.js';
import { createLogger } from '@automaker/utils';
const logger = createLogger('OrphanedFeatures');
type ResolveAction = 'delete' | 'create-worktree' | 'move-to-branch';
const VALID_ACTIONS: ResolveAction[] = ['delete', 'create-worktree', 'move-to-branch'];
export function createOrphanedListHandler(
featureLoader: FeatureLoader,
autoModeService?: AutoModeServiceCompat
) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath: string };
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!autoModeService) {
res.status(500).json({ success: false, error: 'Auto-mode service not available' });
return;
}
const orphanedFeatures = await autoModeService.detectOrphanedFeatures(projectPath);
res.json({ success: true, orphanedFeatures });
} catch (error) {
logError(error, 'Detect orphaned features failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
export function createOrphanedResolveHandler(
featureLoader: FeatureLoader,
_autoModeService?: AutoModeServiceCompat
) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, action, targetBranch } = req.body as {
projectPath: string;
featureId: string;
action: ResolveAction;
targetBranch?: string | null;
};
if (!projectPath || !featureId || !action) {
res.status(400).json({
success: false,
error: 'projectPath, featureId, and action are required',
});
return;
}
if (!VALID_ACTIONS.includes(action)) {
res.status(400).json({
success: false,
error: `action must be one of: ${VALID_ACTIONS.join(', ')}`,
});
return;
}
const result = await resolveOrphanedFeature(
featureLoader,
projectPath,
featureId,
action,
targetBranch
);
if (!result.success) {
res.status(result.error === 'Feature not found' ? 404 : 500).json(result);
return;
}
res.json(result);
} catch (error) {
logError(error, 'Resolve orphaned feature failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
interface BulkResolveResult {
featureId: string;
success: boolean;
action?: string;
error?: string;
}
async function resolveOrphanedFeature(
featureLoader: FeatureLoader,
projectPath: string,
featureId: string,
action: ResolveAction,
targetBranch?: string | null
): Promise<BulkResolveResult> {
try {
const feature = await featureLoader.get(projectPath, featureId);
if (!feature) {
return { featureId, success: false, error: 'Feature not found' };
}
const missingBranch = feature.branchName;
switch (action) {
case 'delete': {
if (missingBranch) {
try {
await deleteWorktreeMetadata(projectPath, missingBranch);
} catch {
// Non-fatal
}
}
const success = await featureLoader.delete(projectPath, featureId);
if (!success) {
return { featureId, success: false, error: 'Deletion failed' };
}
logger.info(`Deleted orphaned feature ${featureId} (branch: ${missingBranch})`);
return { featureId, success: true, action: 'deleted' };
}
case 'create-worktree': {
if (!missingBranch) {
return { featureId, success: false, error: 'Feature has no branch name to recreate' };
}
const sanitizedName = missingBranch.replace(/[^a-zA-Z0-9_-]/g, '-');
const hash = crypto.createHash('sha1').update(missingBranch).digest('hex').slice(0, 8);
const worktreesDir = path.join(projectPath, '.worktrees');
const worktreePath = path.join(worktreesDir, `${sanitizedName}-${hash}`);
try {
await execGitCommand(['worktree', 'add', '-b', missingBranch, worktreePath], projectPath);
} catch (error) {
const msg = getErrorMessage(error);
if (msg.includes('already exists')) {
try {
await execGitCommand(['worktree', 'add', worktreePath, missingBranch], projectPath);
} catch (innerError) {
return {
featureId,
success: false,
error: `Failed to create worktree: ${getErrorMessage(innerError)}`,
};
}
} else {
return { featureId, success: false, error: `Failed to create worktree: ${msg}` };
}
}
logger.info(
`Created worktree for orphaned feature ${featureId} at ${worktreePath} (branch: ${missingBranch})`
);
return { featureId, success: true, action: 'worktree-created' };
}
case 'move-to-branch': {
// Move the feature to a different branch (or clear branch to use main worktree)
const newBranch = targetBranch || null;
// Validate that the target branch exists if one is specified
if (newBranch) {
try {
await execGitCommand(['rev-parse', '--verify', newBranch], projectPath);
} catch {
return {
featureId,
success: false,
error: `Target branch "${newBranch}" does not exist`,
};
}
}
await featureLoader.update(projectPath, featureId, {
branchName: newBranch,
status: 'pending',
});
// Clean up old worktree metadata
if (missingBranch) {
try {
await deleteWorktreeMetadata(projectPath, missingBranch);
} catch {
// Non-fatal
}
}
const destination = newBranch ?? 'main worktree';
logger.info(
`Moved orphaned feature ${featureId} to ${destination} (was: ${missingBranch})`
);
return { featureId, success: true, action: 'moved' };
}
}
} catch (error) {
return { featureId, success: false, error: getErrorMessage(error) };
}
}
export function createOrphanedBulkResolveHandler(featureLoader: FeatureLoader) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureIds, action, targetBranch } = req.body as {
projectPath: string;
featureIds: string[];
action: ResolveAction;
targetBranch?: string | null;
};
if (
!projectPath ||
!featureIds ||
!Array.isArray(featureIds) ||
featureIds.length === 0 ||
!action
) {
res.status(400).json({
success: false,
error: 'projectPath, featureIds (non-empty array), and action are required',
});
return;
}
if (!VALID_ACTIONS.includes(action)) {
res.status(400).json({
success: false,
error: `action must be one of: ${VALID_ACTIONS.join(', ')}`,
});
return;
}
// Process sequentially for worktree creation (git operations shouldn't race),
// in parallel for delete/move-to-branch
const results: BulkResolveResult[] = [];
if (action === 'create-worktree') {
for (const featureId of featureIds) {
const result = await resolveOrphanedFeature(
featureLoader,
projectPath,
featureId,
action,
targetBranch
);
results.push(result);
}
} else {
const batchResults = await Promise.all(
featureIds.map((featureId) =>
resolveOrphanedFeature(featureLoader, projectPath, featureId, action, targetBranch)
)
);
results.push(...batchResults);
}
const successCount = results.filter((r) => r.success).length;
const failedCount = results.length - successCount;
res.json({
success: failedCount === 0,
resolvedCount: successCount,
failedCount,
results,
});
} catch (error) {
logError(error, 'Bulk resolve orphaned features failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -5,6 +5,7 @@
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { FeatureLoader } from '../../../services/feature-loader.js'; import { FeatureLoader } from '../../../services/feature-loader.js';
import type { Feature, FeatureStatus } from '@automaker/types'; import type { Feature, FeatureStatus } from '@automaker/types';
import type { EventEmitter } from '../../../lib/events.js';
import { getErrorMessage, logError } from '../common.js'; import { getErrorMessage, logError } from '../common.js';
import { createLogger } from '@automaker/utils'; import { createLogger } from '@automaker/utils';
@@ -13,7 +14,7 @@ const logger = createLogger('features/update');
// Statuses that should trigger syncing to app_spec.txt // Statuses that should trigger syncing to app_spec.txt
const SYNC_TRIGGER_STATUSES: FeatureStatus[] = ['verified', 'completed']; const SYNC_TRIGGER_STATUSES: FeatureStatus[] = ['verified', 'completed'];
export function createUpdateHandler(featureLoader: FeatureLoader) { export function createUpdateHandler(featureLoader: FeatureLoader, events?: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { const {
@@ -42,7 +43,11 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
// Get the current feature to detect status changes // Get the current feature to detect status changes
const currentFeature = await featureLoader.get(projectPath, featureId); const currentFeature = await featureLoader.get(projectPath, featureId);
const previousStatus = currentFeature?.status as FeatureStatus | undefined; if (!currentFeature) {
res.status(404).json({ success: false, error: `Feature ${featureId} not found` });
return;
}
const previousStatus = currentFeature.status as FeatureStatus;
const newStatus = updates.status as FeatureStatus | undefined; const newStatus = updates.status as FeatureStatus | undefined;
const updated = await featureLoader.update( const updated = await featureLoader.update(
@@ -54,8 +59,18 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
preEnhancementDescription preEnhancementDescription
); );
// Trigger sync to app_spec.txt when status changes to verified or completed // Emit completion event and sync to app_spec.txt when status transitions to verified/completed
if (newStatus && SYNC_TRIGGER_STATUSES.includes(newStatus) && previousStatus !== newStatus) { if (newStatus && SYNC_TRIGGER_STATUSES.includes(newStatus) && previousStatus !== newStatus) {
events?.emit('feature:completed', {
featureId,
featureName: updated.title,
projectPath,
passes: true,
message:
newStatus === 'verified' ? 'Feature verified manually' : 'Feature completed manually',
executionMode: 'manual',
});
try { try {
const synced = await featureLoader.syncFeatureToAppSpec(projectPath, updated); const synced = await featureLoader.syncFeatureToAppSpec(projectPath, updated);
if (synced) { if (synced) {

View File

@@ -3,16 +3,29 @@
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js'; import * as secureFs from '../../../lib/secure-fs.js';
import { PathNotAllowedError } from '@automaker/platform'; import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js'; import { getErrorMessage, logError } from '../common.js';
// Optional files that are expected to not exist in new projects // Optional files that are expected to not exist in new projects
// Don't log ENOENT errors for these to reduce noise // Don't log ENOENT errors for these to reduce noise
const OPTIONAL_FILES = ['categories.json', 'app_spec.txt']; const OPTIONAL_FILES = ['categories.json', 'app_spec.txt', 'context-metadata.json'];
function isOptionalFile(filePath: string): boolean { function isOptionalFile(filePath: string): boolean {
return OPTIONAL_FILES.some((optionalFile) => filePath.endsWith(optionalFile)); const basename = path.basename(filePath);
if (OPTIONAL_FILES.some((optionalFile) => basename === optionalFile)) {
return true;
}
// Context and memory files may not exist yet during create/delete or test races
if (filePath.includes('.automaker/context/') || filePath.includes('.automaker/memory/')) {
const name = path.basename(filePath);
const lower = name.toLowerCase();
if (lower.endsWith('.md') || lower.endsWith('.txt') || lower.endsWith('.markdown')) {
return true;
}
}
return false;
} }
function isENOENT(error: unknown): boolean { function isENOENT(error: unknown): boolean {
@@ -39,12 +52,14 @@ export function createReadHandler() {
return; return;
} }
// Don't log ENOENT errors for optional files (expected to be missing in new projects) const filePath = req.body?.filePath || '';
const shouldLog = !(isENOENT(error) && isOptionalFile(req.body?.filePath || '')); const optionalMissing = isENOENT(error) && isOptionalFile(filePath);
if (shouldLog) { if (!optionalMissing) {
logError(error, 'Read file failed'); logError(error, 'Read file failed');
} }
res.status(500).json({ success: false, error: getErrorMessage(error) }); // Return 404 for missing optional files so clients can handle "not found"
const status = optionalMissing ? 404 : 500;
res.status(status).json({ success: false, error: getErrorMessage(error) });
} }
}; };
} }

View File

@@ -35,6 +35,16 @@ export function createStatHandler() {
return; return;
} }
// File or directory does not exist - return 404 so UI can handle missing paths
const code =
error && typeof error === 'object' && 'code' in error
? (error as { code: string }).code
: '';
if (code === 'ENOENT') {
res.status(404).json({ success: false, error: 'File or directory not found' });
return;
}
logError(error, 'Get file stats failed'); logError(error, 'Get file stats failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }

View File

@@ -24,7 +24,9 @@ export function createWriteHandler() {
// Ensure parent directory exists (symlink-safe) // Ensure parent directory exists (symlink-safe)
await mkdirSafe(path.dirname(path.resolve(filePath))); await mkdirSafe(path.dirname(path.resolve(filePath)));
await secureFs.writeFile(filePath, content, 'utf-8'); // Default content to empty string if undefined/null to prevent writing
// "undefined" as literal text (e.g. when content field is missing from request)
await secureFs.writeFile(filePath, content ?? '', 'utf-8');
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {

View File

@@ -9,6 +9,8 @@ import { createCheckGitHubRemoteHandler } from './routes/check-github-remote.js'
import { createListIssuesHandler } from './routes/list-issues.js'; import { createListIssuesHandler } from './routes/list-issues.js';
import { createListPRsHandler } from './routes/list-prs.js'; import { createListPRsHandler } from './routes/list-prs.js';
import { createListCommentsHandler } from './routes/list-comments.js'; import { createListCommentsHandler } from './routes/list-comments.js';
import { createListPRReviewCommentsHandler } from './routes/list-pr-review-comments.js';
import { createResolvePRCommentHandler } from './routes/resolve-pr-comment.js';
import { createValidateIssueHandler } from './routes/validate-issue.js'; import { createValidateIssueHandler } from './routes/validate-issue.js';
import { import {
createValidationStatusHandler, createValidationStatusHandler,
@@ -29,6 +31,16 @@ export function createGitHubRoutes(
router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler()); router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler());
router.post('/prs', validatePathParams('projectPath'), createListPRsHandler()); router.post('/prs', validatePathParams('projectPath'), createListPRsHandler());
router.post('/issue-comments', validatePathParams('projectPath'), createListCommentsHandler()); router.post('/issue-comments', validatePathParams('projectPath'), createListCommentsHandler());
router.post(
'/pr-review-comments',
validatePathParams('projectPath'),
createListPRReviewCommentsHandler()
);
router.post(
'/resolve-pr-comment',
validatePathParams('projectPath'),
createResolvePRCommentHandler()
);
router.post( router.post(
'/validate-issue', '/validate-issue',
validatePathParams('projectPath'), validatePathParams('projectPath'),

View File

@@ -1,38 +1,14 @@
/** /**
* Common utilities for GitHub routes * Common utilities for GitHub routes
*
* Re-exports shared utilities from lib/exec-utils so route consumers
* can continue importing from this module unchanged.
*/ */
import { exec } from 'child_process'; import { exec } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import { createLogger } from '@automaker/utils';
const logger = createLogger('GitHub');
export const execAsync = promisify(exec); export const execAsync = promisify(exec);
// Extended PATH to include common tool installation locations // Re-export shared utilities from the canonical location
export const extendedPath = [ export { extendedPath, execEnv, getErrorMessage, logError } from '../../../lib/exec-utils.js';
process.env.PATH,
'/opt/homebrew/bin',
'/usr/local/bin',
'/home/linuxbrew/.linuxbrew/bin',
`${process.env.HOME}/.local/bin`,
]
.filter(Boolean)
.join(':');
export const execEnv = {
...process.env,
PATH: extendedPath,
};
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
export function logError(error: unknown, context: string): void {
logger.error(`${context}:`, error);
}

View File

@@ -0,0 +1,72 @@
/**
* POST /pr-review-comments endpoint - Fetch review comments for a GitHub PR
*
* Fetches both regular PR comments and inline code review comments
* for a specific pull request, providing file path and line context.
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from './common.js';
import { checkGitHubRemote } from './check-github-remote.js';
import {
fetchPRReviewComments,
fetchReviewThreadResolvedStatus,
type PRReviewComment,
type ListPRReviewCommentsResult,
} from '../../../services/pr-review-comments.service.js';
// Re-export types so existing callers continue to work
export type { PRReviewComment, ListPRReviewCommentsResult };
// Re-export service functions so existing callers continue to work
export { fetchPRReviewComments, fetchReviewThreadResolvedStatus };
interface ListPRReviewCommentsRequest {
projectPath: string;
prNumber: number;
}
export function createListPRReviewCommentsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, prNumber } = req.body as ListPRReviewCommentsRequest;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!prNumber || typeof prNumber !== 'number') {
res
.status(400)
.json({ success: false, error: 'prNumber is required and must be a number' });
return;
}
// Check if this is a GitHub repo and get owner/repo
const remoteStatus = await checkGitHubRemote(projectPath);
if (!remoteStatus.hasGitHubRemote || !remoteStatus.owner || !remoteStatus.repo) {
res.status(400).json({
success: false,
error: 'Project does not have a GitHub remote',
});
return;
}
const comments = await fetchPRReviewComments(
projectPath,
remoteStatus.owner,
remoteStatus.repo,
prNumber
);
res.json({
success: true,
comments,
totalCount: comments.length,
});
} catch (error) {
logError(error, 'Fetch PR review comments failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,66 @@
/**
* POST /resolve-pr-comment endpoint - Resolve or unresolve a GitHub PR review thread
*
* Uses the GitHub GraphQL API to resolve or unresolve a review thread
* identified by its GraphQL node ID (threadId).
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from './common.js';
import { checkGitHubRemote } from './check-github-remote.js';
import { executeReviewThreadMutation } from '../../../services/github-pr-comment.service.js';
export interface ResolvePRCommentResult {
success: boolean;
isResolved?: boolean;
error?: string;
}
interface ResolvePRCommentRequest {
projectPath: string;
threadId: string;
resolve: boolean;
}
export function createResolvePRCommentHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, threadId, resolve } = req.body as ResolvePRCommentRequest;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!threadId) {
res.status(400).json({ success: false, error: 'threadId is required' });
return;
}
if (typeof resolve !== 'boolean') {
res.status(400).json({ success: false, error: 'resolve must be a boolean' });
return;
}
// Check if this is a GitHub repo
const remoteStatus = await checkGitHubRemote(projectPath);
if (!remoteStatus.hasGitHubRemote) {
res.status(400).json({
success: false,
error: 'Project does not have a GitHub remote',
});
return;
}
const result = await executeReviewThreadMutation(projectPath, threadId, resolve);
res.json({
success: true,
isResolved: result.isResolved,
});
} catch (error) {
logError(error, 'Resolve PR comment failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -38,7 +38,7 @@ import {
import { import {
getPromptCustomization, getPromptCustomization,
getAutoLoadClaudeMdSetting, getAutoLoadClaudeMdSetting,
getProviderByModelId, resolveProviderContext,
} from '../../../lib/settings-helpers.js'; } from '../../../lib/settings-helpers.js';
import { import {
trySetValidationRunning, trySetValidationRunning,
@@ -64,6 +64,8 @@ interface ValidateIssueRequestBody {
thinkingLevel?: ThinkingLevel; thinkingLevel?: ThinkingLevel;
/** Reasoning effort for Codex models (ignored for non-Codex models) */ /** Reasoning effort for Codex models (ignored for non-Codex models) */
reasoningEffort?: ReasoningEffort; reasoningEffort?: ReasoningEffort;
/** Optional Claude-compatible provider ID for custom providers (e.g., GLM, MiniMax) */
providerId?: string;
/** Comments to include in validation analysis */ /** Comments to include in validation analysis */
comments?: GitHubComment[]; comments?: GitHubComment[];
/** Linked pull requests for this issue */ /** Linked pull requests for this issue */
@@ -87,6 +89,7 @@ async function runValidation(
events: EventEmitter, events: EventEmitter,
abortController: AbortController, abortController: AbortController,
settingsService?: SettingsService, settingsService?: SettingsService,
providerId?: string,
comments?: ValidationComment[], comments?: ValidationComment[],
linkedPRs?: ValidationLinkedPR[], linkedPRs?: ValidationLinkedPR[],
thinkingLevel?: ThinkingLevel, thinkingLevel?: ThinkingLevel,
@@ -176,7 +179,12 @@ ${basePrompt}`;
let credentials = await settingsService?.getCredentials(); let credentials = await settingsService?.getCredentials();
if (settingsService) { if (settingsService) {
const providerResult = await getProviderByModelId(model, settingsService, '[ValidateIssue]'); const providerResult = await resolveProviderContext(
settingsService,
model,
providerId,
'[ValidateIssue]'
);
if (providerResult.provider) { if (providerResult.provider) {
claudeCompatibleProvider = providerResult.provider; claudeCompatibleProvider = providerResult.provider;
providerResolvedModel = providerResult.resolvedModel; providerResolvedModel = providerResult.resolvedModel;
@@ -312,10 +320,16 @@ export function createValidateIssueHandler(
model = 'opus', model = 'opus',
thinkingLevel, thinkingLevel,
reasoningEffort, reasoningEffort,
providerId,
comments: rawComments, comments: rawComments,
linkedPRs: rawLinkedPRs, linkedPRs: rawLinkedPRs,
} = req.body as ValidateIssueRequestBody; } = req.body as ValidateIssueRequestBody;
const normalizedProviderId =
typeof providerId === 'string' && providerId.trim().length > 0
? providerId.trim()
: undefined;
// Transform GitHubComment[] to ValidationComment[] if provided // Transform GitHubComment[] to ValidationComment[] if provided
const validationComments: ValidationComment[] | undefined = rawComments?.map((c) => ({ const validationComments: ValidationComment[] | undefined = rawComments?.map((c) => ({
author: c.author?.login || 'ghost', author: c.author?.login || 'ghost',
@@ -364,12 +378,14 @@ export function createValidateIssueHandler(
isClaudeModel(model) || isClaudeModel(model) ||
isCursorModel(model) || isCursorModel(model) ||
isCodexModel(model) || isCodexModel(model) ||
isOpencodeModel(model); isOpencodeModel(model) ||
!!normalizedProviderId;
if (!isValidModel) { if (!isValidModel) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: 'Invalid model. Must be a Claude, Cursor, Codex, or OpenCode model ID (or alias).', error:
'Invalid model. Must be a Claude, Cursor, Codex, or OpenCode model ID (or alias), or provide a valid providerId for custom Claude-compatible models.',
}); });
return; return;
} }
@@ -398,6 +414,7 @@ export function createValidateIssueHandler(
events, events,
abortController, abortController,
settingsService, settingsService,
normalizedProviderId,
validationComments, validationComments,
validationLinkedPRs, validationLinkedPRs,
thinkingLevel, thinkingLevel,

View File

@@ -80,6 +80,12 @@ function containsAuthError(text: string): boolean {
export function createVerifyClaudeAuthHandler() { export function createVerifyClaudeAuthHandler() {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
// In E2E/CI mock mode, skip real API calls
if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
res.json({ success: true, authenticated: true });
return;
}
// Get the auth method and optional API key from the request body // Get the auth method and optional API key from the request body
const { authMethod, apiKey } = req.body as { const { authMethod, apiKey } = req.body as {
authMethod?: 'cli' | 'api_key'; authMethod?: 'cli' | 'api_key';

View File

@@ -82,6 +82,12 @@ function isRateLimitError(text: string): boolean {
export function createVerifyCodexAuthHandler() { export function createVerifyCodexAuthHandler() {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
// In E2E/CI mock mode, skip real API calls
if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
res.json({ success: true, authenticated: true });
return;
}
const { authMethod, apiKey } = req.body as { const { authMethod, apiKey } = req.body as {
authMethod?: 'cli' | 'api_key'; authMethod?: 'cli' | 'api_key';
apiKey?: string; apiKey?: string;

View File

@@ -67,11 +67,16 @@ import { createAbortOperationHandler } from './routes/abort-operation.js';
import { createContinueOperationHandler } from './routes/continue-operation.js'; import { createContinueOperationHandler } from './routes/continue-operation.js';
import { createStageFilesHandler } from './routes/stage-files.js'; import { createStageFilesHandler } from './routes/stage-files.js';
import { createCheckChangesHandler } from './routes/check-changes.js'; import { createCheckChangesHandler } from './routes/check-changes.js';
import { createSetTrackingHandler } from './routes/set-tracking.js';
import { createSyncHandler } from './routes/sync.js';
import { createUpdatePRNumberHandler } from './routes/update-pr-number.js';
import type { SettingsService } from '../../services/settings-service.js'; import type { SettingsService } from '../../services/settings-service.js';
import type { FeatureLoader } from '../../services/feature-loader.js';
export function createWorktreeRoutes( export function createWorktreeRoutes(
events: EventEmitter, events: EventEmitter,
settingsService?: SettingsService settingsService?: SettingsService,
featureLoader?: FeatureLoader
): Router { ): Router {
const router = Router(); const router = Router();
@@ -91,9 +96,19 @@ export function createWorktreeRoutes(
validatePathParams('projectPath'), validatePathParams('projectPath'),
createCreateHandler(events, settingsService) createCreateHandler(events, settingsService)
); );
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler()); router.post(
'/delete',
validatePathParams('projectPath', 'worktreePath'),
createDeleteHandler(events, featureLoader)
);
router.post('/create-pr', createCreatePRHandler()); router.post('/create-pr', createCreatePRHandler());
router.post('/pr-info', createPRInfoHandler()); router.post('/pr-info', createPRInfoHandler());
router.post(
'/update-pr-number',
validatePathParams('worktreePath', 'projectPath?'),
requireValidWorktree,
createUpdatePRNumberHandler()
);
router.post( router.post(
'/commit', '/commit',
validatePathParams('worktreePath'), validatePathParams('worktreePath'),
@@ -118,6 +133,18 @@ export function createWorktreeRoutes(
requireValidWorktree, requireValidWorktree,
createPullHandler() createPullHandler()
); );
router.post(
'/sync',
validatePathParams('worktreePath'),
requireValidWorktree,
createSyncHandler()
);
router.post(
'/set-tracking',
validatePathParams('worktreePath'),
requireValidWorktree,
createSetTrackingHandler()
);
router.post( router.post(
'/checkout-branch', '/checkout-branch',
validatePathParams('worktreePath'), validatePathParams('worktreePath'),

View File

@@ -4,7 +4,8 @@
* This endpoint handles worktree creation with proper checks: * This endpoint handles worktree creation with proper checks:
* 1. First checks if git already has a worktree for the branch (anywhere) * 1. First checks if git already has a worktree for the branch (anywhere)
* 2. If found, returns the existing worktree (no error) * 2. If found, returns the existing worktree (no error)
* 3. Only creates a new worktree if none exists for the branch * 3. Syncs the base branch from its remote tracking branch (fast-forward only)
* 4. Only creates a new worktree if none exists for the branch
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
@@ -27,6 +28,10 @@ import { execGitCommand } from '../../../lib/git.js';
import { trackBranch } from './branch-tracking.js'; import { trackBranch } from './branch-tracking.js';
import { createLogger } from '@automaker/utils'; import { createLogger } from '@automaker/utils';
import { runInitScript } from '../../../services/init-script-service.js'; import { runInitScript } from '../../../services/init-script-service.js';
import {
syncBaseBranch,
type BaseBranchSyncResult,
} from '../../../services/branch-sync-service.js';
const logger = createLogger('Worktree'); const logger = createLogger('Worktree');
@@ -193,6 +198,52 @@ export function createCreateHandler(events: EventEmitter, settingsService?: Sett
logger.warn(`Failed to fetch from remotes: ${getErrorMessage(fetchErr)}`); logger.warn(`Failed to fetch from remotes: ${getErrorMessage(fetchErr)}`);
} }
// Sync the base branch with its remote tracking branch (fast-forward only).
// This ensures the new worktree starts from an up-to-date state rather than
// a potentially stale local copy. If the sync fails or the branch has diverged,
// we proceed with the local copy and inform the user.
const effectiveBase = baseBranch || 'HEAD';
let syncResult: BaseBranchSyncResult = { attempted: false, synced: false };
// Only sync if the base is a real branch (not 'HEAD')
// Pass skipFetch=true because we already fetched all remotes above.
if (effectiveBase !== 'HEAD') {
logger.info(`Syncing base branch '${effectiveBase}' before creating worktree`);
syncResult = await syncBaseBranch(projectPath, effectiveBase, true);
if (syncResult.attempted) {
if (syncResult.synced) {
logger.info(`Base branch sync result: ${syncResult.message}`);
} else {
logger.warn(`Base branch sync result: ${syncResult.message}`);
}
}
} else {
// When using HEAD, try to sync the currently checked-out branch
// Pass skipFetch=true because we already fetched all remotes above.
try {
const currentBranch = await execGitCommand(
['rev-parse', '--abbrev-ref', 'HEAD'],
projectPath
);
const trimmedBranch = currentBranch.trim();
if (trimmedBranch && trimmedBranch !== 'HEAD') {
logger.info(
`Syncing current branch '${trimmedBranch}' (HEAD) before creating worktree`
);
syncResult = await syncBaseBranch(projectPath, trimmedBranch, true);
if (syncResult.attempted) {
if (syncResult.synced) {
logger.info(`HEAD branch sync result: ${syncResult.message}`);
} else {
logger.warn(`HEAD branch sync result: ${syncResult.message}`);
}
}
}
} catch {
// Could not determine HEAD branch — skip sync
}
}
// Check if branch exists (using array arguments to prevent injection) // Check if branch exists (using array arguments to prevent injection)
let branchExists = false; let branchExists = false;
try { try {
@@ -226,6 +277,19 @@ export function createCreateHandler(events: EventEmitter, settingsService?: Sett
// normalizePath converts to forward slashes for API consistency // normalizePath converts to forward slashes for API consistency
const absoluteWorktreePath = path.resolve(worktreePath); const absoluteWorktreePath = path.resolve(worktreePath);
// Get the commit hash the new worktree is based on for logging
let baseCommitHash: string | undefined;
try {
const hash = await execGitCommand(['rev-parse', '--short', 'HEAD'], absoluteWorktreePath);
baseCommitHash = hash.trim();
} catch {
// Non-critical — just for logging
}
if (baseCommitHash) {
logger.info(`New worktree for '${branchName}' based on commit ${baseCommitHash}`);
}
// Copy configured files into the new worktree before responding // Copy configured files into the new worktree before responding
// This runs synchronously to ensure files are in place before any init script // This runs synchronously to ensure files are in place before any init script
try { try {
@@ -247,6 +311,17 @@ export function createCreateHandler(events: EventEmitter, settingsService?: Sett
path: normalizePath(absoluteWorktreePath), path: normalizePath(absoluteWorktreePath),
branch: branchName, branch: branchName,
isNew: !branchExists, isNew: !branchExists,
baseCommitHash,
...(syncResult.attempted
? {
syncResult: {
synced: syncResult.synced,
remote: syncResult.remote,
message: syncResult.message,
diverged: syncResult.diverged,
},
}
: {}),
}, },
}); });

View File

@@ -5,15 +5,18 @@
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { exec } from 'child_process'; import { exec } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import fs from 'fs/promises';
import { isGitRepo } from '@automaker/git-utils'; import { isGitRepo } from '@automaker/git-utils';
import { getErrorMessage, logError, isValidBranchName } from '../common.js'; import { getErrorMessage, logError, isValidBranchName } from '../common.js';
import { execGitCommand } from '../../../lib/git.js'; import { execGitCommand } from '../../../lib/git.js';
import { createLogger } from '@automaker/utils'; import { createLogger } from '@automaker/utils';
import type { FeatureLoader } from '../../../services/feature-loader.js';
import type { EventEmitter } from '../../../lib/events.js';
const execAsync = promisify(exec); const execAsync = promisify(exec);
const logger = createLogger('Worktree'); const logger = createLogger('Worktree');
export function createDeleteHandler() { export function createDeleteHandler(events: EventEmitter, featureLoader?: FeatureLoader) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { projectPath, worktreePath, deleteBranch } = req.body as { const { projectPath, worktreePath, deleteBranch } = req.body as {
@@ -46,20 +49,79 @@ export function createDeleteHandler() {
}); });
branchName = stdout.trim(); branchName = stdout.trim();
} catch { } catch {
// Could not get branch name // Could not get branch name - worktree directory may already be gone
logger.debug('Could not determine branch for worktree, directory may be missing');
} }
// Remove the worktree (using array arguments to prevent injection) // Remove the worktree (using array arguments to prevent injection)
let removeSucceeded = false;
try { try {
await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath); await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
} catch { removeSucceeded = true;
// Try with prune if remove fails } catch (removeError) {
// `git worktree remove` can fail if the directory is already missing
// or in a bad state. Try pruning stale worktree entries as a fallback.
logger.debug('git worktree remove failed, trying prune', {
error: getErrorMessage(removeError),
});
try {
await execGitCommand(['worktree', 'prune'], projectPath); await execGitCommand(['worktree', 'prune'], projectPath);
// Verify the specific worktree is no longer registered after prune.
// `git worktree prune` exits 0 even if worktreePath was never registered,
// so we must explicitly check the worktree list to avoid false positives.
const { stdout: listOut } = await execAsync('git worktree list --porcelain', {
cwd: projectPath,
});
// Parse porcelain output and check for an exact path match.
// Using substring .includes() can produce false positives when one
// worktree path is a prefix of another (e.g. /foo vs /foobar).
const stillRegistered = listOut
.split('\n')
.filter((line) => line.startsWith('worktree '))
.map((line) => line.slice('worktree '.length).trim())
.some((registeredPath) => registeredPath === worktreePath);
if (stillRegistered) {
// Prune didn't clean up our entry - treat as failure
throw removeError;
}
removeSucceeded = true;
} catch (pruneError) {
// If pruneError is the original removeError re-thrown, propagate it
if (pruneError === removeError) {
throw removeError;
}
logger.warn('git worktree prune also failed', {
error: getErrorMessage(pruneError),
});
// If both remove and prune fail, still try to return success
// if the worktree directory no longer exists (it may have been
// manually deleted already).
let dirExists = false;
try {
await fs.access(worktreePath);
dirExists = true;
} catch {
// Directory doesn't exist
}
if (dirExists) {
// Directory still exists - this is a real failure
throw removeError;
}
// Directory is gone, treat as success
removeSucceeded = true;
}
} }
// Optionally delete the branch // Optionally delete the branch (only if worktree was successfully removed)
let branchDeleted = false; let branchDeleted = false;
if (deleteBranch && branchName && branchName !== 'main' && branchName !== 'master') { if (
removeSucceeded &&
deleteBranch &&
branchName &&
branchName !== 'main' &&
branchName !== 'master'
) {
// Validate branch name to prevent command injection // Validate branch name to prevent command injection
if (!isValidBranchName(branchName)) { if (!isValidBranchName(branchName)) {
logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`); logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`);
@@ -74,12 +136,65 @@ export function createDeleteHandler() {
} }
} }
// Emit worktree:deleted event after successful deletion
events.emit('worktree:deleted', {
worktreePath,
projectPath,
branchName,
branchDeleted,
});
// Move features associated with the deleted branch to the main worktree
// This prevents features from being orphaned when a worktree is deleted
let featuresMovedToMain = 0;
if (featureLoader && branchName) {
try {
const allFeatures = await featureLoader.getAll(projectPath);
const affectedFeatures = allFeatures.filter((f) => f.branchName === branchName);
for (const feature of affectedFeatures) {
try {
await featureLoader.update(projectPath, feature.id, {
branchName: null,
});
featuresMovedToMain++;
// Emit feature:migrated event for each successfully migrated feature
events.emit('feature:migrated', {
featureId: feature.id,
status: 'migrated',
fromBranch: branchName,
toWorktreeId: null, // migrated to main worktree (no specific worktree)
projectPath,
});
} catch (featureUpdateError) {
// Non-fatal: log per-feature failure but continue migrating others
logger.warn('Failed to move feature to main worktree after deletion', {
error: getErrorMessage(featureUpdateError),
featureId: feature.id,
branchName,
});
}
}
if (featuresMovedToMain > 0) {
logger.info(
`Moved ${featuresMovedToMain} feature(s) to main worktree after deleting worktree with branch: ${branchName}`
);
}
} catch (featureError) {
// Non-fatal: log but don't fail the deletion (getAll failed)
logger.warn('Failed to load features for migration to main worktree after deletion', {
error: getErrorMessage(featureError),
branchName,
});
}
}
res.json({ res.json({
success: true, success: true,
deleted: { deleted: {
worktreePath, worktreePath,
branch: branchDeleted ? branchName : null, branch: branchDeleted ? branchName : null,
branchDeleted, branchDeleted,
featuresMovedToMain,
}, },
}); });
} catch (error) { } catch (error) {

View File

@@ -5,12 +5,12 @@
* 1. Discard ALL changes (when no files array is provided) * 1. Discard ALL changes (when no files array is provided)
* - Resets staged changes (git reset HEAD) * - Resets staged changes (git reset HEAD)
* - Discards modified tracked files (git checkout .) * - Discards modified tracked files (git checkout .)
* - Removes untracked files and directories (git clean -fd) * - Removes untracked files and directories (git clean -ffd)
* *
* 2. Discard SELECTED files (when files array is provided) * 2. Discard SELECTED files (when files array is provided)
* - Unstages selected staged files (git reset HEAD -- <files>) * - Unstages selected staged files (git reset HEAD -- <files>)
* - Reverts selected tracked file changes (git checkout -- <files>) * - Reverts selected tracked file changes (git checkout -- <files>)
* - Removes selected untracked files (git clean -fd -- <files>) * - Removes selected untracked files (git clean -ffd -- <files>)
* *
* Note: Git repository validation (isGitRepo) is handled by * Note: Git repository validation (isGitRepo) is handled by
* the requireGitRepoOnly middleware in index.ts * the requireGitRepoOnly middleware in index.ts
@@ -52,6 +52,22 @@ function validateFilePath(filePath: string, worktreePath: string): boolean {
} }
} }
/**
* Parse a file path from git status --porcelain output, handling renames.
* For renamed files (R status), git reports "old_path -> new_path" and
* we need the new path to match what parseGitStatus() returns in git-utils.
*/
function parseFilePath(rawPath: string, indexStatus: string, workTreeStatus: string): string {
const trimmedPath = rawPath.trim();
if (indexStatus === 'R' || workTreeStatus === 'R') {
const arrowIndex = trimmedPath.indexOf(' -> ');
if (arrowIndex !== -1) {
return trimmedPath.slice(arrowIndex + 4);
}
}
return trimmedPath;
}
export function createDiscardChangesHandler() { export function createDiscardChangesHandler() {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
@@ -91,11 +107,16 @@ export function createDiscardChangesHandler() {
// Parse the status output to categorize files // Parse the status output to categorize files
// Git --porcelain format: XY PATH where X=index status, Y=worktree status // Git --porcelain format: XY PATH where X=index status, Y=worktree status
// Preserve the exact two-character XY status (no trim) to keep index vs worktree info // For renamed files: XY OLD_PATH -> NEW_PATH
const statusLines = status.trim().split('\n').filter(Boolean); const statusLines = status.trim().split('\n').filter(Boolean);
const allFiles = statusLines.map((line) => { const allFiles = statusLines.map((line) => {
const fileStatus = line.substring(0, 2); const fileStatus = line.substring(0, 2);
const filePath = line.slice(3).trim(); const rawPath = line.slice(3);
const indexStatus = fileStatus.charAt(0);
const workTreeStatus = fileStatus.charAt(1);
// Parse path consistently with parseGitStatus() in git-utils,
// which extracts the new path for renames
const filePath = parseFilePath(rawPath, indexStatus, workTreeStatus);
return { status: fileStatus, path: filePath }; return { status: fileStatus, path: filePath };
}); });
@@ -122,8 +143,12 @@ export function createDiscardChangesHandler() {
const untrackedFiles: string[] = []; // Untracked files (?) const untrackedFiles: string[] = []; // Untracked files (?)
const warnings: string[] = []; const warnings: string[] = [];
// Track which requested files were matched so we can handle unmatched ones
const matchedFiles = new Set<string>();
for (const file of allFiles) { for (const file of allFiles) {
if (!filesToDiscard.has(file.path)) continue; if (!filesToDiscard.has(file.path)) continue;
matchedFiles.add(file.path);
// file.status is the raw two-character XY git porcelain status (no trim) // file.status is the raw two-character XY git porcelain status (no trim)
// X = index/staging status, Y = worktree status // X = index/staging status, Y = worktree status
@@ -151,6 +176,16 @@ export function createDiscardChangesHandler() {
} }
} }
// Handle files from the UI that didn't match any entry in allFiles.
// This can happen due to timing differences between the UI loading diffs
// and the discard request, or path format differences.
// Attempt to clean unmatched files directly as untracked files.
for (const requestedFile of files) {
if (!matchedFiles.has(requestedFile)) {
untrackedFiles.push(requestedFile);
}
}
// 1. Unstage selected staged files (using execFile to bypass shell) // 1. Unstage selected staged files (using execFile to bypass shell)
if (stagedFiles.length > 0) { if (stagedFiles.length > 0) {
try { try {
@@ -174,9 +209,10 @@ export function createDiscardChangesHandler() {
} }
// 3. Remove selected untracked files // 3. Remove selected untracked files
// Use -ffd (double force) to also handle nested git repositories
if (untrackedFiles.length > 0) { if (untrackedFiles.length > 0) {
try { try {
await execGitCommand(['clean', '-fd', '--', ...untrackedFiles], worktreePath); await execGitCommand(['clean', '-ffd', '--', ...untrackedFiles], worktreePath);
} catch (error) { } catch (error) {
const msg = getErrorMessage(error); const msg = getErrorMessage(error);
logError(error, `Failed to clean untracked files: ${msg}`); logError(error, `Failed to clean untracked files: ${msg}`);
@@ -234,11 +270,12 @@ export function createDiscardChangesHandler() {
} }
// 3. Remove untracked files and directories // 3. Remove untracked files and directories
// Use -ffd (double force) to also handle nested git repositories
try { try {
await execGitCommand(['clean', '-fd'], worktreePath); await execGitCommand(['clean', '-ffd', '--'], worktreePath);
} catch (error) { } catch (error) {
const msg = getErrorMessage(error); const msg = getErrorMessage(error);
logError(error, `git clean -fd failed: ${msg}`); logError(error, `git clean -ffd failed: ${msg}`);
warnings.push(`Failed to remove untracked files: ${msg}`); warnings.push(`Failed to remove untracked files: ${msg}`);
} }

View File

@@ -6,7 +6,7 @@
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { exec } from 'child_process'; import { execFile } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
@@ -20,7 +20,7 @@ import { getErrorMessage, logError } from '../common.js';
import { getPhaseModelWithOverrides } from '../../../lib/settings-helpers.js'; import { getPhaseModelWithOverrides } from '../../../lib/settings-helpers.js';
const logger = createLogger('GenerateCommitMessage'); const logger = createLogger('GenerateCommitMessage');
const execAsync = promisify(exec); const execFileAsync = promisify(execFile);
/** Timeout for AI provider calls in milliseconds (30 seconds) */ /** Timeout for AI provider calls in milliseconds (30 seconds) */
const AI_TIMEOUT_MS = 30_000; const AI_TIMEOUT_MS = 30_000;
@@ -33,21 +33,40 @@ async function* withTimeout<T>(
generator: AsyncIterable<T>, generator: AsyncIterable<T>,
timeoutMs: number timeoutMs: number
): AsyncGenerator<T, void, unknown> { ): AsyncGenerator<T, void, unknown> {
let timerId: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => { const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(`AI provider timed out after ${timeoutMs}ms`)), timeoutMs); timerId = setTimeout(
() => reject(new Error(`AI provider timed out after ${timeoutMs}ms`)),
timeoutMs
);
}); });
const iterator = generator[Symbol.asyncIterator](); const iterator = generator[Symbol.asyncIterator]();
let done = false; let done = false;
try {
while (!done) { while (!done) {
const result = await Promise.race([iterator.next(), timeoutPromise]); const result = await Promise.race([iterator.next(), timeoutPromise]).catch(async (err) => {
// Capture the original error, then attempt to close the iterator.
// If iterator.return() throws, log it but rethrow the original error
// so the timeout error (not the teardown error) is preserved.
try {
await iterator.return?.();
} catch (teardownErr) {
logger.warn('Error during iterator cleanup after timeout:', teardownErr);
}
throw err;
});
if (result.done) { if (result.done) {
done = true; done = true;
} else { } else {
yield result.value; yield result.value;
} }
} }
} finally {
clearTimeout(timerId);
}
} }
/** /**
@@ -117,14 +136,14 @@ export function createGenerateCommitMessageHandler(
let diff = ''; let diff = '';
try { try {
// First try to get staged changes // First try to get staged changes
const { stdout: stagedDiff } = await execAsync('git diff --cached', { const { stdout: stagedDiff } = await execFileAsync('git', ['diff', '--cached'], {
cwd: worktreePath, cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5, // 5MB buffer maxBuffer: 1024 * 1024 * 5, // 5MB buffer
}); });
// If no staged changes, get unstaged changes // If no staged changes, get unstaged changes
if (!stagedDiff.trim()) { if (!stagedDiff.trim()) {
const { stdout: unstagedDiff } = await execAsync('git diff', { const { stdout: unstagedDiff } = await execFileAsync('git', ['diff'], {
cwd: worktreePath, cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5, // 5MB buffer maxBuffer: 1024 * 1024 * 5, // 5MB buffer
}); });
@@ -213,14 +232,16 @@ export function createGenerateCommitMessageHandler(
} }
} }
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) { } else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
// Use result if available (some providers return final text here) // Use result text if longer than accumulated text (consistent with simpleQuery pattern)
if (msg.result.length > responseText.length) {
responseText = msg.result; responseText = msg.result;
} }
} }
}
const message = responseText.trim(); const message = responseText.trim();
if (!message || message.trim().length === 0) { if (!message) {
logger.warn('Received empty response from model'); logger.warn('Received empty response from model');
const response: GenerateCommitMessageErrorResponse = { const response: GenerateCommitMessageErrorResponse = {
success: false, success: false,

View File

@@ -30,6 +30,8 @@ const MAX_DIFF_SIZE = 15_000;
const PR_DESCRIPTION_SYSTEM_PROMPT = `You are a pull request description generator. Your task is to create a clear, well-structured PR title and description based on the git diff and branch information provided. const PR_DESCRIPTION_SYSTEM_PROMPT = `You are a pull request description generator. Your task is to create a clear, well-structured PR title and description based on the git diff and branch information provided.
IMPORTANT: Do NOT include any conversational text, explanations, or preamble. Do NOT say things like "I'll analyze..." or "Here is...". Output ONLY the structured format below and nothing else.
Output your response in EXACTLY this format (including the markers): Output your response in EXACTLY this format (including the markers):
---TITLE--- ---TITLE---
<a concise PR title, 50-72 chars, imperative mood> <a concise PR title, 50-72 chars, imperative mood>
@@ -41,6 +43,7 @@ Output your response in EXACTLY this format (including the markers):
<Detailed list of what was changed and why> <Detailed list of what was changed and why>
Rules: Rules:
- Your ENTIRE response must start with ---TITLE--- and contain nothing before it
- The title should be concise and descriptive (50-72 characters) - The title should be concise and descriptive (50-72 characters)
- Use imperative mood for the title (e.g., "Add dark mode toggle" not "Added dark mode toggle") - Use imperative mood for the title (e.g., "Add dark mode toggle" not "Added dark mode toggle")
- The description should explain WHAT changed and WHY - The description should explain WHAT changed and WHY
@@ -50,7 +53,9 @@ Rules:
- Focus on the user-facing impact when possible - Focus on the user-facing impact when possible
- If there are breaking changes, mention them prominently - If there are breaking changes, mention them prominently
- The diff may include both committed changes and uncommitted working directory changes. Treat all changes as part of the PR since uncommitted changes will be committed when the PR is created - The diff may include both committed changes and uncommitted working directory changes. Treat all changes as part of the PR since uncommitted changes will be committed when the PR is created
- Do NOT distinguish between committed and uncommitted changes in the output - describe all changes as a unified set of PR changes`; - Do NOT distinguish between committed and uncommitted changes in the output - describe all changes as a unified set of PR changes
- EXCLUDE any files that are gitignored (e.g., node_modules, dist, build, .env files, lock files, generated files, binary artifacts, coverage reports, cache directories). These should not be mentioned in the description even if they appear in the diff
- Focus only on meaningful source code changes that are tracked by git and relevant to reviewers`;
/** /**
* Wraps an async generator with a timeout. * Wraps an async generator with a timeout.
@@ -165,127 +170,125 @@ export function createGeneratePRDescriptionHandler(
// Determine the base branch for comparison // Determine the base branch for comparison
const base = baseBranch || 'main'; const base = baseBranch || 'main';
// Get the diff between current branch and base branch (committed changes) // Collect diffs in three layers and combine them:
// Track whether the diff method used only includes committed changes. // 1. Committed changes on the branch: `git diff base...HEAD`
// `git diff base...HEAD` and `git diff origin/base...HEAD` only show committed changes, // 2. Staged (cached) changes not yet committed: `git diff --cached`
// while the fallback methods (`git diff HEAD`, `git diff --cached + git diff`) already // 3. Unstaged changes to tracked files: `git diff` (no --cached flag)
// include uncommitted working directory changes. //
let diff = ''; // Untracked files are intentionally excluded — they are typically build artifacts,
let diffIncludesUncommitted = false; // planning files, hidden dotfiles, or other files unrelated to the PR.
// `git diff` and `git diff --cached` only show changes to files already tracked by git,
// which is exactly the correct scope.
//
// We combine all three sources and deduplicate by file path so that a file modified
// in commits AND with additional uncommitted changes is not double-counted.
/** Parse a unified diff into per-file hunks keyed by file path */
function parseDiffIntoFileHunks(diffText: string): Map<string, string> {
const fileHunks = new Map<string, string>();
if (!diffText.trim()) return fileHunks;
// Split on "diff --git" boundaries (keep the delimiter)
const sections = diffText.split(/(?=^diff --git )/m);
for (const section of sections) {
if (!section.trim()) continue;
// Use a back-reference pattern so the "b/" side must match the "a/" capture,
// correctly handling paths that contain " b/" in their name.
// Falls back to a two-capture pattern to handle renames (a/ and b/ differ).
const backrefMatch = section.match(/^diff --git a\/(.+) b\/\1$/m);
const renameMatch = !backrefMatch ? section.match(/^diff --git a\/(.+) b\/(.+)$/m) : null;
const match = backrefMatch || renameMatch;
if (match) {
// Prefer the backref capture (identical paths); for renames use the destination (match[2])
const filePath = backrefMatch ? match[1] : match[2];
// Merge hunks if the same file appears in multiple diff sources
const existing = fileHunks.get(filePath) ?? '';
fileHunks.set(filePath, existing + section);
}
}
return fileHunks;
}
// --- Step 1: committed changes (branch vs base) ---
let committedDiff = '';
try { try {
// First, try to get diff against the base branch const { stdout } = await execFileAsync('git', ['diff', `${base}...HEAD`], {
const { stdout: branchDiff } = await execFileAsync('git', ['diff', `${base}...HEAD`], {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5, // 5MB buffer
});
diff = branchDiff;
// git diff base...HEAD only shows committed changes
diffIncludesUncommitted = false;
} catch {
// If branch comparison fails (e.g., base branch doesn't exist locally),
// try fetching and comparing against remote base
try {
const { stdout: remoteDiff } = await execFileAsync(
'git',
['diff', `origin/${base}...HEAD`],
{
cwd: worktreePath, cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5, maxBuffer: 1024 * 1024 * 5,
});
committedDiff = stdout;
} catch {
// Base branch may not exist locally; try the remote tracking branch
try {
const { stdout } = await execFileAsync('git', ['diff', `origin/${base}...HEAD`], {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
});
committedDiff = stdout;
} catch {
// Cannot compare against base — leave committedDiff empty; the uncommitted
// changes gathered below will still be included.
logger.warn(`Could not get committed diff against ${base} or origin/${base}`);
} }
}
// --- Step 2: staged changes (tracked files only) ---
let stagedDiff = '';
try {
const { stdout } = await execFileAsync('git', ['diff', '--cached'], {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
});
stagedDiff = stdout;
} catch (err) {
// Non-fatal — staged diff is a best-effort supplement
logger.debug('Failed to get staged diff', err);
}
// --- Step 3: unstaged changes (tracked files only) ---
let unstagedDiff = '';
try {
const { stdout } = await execFileAsync('git', ['diff'], {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
});
unstagedDiff = stdout;
} catch (err) {
// Non-fatal — unstaged diff is a best-effort supplement
logger.debug('Failed to get unstaged diff', err);
}
// --- Combine and deduplicate ---
// Build a map of filePath → diff content by concatenating hunks from all sources
// in chronological order (committed → staged → unstaged) so that no changes
// are lost when a file appears in multiple diff sources.
const combinedFileHunks = new Map<string, string>();
for (const source of [committedDiff, stagedDiff, unstagedDiff]) {
const hunks = parseDiffIntoFileHunks(source);
for (const [filePath, hunk] of hunks) {
if (combinedFileHunks.has(filePath)) {
combinedFileHunks.set(filePath, combinedFileHunks.get(filePath)! + hunk);
} else {
combinedFileHunks.set(filePath, hunk);
}
}
}
const diff = Array.from(combinedFileHunks.values()).join('');
// Log what files were included for observability
if (combinedFileHunks.size > 0) {
logger.info(`PR description scope: ${combinedFileHunks.size} file(s)`);
logger.debug(
`PR description scope files: ${Array.from(combinedFileHunks.keys()).join(', ')}`
); );
diff = remoteDiff;
// git diff origin/base...HEAD only shows committed changes
diffIncludesUncommitted = false;
} catch {
// Fall back to getting all uncommitted + committed changes
try {
const { stdout: allDiff } = await execFileAsync('git', ['diff', 'HEAD'], {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
});
diff = allDiff;
// git diff HEAD includes uncommitted changes
diffIncludesUncommitted = true;
} catch {
// Last resort: get staged + unstaged changes
const { stdout: stagedDiff } = await execFileAsync('git', ['diff', '--cached'], {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
});
const { stdout: unstagedDiff } = await execFileAsync('git', ['diff'], {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
});
diff = stagedDiff + unstagedDiff;
// These already include uncommitted changes
diffIncludesUncommitted = true;
}
}
} }
// Check for uncommitted changes (staged + unstaged) to include in the description. // Also get the commit log for context — always scoped to the selected base branch
// When creating a PR, uncommitted changes will be auto-committed, so they should be // so the log only contains commits that are part of this PR.
// reflected in the generated description. We only need to fetch uncommitted diffs // We do NOT fall back to an unscoped `git log` because that would include commits
// when the primary diff method (base...HEAD) was used, since it only shows committed changes. // from the base branch itself and produce misleading AI context.
let hasUncommittedChanges = false;
try {
const { stdout: statusOutput } = await execFileAsync('git', ['status', '--porcelain'], {
cwd: worktreePath,
});
hasUncommittedChanges = statusOutput.trim().length > 0;
if (hasUncommittedChanges && !diffIncludesUncommitted) {
logger.info('Uncommitted changes detected, including in PR description context');
let uncommittedDiff = '';
// Get staged changes
try {
const { stdout: stagedDiff } = await execFileAsync('git', ['diff', '--cached'], {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
});
if (stagedDiff.trim()) {
uncommittedDiff += stagedDiff;
}
} catch {
// Ignore staged diff errors
}
// Get unstaged changes (tracked files only)
try {
const { stdout: unstagedDiff } = await execFileAsync('git', ['diff'], {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
});
if (unstagedDiff.trim()) {
uncommittedDiff += unstagedDiff;
}
} catch {
// Ignore unstaged diff errors
}
// Get list of untracked files for context
const untrackedFiles = statusOutput
.split('\n')
.filter((line) => line.startsWith('??'))
.map((line) => line.substring(3).trim());
if (untrackedFiles.length > 0) {
// Add a summary of untracked (new) files as context
uncommittedDiff += `\n# New untracked files:\n${untrackedFiles.map((f) => `# + ${f}`).join('\n')}\n`;
}
// Append uncommitted changes to the committed diff
if (uncommittedDiff.trim()) {
diff = diff + uncommittedDiff;
}
}
} catch {
// Ignore errors checking for uncommitted changes
}
// Also get the commit log for context
let commitLog = ''; let commitLog = '';
try { try {
const { stdout: logOutput } = await execFileAsync( const { stdout: logOutput } = await execFileAsync(
@@ -298,11 +301,11 @@ export function createGeneratePRDescriptionHandler(
); );
commitLog = logOutput.trim(); commitLog = logOutput.trim();
} catch { } catch {
// If comparing against base fails, fall back to recent commits // Base branch not available locally — try the remote tracking branch
try { try {
const { stdout: logOutput } = await execFileAsync( const { stdout: logOutput } = await execFileAsync(
'git', 'git',
['log', '--oneline', '-10', '--no-decorate'], ['log', `origin/${base}..HEAD`, '--oneline', '--no-decorate'],
{ {
cwd: worktreePath, cwd: worktreePath,
maxBuffer: 1024 * 1024, maxBuffer: 1024 * 1024,
@@ -310,7 +313,9 @@ export function createGeneratePRDescriptionHandler(
); );
commitLog = logOutput.trim(); commitLog = logOutput.trim();
} catch { } catch {
// Ignore commit log errors // Cannot scope commit log to base branch — leave empty rather than
// including unscoped commits that would pollute the AI context.
logger.warn(`Could not get commit log against ${base} or origin/${base}`);
} }
} }
@@ -336,10 +341,6 @@ export function createGeneratePRDescriptionHandler(
userPrompt += `\nCommit History:\n${commitLog}\n`; userPrompt += `\nCommit History:\n${commitLog}\n`;
} }
if (hasUncommittedChanges) {
userPrompt += `\nNote: This branch has uncommitted changes that will be included in the PR.\n`;
}
if (truncatedDiff) { if (truncatedDiff) {
userPrompt += `\n\`\`\`diff\n${truncatedDiff}\n\`\`\``; userPrompt += `\n\`\`\`diff\n${truncatedDiff}\n\`\`\``;
} }
@@ -397,9 +398,12 @@ export function createGeneratePRDescriptionHandler(
} }
} }
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) { } else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
// Use result text if longer than accumulated text (consistent with simpleQuery pattern)
if (msg.result.length > responseText.length) {
responseText = msg.result; responseText = msg.result;
} }
} }
}
const fullResponse = responseText.trim(); const fullResponse = responseText.trim();
@@ -413,7 +417,9 @@ export function createGeneratePRDescriptionHandler(
return; return;
} }
// Parse the response to extract title and body // Parse the response to extract title and body.
// The model may include conversational preamble before the structured markers,
// so we search for the markers anywhere in the response, not just at the start.
let title = ''; let title = '';
let body = ''; let body = '';
@@ -424,14 +430,46 @@ export function createGeneratePRDescriptionHandler(
title = titleMatch[1].trim(); title = titleMatch[1].trim();
body = bodyMatch[1].trim(); body = bodyMatch[1].trim();
} else { } else {
// Fallback: treat first line as title, rest as body // Fallback: try to extract meaningful content, skipping any conversational preamble.
const lines = fullResponse.split('\n'); // Common preamble patterns start with "I'll", "I will", "Here", "Let me", "Based on", etc.
title = lines[0].trim(); const lines = fullResponse.split('\n').filter((line) => line.trim().length > 0);
body = lines.slice(1).join('\n').trim();
// Skip lines that look like conversational preamble
let startIndex = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// Check if this line looks like conversational AI preamble
if (
/^(I'll|I will|Here('s| is| are)|Let me|Based on|Looking at|Analyzing|Sure|OK|Okay|Of course)/i.test(
line
) ||
/^(The following|Below is|This (is|will)|After (analyzing|reviewing|looking))/i.test(
line
)
) {
startIndex = i + 1;
continue;
}
break;
} }
// Clean up title - remove any markdown or quotes // Use remaining lines after skipping preamble
title = title.replace(/^#+\s*/, '').replace(/^["']|["']$/g, ''); const contentLines = lines.slice(startIndex);
if (contentLines.length > 0) {
title = contentLines[0].trim();
body = contentLines.slice(1).join('\n').trim();
} else {
// If all lines were filtered as preamble, use the original first non-empty line
title = lines[0]?.trim() || '';
body = lines.slice(1).join('\n').trim();
}
}
// Clean up title - remove any markdown headings, quotes, or marker artifacts
title = title
.replace(/^#+\s*/, '')
.replace(/^["']|["']$/g, '')
.replace(/^---\w+---\s*/, '');
logger.info(`Generated PR title: ${title.substring(0, 100)}...`); logger.info(`Generated PR title: ${title.substring(0, 100)}...`);

View File

@@ -44,13 +44,79 @@ export function createInitGitHandler() {
} }
// Initialize git with 'main' as the default branch (matching GitHub's standard since 2020) // Initialize git with 'main' as the default branch (matching GitHub's standard since 2020)
// and create an initial empty commit // Run commands sequentially so failures can be handled and partial state cleaned up.
await execAsync( let gitDirCreated = false;
`git init --initial-branch=main && git commit --allow-empty -m "Initial commit"`, try {
{ // Step 1: initialize the repository
cwd: projectPath, try {
await execAsync(`git init --initial-branch=main`, { cwd: projectPath });
} catch (initError: unknown) {
const stderr =
initError && typeof initError === 'object' && 'stderr' in initError
? String((initError as { stderr?: string }).stderr)
: '';
// Idempotent: if .git was created by a concurrent request or a stale lock exists,
// treat as "repo already exists" instead of failing
if (
/could not lock config file.*File exists|fatal: could not set 'core\.repositoryformatversion'/.test(
stderr
)
) {
try {
await secureFs.access(gitDirPath);
res.json({
success: true,
result: {
initialized: false,
message: 'Git repository already exists',
},
});
return;
} catch {
// .git still missing, rethrow original error
}
}
throw initError;
}
gitDirCreated = true;
// Step 2: ensure user.name and user.email are set so the commit can succeed.
// Check the global/system config first; only set locally if missing.
let userName = '';
let userEmail = '';
try {
({ stdout: userName } = await execAsync(`git config user.name`, { cwd: projectPath }));
} catch {
// not set globally will configure locally below
}
try {
({ stdout: userEmail } = await execAsync(`git config user.email`, {
cwd: projectPath,
}));
} catch {
// not set globally will configure locally below
}
if (!userName.trim()) {
await execAsync(`git config user.name "Automaker"`, { cwd: projectPath });
}
if (!userEmail.trim()) {
await execAsync(`git config user.email "automaker@localhost"`, { cwd: projectPath });
}
// Step 3: create the initial empty commit
await execAsync(`git commit --allow-empty -m "Initial commit"`, { cwd: projectPath });
} catch (error: unknown) {
// Clean up the partial .git directory so subsequent runs behave deterministically
if (gitDirCreated) {
try {
await secureFs.rm(gitDirPath, { recursive: true, force: true });
} catch {
// best-effort cleanup; ignore errors
}
}
throw error;
} }
);
res.json({ res.json({
success: true, success: true,

View File

@@ -6,11 +6,11 @@
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { exec, execFile } from 'child_process'; import { execFile } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import { getErrorMessage, logWorktreeError } from '../common.js'; import { getErrorMessage, logWorktreeError, execGitCommand } from '../common.js';
import { getRemotesWithBranch } from '../../../services/worktree-service.js';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
interface BranchInfo { interface BranchInfo {
@@ -35,18 +35,18 @@ export function createListBranchesHandler() {
return; return;
} }
// Get current branch // Get current branch (execGitCommand avoids spawning /bin/sh; works in sandboxed CI)
const { stdout: currentBranchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { const currentBranchOutput = await execGitCommand(
cwd: worktreePath, ['rev-parse', '--abbrev-ref', 'HEAD'],
}); worktreePath
);
const currentBranch = currentBranchOutput.trim(); const currentBranch = currentBranchOutput.trim();
// List all local branches // List all local branches
// Use double quotes around the format string for cross-platform compatibility const branchesOutput = await execGitCommand(
// Single quotes are preserved literally on Windows; double quotes work on both ['branch', '--format=%(refname:short)'],
const { stdout: branchesOutput } = await execAsync('git branch --format="%(refname:short)"', { worktreePath
cwd: worktreePath, );
});
const branches: BranchInfo[] = branchesOutput const branches: BranchInfo[] = branchesOutput
.trim() .trim()
@@ -67,18 +67,15 @@ export function createListBranchesHandler() {
try { try {
// Fetch latest remote refs (silently, don't fail if offline) // Fetch latest remote refs (silently, don't fail if offline)
try { try {
await execAsync('git fetch --all --quiet', { await execGitCommand(['fetch', '--all', '--quiet'], worktreePath);
cwd: worktreePath,
timeout: 10000, // 10 second timeout
});
} catch { } catch {
// Ignore fetch errors - we'll use cached remote refs // Ignore fetch errors - we'll use cached remote refs
} }
// List remote branches // List remote branches
const { stdout: remoteBranchesOutput } = await execAsync( const remoteBranchesOutput = await execGitCommand(
'git branch -r --format="%(refname:short)"', ['branch', '-r', '--format=%(refname:short)'],
{ cwd: worktreePath } worktreePath
); );
const localBranchNames = new Set(branches.map((b) => b.name)); const localBranchNames = new Set(branches.map((b) => b.name));
@@ -117,9 +114,7 @@ export function createListBranchesHandler() {
// Check if any remotes are configured for this repository // Check if any remotes are configured for this repository
let hasAnyRemotes = false; let hasAnyRemotes = false;
try { try {
const { stdout: remotesOutput } = await execAsync('git remote', { const remotesOutput = await execGitCommand(['remote'], worktreePath);
cwd: worktreePath,
});
hasAnyRemotes = remotesOutput.trim().length > 0; hasAnyRemotes = remotesOutput.trim().length > 0;
} catch { } catch {
// If git remote fails, assume no remotes // If git remote fails, assume no remotes
@@ -131,6 +126,8 @@ export function createListBranchesHandler() {
let behindCount = 0; let behindCount = 0;
let hasRemoteBranch = false; let hasRemoteBranch = false;
let trackingRemote: string | undefined; let trackingRemote: string | undefined;
// List of remote names that have a branch matching the current branch name
let remotesWithBranch: string[] = [];
try { try {
// First check if there's a remote tracking branch // First check if there's a remote tracking branch
const { stdout: upstreamOutput } = await execFileAsync( const { stdout: upstreamOutput } = await execFileAsync(
@@ -172,6 +169,12 @@ export function createListBranchesHandler() {
} }
} }
// Check which remotes have a branch matching the current branch name.
// This helps the UI distinguish between "branch exists on tracking remote" vs
// "branch was pushed to a different remote" (e.g., pushed to 'upstream' but tracking 'origin').
// Use for-each-ref to check cached remote refs (already fetched above if includeRemote was true)
remotesWithBranch = await getRemotesWithBranch(worktreePath, currentBranch, hasAnyRemotes);
res.json({ res.json({
success: true, success: true,
result: { result: {
@@ -182,6 +185,7 @@ export function createListBranchesHandler() {
hasRemoteBranch, hasRemoteBranch,
hasAnyRemotes, hasAnyRemotes,
trackingRemote, trackingRemote,
remotesWithBranch,
}, },
}); });
} catch (error) { } catch (error) {

View File

@@ -13,7 +13,14 @@ import { promisify } from 'util';
import path from 'path'; import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js'; import * as secureFs from '../../../lib/secure-fs.js';
import { isGitRepo } from '@automaker/git-utils'; import { isGitRepo } from '@automaker/git-utils';
import { getErrorMessage, logError, normalizePath, execEnv, isGhCliAvailable } from '../common.js'; import {
getErrorMessage,
logError,
normalizePath,
execEnv,
isGhCliAvailable,
execGitCommand,
} from '../common.js';
import { import {
readAllWorktreeMetadata, readAllWorktreeMetadata,
updateWorktreePRInfo, updateWorktreePRInfo,
@@ -29,6 +36,22 @@ import {
const execAsync = promisify(exec); const execAsync = promisify(exec);
const logger = createLogger('Worktree'); const logger = createLogger('Worktree');
/** True when git (or shell) could not be spawned (e.g. ENOENT in sandboxed CI). */
function isSpawnENOENT(error: unknown): boolean {
if (!error || typeof error !== 'object') return false;
const e = error as { code?: string; errno?: number; syscall?: string };
// Accept ENOENT with or without syscall so wrapped/reexported errors are handled.
// Node may set syscall to 'spawn' or 'spawn git' (or other command name).
if (e.code === 'ENOENT' || e.errno === -2) {
return (
e.syscall === 'spawn' ||
(typeof e.syscall === 'string' && e.syscall.startsWith('spawn')) ||
e.syscall === undefined
);
}
return false;
}
/** /**
* Cache for GitHub remote status per project path. * Cache for GitHub remote status per project path.
* This prevents repeated "no git remotes found" warnings when polling * This prevents repeated "no git remotes found" warnings when polling
@@ -64,6 +87,8 @@ interface WorktreeInfo {
conflictType?: 'merge' | 'rebase' | 'cherry-pick'; conflictType?: 'merge' | 'rebase' | 'cherry-pick';
/** List of files with conflicts */ /** List of files with conflicts */
conflictFiles?: string[]; conflictFiles?: string[];
/** Source branch involved in merge/rebase/cherry-pick, when resolvable */
conflictSourceBranch?: string;
} }
/** /**
@@ -75,13 +100,11 @@ async function detectConflictState(worktreePath: string): Promise<{
hasConflicts: boolean; hasConflicts: boolean;
conflictType?: 'merge' | 'rebase' | 'cherry-pick'; conflictType?: 'merge' | 'rebase' | 'cherry-pick';
conflictFiles?: string[]; conflictFiles?: string[];
conflictSourceBranch?: string;
}> { }> {
try { try {
// Find the canonical .git directory for this worktree // Find the canonical .git directory for this worktree (execGitCommand avoids /bin/sh in CI)
const { stdout: gitDirRaw } = await execAsync('git rev-parse --git-dir', { const gitDirRaw = await execGitCommand(['rev-parse', '--git-dir'], worktreePath);
cwd: worktreePath,
timeout: 15000,
});
const gitDir = path.resolve(worktreePath, gitDirRaw.trim()); const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
// Check for merge, rebase, and cherry-pick state files/directories // Check for merge, rebase, and cherry-pick state files/directories
@@ -121,10 +144,10 @@ async function detectConflictState(worktreePath: string): Promise<{
// Get list of conflicted files using machine-readable git status // Get list of conflicted files using machine-readable git status
let conflictFiles: string[] = []; let conflictFiles: string[] = [];
try { try {
const { stdout: statusOutput } = await execAsync('git diff --name-only --diff-filter=U', { const statusOutput = await execGitCommand(
cwd: worktreePath, ['diff', '--name-only', '--diff-filter=U'],
timeout: 15000, worktreePath
}); );
conflictFiles = statusOutput conflictFiles = statusOutput
.trim() .trim()
.split('\n') .split('\n')
@@ -133,10 +156,84 @@ async function detectConflictState(worktreePath: string): Promise<{
// Fall back to empty list if diff fails // Fall back to empty list if diff fails
} }
// Detect the source branch involved in the conflict
let conflictSourceBranch: string | undefined;
try {
if (conflictType === 'merge' && mergeHeadExists) {
// For merges, resolve MERGE_HEAD to a branch name
const mergeHead = (
(await secureFs.readFile(path.join(gitDir, 'MERGE_HEAD'), 'utf-8')) as string
).trim();
try {
const branchName = await execGitCommand(
['name-rev', '--name-only', '--refs=refs/heads/*', mergeHead],
worktreePath
);
const cleaned = branchName.trim().replace(/~\d+$/, '');
if (cleaned && cleaned !== 'undefined') {
conflictSourceBranch = cleaned;
}
} catch {
// Could not resolve to branch name
}
} else if (conflictType === 'rebase') {
// For rebases, read the onto branch from rebase-merge/head-name or rebase-apply/head-name
const headNamePath = rebaseMergeExists
? path.join(gitDir, 'rebase-merge', 'onto-name')
: path.join(gitDir, 'rebase-apply', 'onto-name');
try {
const ontoName = ((await secureFs.readFile(headNamePath, 'utf-8')) as string).trim();
if (ontoName) {
conflictSourceBranch = ontoName.replace(/^refs\/heads\//, '');
}
} catch {
// onto-name may not exist; try to resolve the onto commit
try {
const ontoPath = rebaseMergeExists
? path.join(gitDir, 'rebase-merge', 'onto')
: path.join(gitDir, 'rebase-apply', 'onto');
const ontoCommit = ((await secureFs.readFile(ontoPath, 'utf-8')) as string).trim();
if (ontoCommit) {
const branchName = await execGitCommand(
['name-rev', '--name-only', '--refs=refs/heads/*', ontoCommit],
worktreePath
);
const cleaned = branchName.trim().replace(/~\d+$/, '');
if (cleaned && cleaned !== 'undefined') {
conflictSourceBranch = cleaned;
}
}
} catch {
// Could not resolve onto commit
}
}
} else if (conflictType === 'cherry-pick' && cherryPickHeadExists) {
// For cherry-picks, try to resolve CHERRY_PICK_HEAD to a branch name
const cherryPickHead = (
(await secureFs.readFile(path.join(gitDir, 'CHERRY_PICK_HEAD'), 'utf-8')) as string
).trim();
try {
const branchName = await execGitCommand(
['name-rev', '--name-only', '--refs=refs/heads/*', cherryPickHead],
worktreePath
);
const cleaned = branchName.trim().replace(/~\d+$/, '');
if (cleaned && cleaned !== 'undefined') {
conflictSourceBranch = cleaned;
}
} catch {
// Could not resolve to branch name
}
}
} catch {
// Ignore source branch detection errors
}
return { return {
hasConflicts: conflictFiles.length > 0, hasConflicts: conflictFiles.length > 0,
conflictType, conflictType,
conflictFiles, conflictFiles,
conflictSourceBranch,
}; };
} catch { } catch {
// If anything fails, assume no conflicts // If anything fails, assume no conflicts
@@ -146,13 +243,69 @@ async function detectConflictState(worktreePath: string): Promise<{
async function getCurrentBranch(cwd: string): Promise<string> { async function getCurrentBranch(cwd: string): Promise<string> {
try { try {
const { stdout } = await execAsync('git branch --show-current', { cwd }); const stdout = await execGitCommand(['branch', '--show-current'], cwd);
return stdout.trim(); return stdout.trim();
} catch { } catch {
return ''; return '';
} }
} }
function normalizeBranchFromHeadRef(headRef: string): string | null {
let normalized = headRef.trim();
const prefixes = ['refs/heads/', 'refs/remotes/origin/', 'refs/remotes/', 'refs/'];
for (const prefix of prefixes) {
if (normalized.startsWith(prefix)) {
normalized = normalized.slice(prefix.length);
break;
}
}
// Return the full branch name, including any slashes (e.g., "feature/my-branch")
return normalized || null;
}
/**
* Attempt to recover the branch name for a worktree in detached HEAD state.
* This happens during rebase operations where git detaches HEAD from the branch.
* We look at git state files (rebase-merge/head-name, rebase-apply/head-name)
* to determine which branch the operation is targeting.
*
* Note: merge conflicts do NOT detach HEAD, so `git worktree list --porcelain`
* still includes the `branch` line for merge conflicts. This recovery is
* specifically for rebase and cherry-pick operations.
*/
async function recoverBranchForDetachedWorktree(worktreePath: string): Promise<string | null> {
try {
const gitDirRaw = await execGitCommand(['rev-parse', '--git-dir'], worktreePath);
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
// During a rebase, the original branch is stored in rebase-merge/head-name
try {
const headNamePath = path.join(gitDir, 'rebase-merge', 'head-name');
const headName = (await secureFs.readFile(headNamePath, 'utf-8')) as string;
const branch = normalizeBranchFromHeadRef(headName);
if (branch) return branch;
} catch {
// Not a rebase-merge
}
// rebase-apply also stores the original branch in head-name
try {
const headNamePath = path.join(gitDir, 'rebase-apply', 'head-name');
const headName = (await secureFs.readFile(headNamePath, 'utf-8')) as string;
const branch = normalizeBranchFromHeadRef(headName);
if (branch) return branch;
} catch {
// Not a rebase-apply
}
return null;
} catch {
return null;
}
}
/** /**
* Scan the .worktrees directory to discover worktrees that may exist on disk * Scan the .worktrees directory to discover worktrees that may exist on disk
* but are not registered with git (e.g., created externally or corrupted state). * but are not registered with git (e.g., created externally or corrupted state).
@@ -204,12 +357,29 @@ async function scanWorktreesDirectory(
}); });
} else { } else {
// Try to get branch from HEAD if branch --show-current fails (detached HEAD) // Try to get branch from HEAD if branch --show-current fails (detached HEAD)
let headBranch: string | null = null;
try { try {
const { stdout: headRef } = await execAsync('git rev-parse --abbrev-ref HEAD', { const headRef = await execGitCommand(
cwd: worktreePath, ['rev-parse', '--abbrev-ref', 'HEAD'],
}); worktreePath
const headBranch = headRef.trim(); );
if (headBranch && headBranch !== 'HEAD') { const ref = headRef.trim();
if (ref && ref !== 'HEAD') {
headBranch = ref;
}
} catch (error) {
// Can't determine branch from HEAD ref (including timeout) - fall back to detached HEAD recovery
logger.debug(
`Failed to resolve HEAD ref for ${worktreePath}: ${getErrorMessage(error)}`
);
}
// If HEAD is detached (rebase/merge in progress), try recovery from git state files
if (!headBranch) {
headBranch = await recoverBranchForDetachedWorktree(worktreePath);
}
if (headBranch) {
logger.info( logger.info(
`Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${headBranch})` `Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${headBranch})`
); );
@@ -218,9 +388,6 @@ async function scanWorktreesDirectory(
branch: headBranch, branch: headBranch,
}); });
} }
} catch {
// Can't determine branch, skip this directory
}
} }
} }
} catch { } catch {
@@ -378,15 +545,14 @@ export function createListHandler() {
// Get current branch in main directory // Get current branch in main directory
const currentBranch = await getCurrentBranch(projectPath); const currentBranch = await getCurrentBranch(projectPath);
// Get actual worktrees from git // Get actual worktrees from git (execGitCommand avoids /bin/sh in sandboxed CI)
const { stdout } = await execAsync('git worktree list --porcelain', { const stdout = await execGitCommand(['worktree', 'list', '--porcelain'], projectPath);
cwd: projectPath,
});
const worktrees: WorktreeInfo[] = []; const worktrees: WorktreeInfo[] = [];
const removedWorktrees: Array<{ path: string; branch: string }> = []; const removedWorktrees: Array<{ path: string; branch: string }> = [];
let hasMissingWorktree = false;
const lines = stdout.split('\n'); const lines = stdout.split('\n');
let current: { path?: string; branch?: string } = {}; let current: { path?: string; branch?: string; isDetached?: boolean } = {};
let isFirst = true; let isFirst = true;
// First pass: detect removed worktrees // First pass: detect removed worktrees
@@ -395,8 +561,11 @@ export function createListHandler() {
current.path = normalizePath(line.slice(9)); current.path = normalizePath(line.slice(9));
} else if (line.startsWith('branch ')) { } else if (line.startsWith('branch ')) {
current.branch = line.slice(7).replace('refs/heads/', ''); current.branch = line.slice(7).replace('refs/heads/', '');
} else if (line.startsWith('detached')) {
// Worktree is in detached HEAD state (e.g., during rebase)
current.isDetached = true;
} else if (line === '') { } else if (line === '') {
if (current.path && current.branch) { if (current.path) {
const isMainWorktree = isFirst; const isMainWorktree = isFirst;
// Check if the worktree directory actually exists // Check if the worktree directory actually exists
// Skip checking/pruning the main worktree (projectPath itself) // Skip checking/pruning the main worktree (projectPath itself)
@@ -407,14 +576,19 @@ export function createListHandler() {
} catch { } catch {
worktreeExists = false; worktreeExists = false;
} }
if (!isMainWorktree && !worktreeExists) { if (!isMainWorktree && !worktreeExists) {
hasMissingWorktree = true;
// Worktree directory doesn't exist - it was manually deleted // Worktree directory doesn't exist - it was manually deleted
// Only add to removed list if we know the branch name
if (current.branch) {
removedWorktrees.push({ removedWorktrees.push({
path: current.path, path: current.path,
branch: current.branch, branch: current.branch,
}); });
} else { }
// Worktree exists (or is main worktree), add it to the list } else if (current.branch) {
// Normal case: worktree with a known branch
worktrees.push({ worktrees.push({
path: current.path, path: current.path,
branch: current.branch, branch: current.branch,
@@ -423,16 +597,29 @@ export function createListHandler() {
hasWorktree: true, hasWorktree: true,
}); });
isFirst = false; isFirst = false;
} else if (current.isDetached && worktreeExists) {
// Detached HEAD (e.g., rebase in progress) - try to recover branch name.
// This is critical: without this, worktrees undergoing rebase/merge
// operations would silently disappear from the UI.
const recoveredBranch = await recoverBranchForDetachedWorktree(current.path);
worktrees.push({
path: current.path,
branch: recoveredBranch || `(detached)`,
isMain: isMainWorktree,
isCurrent: false,
hasWorktree: true,
});
isFirst = false;
} }
} }
current = {}; current = {};
} }
} }
// Prune removed worktrees from git (only if any were detected) // Prune removed worktrees from git (only if any missing worktrees were detected)
if (removedWorktrees.length > 0) { if (hasMissingWorktree) {
try { try {
await execAsync('git worktree prune', { cwd: projectPath }); await execGitCommand(['worktree', 'prune'], projectPath);
} catch { } catch {
// Prune failed, but we'll still report the removed worktrees // Prune failed, but we'll still report the removed worktrees
} }
@@ -461,9 +648,7 @@ export function createListHandler() {
if (includeDetails) { if (includeDetails) {
for (const worktree of worktrees) { for (const worktree of worktrees) {
try { try {
const { stdout: statusOutput } = await execAsync('git status --porcelain', { const statusOutput = await execGitCommand(['status', '--porcelain'], worktree.path);
cwd: worktree.path,
});
const changedFiles = statusOutput const changedFiles = statusOutput
.trim() .trim()
.split('\n') .split('\n')
@@ -486,13 +671,14 @@ export function createListHandler() {
// hasConflicts is true only when there are actual unresolved files // hasConflicts is true only when there are actual unresolved files
worktree.hasConflicts = conflictState.hasConflicts; worktree.hasConflicts = conflictState.hasConflicts;
worktree.conflictFiles = conflictState.conflictFiles; worktree.conflictFiles = conflictState.conflictFiles;
worktree.conflictSourceBranch = conflictState.conflictSourceBranch;
} catch { } catch {
// Ignore conflict detection errors // Ignore conflict detection errors
} }
} }
} }
// Assign PR info to each worktree, preferring fresh GitHub data over cached metadata. // Assign PR info to each worktree.
// Only fetch GitHub PRs if includeDetails is requested (performance optimization). // Only fetch GitHub PRs if includeDetails is requested (performance optimization).
// Uses --state all to detect merged/closed PRs, limited to 1000 recent PRs. // Uses --state all to detect merged/closed PRs, limited to 1000 recent PRs.
const githubPRs = includeDetails const githubPRs = includeDetails
@@ -510,14 +696,27 @@ export function createListHandler() {
const metadata = allMetadata.get(worktree.branch); const metadata = allMetadata.get(worktree.branch);
const githubPR = githubPRs.get(worktree.branch); const githubPR = githubPRs.get(worktree.branch);
if (githubPR) { const metadataPR = metadata?.pr;
// Prefer fresh GitHub data (it has the current state) // Preserve explicit user-selected PR tracking from metadata when it differs
// from branch-derived GitHub PR lookup. This allows "Change PR Number" to
// persist instead of being overwritten by gh pr list for the branch.
const hasManualOverride =
!!metadataPR && !!githubPR && metadataPR.number !== githubPR.number;
if (hasManualOverride) {
worktree.pr = metadataPR;
} else if (githubPR) {
// Use fresh GitHub data when there is no explicit override.
worktree.pr = githubPR; worktree.pr = githubPR;
// Sync metadata with GitHub state when: // Sync metadata when missing or stale so fallback data stays current.
// 1. No metadata exists for this PR (PR created externally) const needsSync =
// 2. State has changed (e.g., merged/closed on GitHub) !metadataPR ||
const needsSync = !metadata?.pr || metadata.pr.state !== githubPR.state; metadataPR.number !== githubPR.number ||
metadataPR.state !== githubPR.state ||
metadataPR.title !== githubPR.title ||
metadataPR.url !== githubPR.url ||
metadataPR.createdAt !== githubPR.createdAt;
if (needsSync) { if (needsSync) {
// Fire and forget - don't block the response // Fire and forget - don't block the response
updateWorktreePRInfo(projectPath, worktree.branch, githubPR).catch((err) => { updateWorktreePRInfo(projectPath, worktree.branch, githubPR).catch((err) => {
@@ -526,9 +725,9 @@ export function createListHandler() {
); );
}); });
} }
} else if (metadata?.pr && metadata.pr.state === 'OPEN') { } else if (metadataPR && metadataPR.state === 'OPEN') {
// Fall back to stored metadata only if the PR is still OPEN // Fall back to stored metadata only if the PR is still OPEN
worktree.pr = metadata.pr; worktree.pr = metadataPR;
} }
} }
@@ -538,6 +737,26 @@ export function createListHandler() {
removedWorktrees: removedWorktrees.length > 0 ? removedWorktrees : undefined, removedWorktrees: removedWorktrees.length > 0 ? removedWorktrees : undefined,
}); });
} catch (error) { } catch (error) {
// When git is unavailable (e.g. sandboxed E2E, PATH without git), return minimal list so UI still loads
if (isSpawnENOENT(error)) {
const projectPathFromBody = (req.body as { projectPath?: string })?.projectPath;
const mainPath = projectPathFromBody ? normalizePath(projectPathFromBody) : undefined;
if (mainPath) {
res.json({
success: true,
worktrees: [
{
path: mainPath,
branch: 'main',
isMain: true,
isCurrent: true,
hasWorktree: true,
},
],
});
return;
}
}
logError(error, 'List worktrees failed'); logError(error, 'List worktrees failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }

View File

@@ -23,9 +23,11 @@ import type { PullResult } from '../../../services/pull-service.js';
export function createPullHandler() { export function createPullHandler() {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { worktreePath, remote, stashIfNeeded } = req.body as { const { worktreePath, remote, remoteBranch, stashIfNeeded } = req.body as {
worktreePath: string; worktreePath: string;
remote?: string; remote?: string;
/** Specific remote branch to pull (e.g. 'main'). When provided, pulls this branch from the remote regardless of tracking config. */
remoteBranch?: string;
/** When true, automatically stash local changes before pulling and reapply after */ /** When true, automatically stash local changes before pulling and reapply after */
stashIfNeeded?: boolean; stashIfNeeded?: boolean;
}; };
@@ -39,7 +41,7 @@ export function createPullHandler() {
} }
// Execute the pull via the service // Execute the pull via the service
const result = await performPull(worktreePath, { remote, stashIfNeeded }); const result = await performPull(worktreePath, { remote, remoteBranch, stashIfNeeded });
// Map service result to HTTP response // Map service result to HTTP response
mapResultToResponse(res, result); mapResultToResponse(res, result);

View File

@@ -1,24 +1,24 @@
/** /**
* POST /push endpoint - Push a worktree branch to remote * POST /push endpoint - Push a worktree branch to remote
* *
* Git business logic is delegated to push-service.ts.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by * Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts * the requireValidWorktree middleware in index.ts
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError } from '../common.js'; import { getErrorMessage, logError } from '../common.js';
import { performPush } from '../../../services/push-service.js';
const execAsync = promisify(exec);
export function createPushHandler() { export function createPushHandler() {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { worktreePath, force, remote } = req.body as { const { worktreePath, force, remote, autoResolve } = req.body as {
worktreePath: string; worktreePath: string;
force?: boolean; force?: boolean;
remote?: string; remote?: string;
autoResolve?: boolean;
}; };
if (!worktreePath) { if (!worktreePath) {
@@ -29,34 +29,28 @@ export function createPushHandler() {
return; return;
} }
// Get branch name const result = await performPush(worktreePath, { remote, force, autoResolve });
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: worktreePath,
});
const branchName = branchOutput.trim();
// Use specified remote or default to 'origin' if (!result.success) {
const targetRemote = remote || 'origin'; const statusCode = isClientError(result.error ?? '') ? 400 : 500;
res.status(statusCode).json({
// Push the branch success: false,
const forceFlag = force ? '--force' : ''; error: result.error,
try { diverged: result.diverged,
await execAsync(`git push -u ${targetRemote} ${branchName} ${forceFlag}`, { hasConflicts: result.hasConflicts,
cwd: worktreePath, conflictFiles: result.conflictFiles,
});
} catch {
// Try setting upstream
await execAsync(`git push --set-upstream ${targetRemote} ${branchName} ${forceFlag}`, {
cwd: worktreePath,
}); });
return;
} }
res.json({ res.json({
success: true, success: true,
result: { result: {
branch: branchName, branch: result.branch,
pushed: true, pushed: result.pushed,
message: `Successfully pushed ${branchName} to ${targetRemote}`, diverged: result.diverged,
autoResolved: result.autoResolved,
message: result.message,
}, },
}); });
} catch (error) { } catch (error) {
@@ -65,3 +59,15 @@ export function createPushHandler() {
} }
}; };
} }
/**
* Determine whether an error message represents a client error (400)
* vs a server error (500).
*/
function isClientError(errorMessage: string): boolean {
return (
errorMessage.includes('detached HEAD') ||
errorMessage.includes('rejected') ||
errorMessage.includes('diverged')
);
}

View File

@@ -0,0 +1,76 @@
/**
* POST /set-tracking endpoint - Set the upstream tracking branch for a worktree
*
* Sets `git branch --set-upstream-to=<remote>/<branch>` for the current branch.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';
import { execGitCommand } from '@automaker/git-utils';
import { getErrorMessage, logError } from '../common.js';
import { getCurrentBranch } from '../../../lib/git.js';
export function createSetTrackingHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, remote, branch } = req.body as {
worktreePath: string;
remote: string;
branch?: string;
};
if (!worktreePath) {
res.status(400).json({ success: false, error: 'worktreePath required' });
return;
}
if (!remote) {
res.status(400).json({ success: false, error: 'remote required' });
return;
}
// Get current branch if not provided
let targetBranch = branch;
if (!targetBranch) {
try {
targetBranch = await getCurrentBranch(worktreePath);
} catch (err) {
res.status(400).json({
success: false,
error: `Failed to get current branch: ${getErrorMessage(err)}`,
});
return;
}
if (targetBranch === 'HEAD') {
res.status(400).json({
success: false,
error: 'Cannot set tracking in detached HEAD state.',
});
return;
}
}
// Set upstream tracking (pass local branch name as final arg to be explicit)
await execGitCommand(
['branch', '--set-upstream-to', `${remote}/${targetBranch}`, targetBranch],
worktreePath
);
res.json({
success: true,
result: {
branch: targetBranch,
remote,
upstream: `${remote}/${targetBranch}`,
message: `Set tracking branch to ${remote}/${targetBranch}`,
},
});
} catch (error) {
logError(error, 'Set tracking branch failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,66 @@
/**
* POST /sync endpoint - Pull then push a worktree branch
*
* Performs a full sync operation: pull latest from remote, then push
* local commits. Handles divergence automatically.
*
* Git business logic is delegated to sync-service.ts.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import { performSync } from '../../../services/sync-service.js';
export function createSyncHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, remote } = req.body as {
worktreePath: string;
remote?: string;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath required',
});
return;
}
const result = await performSync(worktreePath, { remote });
if (!result.success) {
const statusCode = result.hasConflicts ? 409 : 500;
res.status(statusCode).json({
success: false,
error: result.error,
hasConflicts: result.hasConflicts,
conflictFiles: result.conflictFiles,
conflictSource: result.conflictSource,
pulled: result.pulled,
pushed: result.pushed,
});
return;
}
res.json({
success: true,
result: {
branch: result.branch,
pulled: result.pulled,
pushed: result.pushed,
isFastForward: result.isFastForward,
isMerge: result.isMerge,
autoResolved: result.autoResolved,
message: result.message,
},
});
} catch (error) {
logError(error, 'Sync worktree failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,163 @@
/**
* POST /update-pr-number endpoint - Update the tracked PR number for a worktree
*
* Allows users to manually change which PR number is tracked for a worktree branch.
* Fetches updated PR info from GitHub when available, or updates metadata with the
* provided number only if GitHub CLI is unavailable.
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError, execAsync, execEnv, isGhCliAvailable } from '../common.js';
import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js';
import { createLogger } from '@automaker/utils';
import { validatePRState } from '@automaker/types';
const logger = createLogger('UpdatePRNumber');
export function createUpdatePRNumberHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, projectPath, prNumber } = req.body as {
worktreePath: string;
projectPath?: string;
prNumber: number;
};
if (!worktreePath) {
res.status(400).json({ success: false, error: 'worktreePath required' });
return;
}
if (
!prNumber ||
typeof prNumber !== 'number' ||
prNumber <= 0 ||
!Number.isInteger(prNumber)
) {
res.status(400).json({ success: false, error: 'prNumber must be a positive integer' });
return;
}
const effectiveProjectPath = projectPath || worktreePath;
// Get current branch name
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: worktreePath,
env: execEnv,
});
const branchName = branchOutput.trim();
if (!branchName || branchName === 'HEAD') {
res.status(400).json({
success: false,
error: 'Cannot update PR number in detached HEAD state',
});
return;
}
// Try to fetch PR info from GitHub for the given PR number
const ghCliAvailable = await isGhCliAvailable();
if (ghCliAvailable) {
try {
// Detect repository for gh CLI
let repoFlag = '';
try {
const { stdout: remotes } = await execAsync('git remote -v', {
cwd: worktreePath,
env: execEnv,
});
const lines = remotes.split(/\r?\n/);
let upstreamRepo: string | null = null;
let originOwner: string | null = null;
let originRepo: string | null = null;
for (const line of lines) {
const match =
line.match(/^(\w+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/) ||
line.match(/^(\w+)\s+git@[^:]+:([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/) ||
line.match(/^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/);
if (match) {
const [, remoteName, owner, repo] = match;
if (remoteName === 'upstream') {
upstreamRepo = `${owner}/${repo}`;
} else if (remoteName === 'origin') {
originOwner = owner;
originRepo = repo;
}
}
}
const targetRepo =
upstreamRepo || (originOwner && originRepo ? `${originOwner}/${originRepo}` : null);
if (targetRepo) {
repoFlag = ` --repo "${targetRepo}"`;
}
} catch {
// Ignore remote parsing errors
}
// Fetch PR info from GitHub using the PR number
const viewCmd = `gh pr view ${prNumber}${repoFlag} --json number,title,url,state,createdAt`;
const { stdout: prOutput } = await execAsync(viewCmd, {
cwd: worktreePath,
env: execEnv,
});
const prData = JSON.parse(prOutput);
const prInfo = {
number: prData.number,
url: prData.url,
title: prData.title,
state: validatePRState(prData.state),
createdAt: prData.createdAt || new Date().toISOString(),
};
await updateWorktreePRInfo(effectiveProjectPath, branchName, prInfo);
logger.info(`Updated PR tracking to #${prNumber} for branch ${branchName}`);
res.json({
success: true,
result: {
branch: branchName,
prInfo,
},
});
return;
} catch (error) {
logger.warn(`Failed to fetch PR #${prNumber} from GitHub:`, error);
// Fall through to simple update below
}
}
// Fallback: update with just the number, preserving existing PR info structure
// or creating minimal info if no GitHub data available
const prInfo = {
number: prNumber,
url: `https://github.com/pulls/${prNumber}`,
title: `PR #${prNumber}`,
state: validatePRState('OPEN'),
createdAt: new Date().toISOString(),
};
await updateWorktreePRInfo(effectiveProjectPath, branchName, prInfo);
logger.info(`Updated PR tracking to #${prNumber} for branch ${branchName} (no GitHub data)`);
res.json({
success: true,
result: {
branch: branchName,
prInfo,
ghCliUnavailable: !ghCliAvailable,
},
});
} catch (error) {
logError(error, 'Update PR number failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -5,6 +5,7 @@
import type { import type {
PlanningMode, PlanningMode,
ThinkingLevel, ThinkingLevel,
ReasoningEffort,
ParsedTask, ParsedTask,
ClaudeCompatibleProvider, ClaudeCompatibleProvider,
Credentials, Credentials,
@@ -24,11 +25,14 @@ export interface AgentExecutionOptions {
previousContent?: string; previousContent?: string;
systemPrompt?: string; systemPrompt?: string;
autoLoadClaudeMd?: boolean; autoLoadClaudeMd?: boolean;
useClaudeCodeSystemPrompt?: boolean;
thinkingLevel?: ThinkingLevel; thinkingLevel?: ThinkingLevel;
reasoningEffort?: ReasoningEffort;
branchName?: string | null; branchName?: string | null;
credentials?: Credentials; credentials?: Credentials;
claudeCompatibleProvider?: ClaudeCompatibleProvider; claudeCompatibleProvider?: ClaudeCompatibleProvider;
mcpServers?: Record<string, unknown>; mcpServers?: Record<string, unknown>;
sdkSessionId?: string;
sdkOptions?: { sdkOptions?: {
maxTurns?: number; maxTurns?: number;
allowedTools?: string[]; allowedTools?: string[];
@@ -40,6 +44,8 @@ export interface AgentExecutionOptions {
specAlreadyDetected?: boolean; specAlreadyDetected?: boolean;
existingApprovedPlanContent?: string; existingApprovedPlanContent?: string;
persistedTasks?: ParsedTask[]; persistedTasks?: ParsedTask[];
/** Feature status - used to check if pipeline summary extraction is required */
status?: string;
} }
export interface AgentExecutionResult { export interface AgentExecutionResult {

View File

@@ -4,6 +4,7 @@
import path from 'path'; import path from 'path';
import type { ExecuteOptions, ParsedTask } from '@automaker/types'; import type { ExecuteOptions, ParsedTask } from '@automaker/types';
import { isPipelineStatus } from '@automaker/types';
import { buildPromptWithImages, createLogger, isAuthenticationError } from '@automaker/utils'; import { buildPromptWithImages, createLogger, isAuthenticationError } from '@automaker/utils';
import { getFeatureDir } from '@automaker/platform'; import { getFeatureDir } from '@automaker/platform';
import * as secureFs from '../lib/secure-fs.js'; import * as secureFs from '../lib/secure-fs.js';
@@ -38,6 +39,8 @@ export type {
const logger = createLogger('AgentExecutor'); const logger = createLogger('AgentExecutor');
const DEFAULT_MAX_TURNS = 10000;
export class AgentExecutor { export class AgentExecutor {
private static readonly WRITE_DEBOUNCE_MS = 500; private static readonly WRITE_DEBOUNCE_MS = 500;
private static readonly STREAM_HEARTBEAT_MS = 15_000; private static readonly STREAM_HEARTBEAT_MS = 15_000;
@@ -89,8 +92,10 @@ export class AgentExecutor {
existingApprovedPlanContent, existingApprovedPlanContent,
persistedTasks, persistedTasks,
credentials, credentials,
status, // Feature status for pipeline summary check
claudeCompatibleProvider, claudeCompatibleProvider,
mcpServers, mcpServers,
sdkSessionId,
sdkOptions, sdkOptions,
} = options; } = options;
const { content: promptContent } = await buildPromptWithImages( const { content: promptContent } = await buildPromptWithImages(
@@ -99,10 +104,22 @@ export class AgentExecutor {
workDir, workDir,
false false
); );
const resolvedMaxTurns = sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS;
if (sdkOptions?.maxTurns == null) {
logger.info(
`[execute] Feature ${featureId}: sdkOptions.maxTurns is not set, defaulting to ${resolvedMaxTurns}. ` +
`Model: ${effectiveBareModel}`
);
} else {
logger.info(
`[execute] Feature ${featureId}: maxTurns=${resolvedMaxTurns}, model=${effectiveBareModel}`
);
}
const executeOptions: ExecuteOptions = { const executeOptions: ExecuteOptions = {
prompt: promptContent, prompt: promptContent,
model: effectiveBareModel, model: effectiveBareModel,
maxTurns: sdkOptions?.maxTurns, maxTurns: resolvedMaxTurns,
cwd: workDir, cwd: workDir,
allowedTools: sdkOptions?.allowedTools as string[] | undefined, allowedTools: sdkOptions?.allowedTools as string[] | undefined,
abortController, abortController,
@@ -113,8 +130,10 @@ export class AgentExecutor {
? (mcpServers as Record<string, { command: string }>) ? (mcpServers as Record<string, { command: string }>)
: undefined, : undefined,
thinkingLevel: options.thinkingLevel, thinkingLevel: options.thinkingLevel,
reasoningEffort: options.reasoningEffort,
credentials, credentials,
claudeCompatibleProvider, claudeCompatibleProvider,
sdkSessionId,
}; };
const featureDirForOutput = getFeatureDir(projectPath, featureId); const featureDirForOutput = getFeatureDir(projectPath, featureId);
const outputPath = path.join(featureDirForOutput, 'agent-output.md'); const outputPath = path.join(featureDirForOutput, 'agent-output.md');
@@ -190,6 +209,17 @@ export class AgentExecutor {
if (writeTimeout) clearTimeout(writeTimeout); if (writeTimeout) clearTimeout(writeTimeout);
if (rawWriteTimeout) clearTimeout(rawWriteTimeout); if (rawWriteTimeout) clearTimeout(rawWriteTimeout);
await writeToFile(); await writeToFile();
// Extract and save summary from the new content generated in this session
await this.extractAndSaveSessionSummary(
projectPath,
featureId,
result.responseText,
previousContent,
callbacks,
status
);
return { return {
responseText: result.responseText, responseText: result.responseText,
specDetected: true, specDetected: true,
@@ -203,6 +233,9 @@ export class AgentExecutor {
try { try {
const stream = provider.executeQuery(executeOptions); const stream = provider.executeQuery(executeOptions);
streamLoop: for await (const msg of stream) { streamLoop: for await (const msg of stream) {
if (msg.session_id && msg.session_id !== options.sdkSessionId) {
options.sdkSessionId = msg.session_id;
}
receivedAnyStreamMessage = true; receivedAnyStreamMessage = true;
appendRawEvent(msg); appendRawEvent(msg);
if (abortController.signal.aborted) { if (abortController.signal.aborted) {
@@ -276,9 +309,40 @@ export class AgentExecutor {
} }
} }
} else if (msg.type === 'error') { } else if (msg.type === 'error') {
throw new Error(AgentExecutor.sanitizeProviderError(msg.error)); const sanitized = AgentExecutor.sanitizeProviderError(msg.error);
} else if (msg.type === 'result' && msg.subtype === 'success') scheduleWrite(); logger.error(
`[execute] Feature ${featureId} received error from provider. ` +
`raw="${msg.error}", sanitized="${sanitized}", session_id=${msg.session_id ?? 'none'}`
);
throw new Error(sanitized);
} else if (msg.type === 'result') {
if (msg.subtype === 'success') {
scheduleWrite();
} else if (msg.subtype?.startsWith('error')) {
// Non-success result subtypes from the SDK (error_max_turns, error_during_execution, etc.)
logger.error(
`[execute] Feature ${featureId} ended with error subtype: ${msg.subtype}. ` +
`session_id=${msg.session_id ?? 'none'}`
);
throw new Error(`Agent execution ended with: ${msg.subtype}`);
} else {
logger.warn(
`[execute] Feature ${featureId} received unhandled result subtype: ${msg.subtype}`
);
} }
}
}
} finally {
clearInterval(streamHeartbeat);
if (writeTimeout) clearTimeout(writeTimeout);
if (rawWriteTimeout) clearTimeout(rawWriteTimeout);
const streamElapsedMs = Date.now() - streamStartTime;
logger.info(
`[execute] Stream ended for feature ${featureId} after ${Math.round(streamElapsedMs / 1000)}s. ` +
`aborted=${aborted}, specDetected=${specDetected}, responseLength=${responseText.length}`
);
await writeToFile(); await writeToFile();
if (enableRawOutput && rawOutputLines.length > 0) { if (enableRawOutput && rawOutputLines.length > 0) {
try { try {
@@ -288,14 +352,79 @@ export class AgentExecutor {
/* ignore */ /* ignore */
} }
} }
} finally {
clearInterval(streamHeartbeat);
if (writeTimeout) clearTimeout(writeTimeout);
if (rawWriteTimeout) clearTimeout(rawWriteTimeout);
} }
// Capture summary if it hasn't been captured by handleSpecGenerated or executeTasksLoop
// or if we're in a simple execution mode (planningMode='skip')
await this.extractAndSaveSessionSummary(
projectPath,
featureId,
responseText,
previousContent,
callbacks,
status
);
return { responseText, specDetected, tasksCompleted, aborted }; return { responseText, specDetected, tasksCompleted, aborted };
} }
/**
* Strip the follow-up session scaffold marker from content.
* The scaffold is added when resuming a session with previous content:
* "\n\n---\n\n## Follow-up Session\n\n"
* This ensures fallback summaries don't include the scaffold header.
*
* The regex pattern handles variations in whitespace while matching the
* scaffold structure: dashes followed by "## Follow-up Session" at the
* start of the content.
*/
private static stripFollowUpScaffold(content: string): string {
// Pattern matches: ^\s*---\s*##\s*Follow-up Session\s*
// - ^ = start of content (scaffold is always at the beginning of sessionContent)
// - \s* = any whitespace (handles \n\n before ---, spaces/tabs between markers)
// - --- = literal dashes
// - \s* = whitespace between dashes and heading
// - ## = heading marker
// - \s* = whitespace before "Follow-up"
// - Follow-up Session = literal heading text
// - \s* = trailing whitespace/newlines after heading
const scaffoldPattern = /^\s*---\s*##\s*Follow-up Session\s*/;
return content.replace(scaffoldPattern, '');
}
/**
* Extract summary ONLY from the new content generated in this session
* and save it via the provided callback.
*/
private async extractAndSaveSessionSummary(
projectPath: string,
featureId: string,
responseText: string,
previousContent: string | undefined,
callbacks: AgentExecutorCallbacks,
status?: string
): Promise<void> {
const sessionContent = responseText.substring(previousContent ? previousContent.length : 0);
const summary = extractSummary(sessionContent);
if (summary) {
await callbacks.saveFeatureSummary(projectPath, featureId, summary);
return;
}
// If we're in a pipeline step, a summary is expected. Use a fallback if extraction fails.
if (isPipelineStatus(status)) {
// Strip any follow-up session scaffold before using as fallback
const cleanSessionContent = AgentExecutor.stripFollowUpScaffold(sessionContent);
const fallback = cleanSessionContent.trim();
if (fallback) {
await callbacks.saveFeatureSummary(projectPath, featureId, fallback);
}
logger.warn(
`[AgentExecutor] Mandatory summary extraction failed for pipeline feature ${featureId} (status="${status}")`
);
}
}
private async executeTasksLoop( private async executeTasksLoop(
options: AgentExecutionOptions, options: AgentExecutionOptions,
tasks: ParsedTask[], tasks: ParsedTask[],
@@ -351,14 +480,22 @@ export class AgentExecutor {
taskPrompts.taskExecution.taskPromptTemplate, taskPrompts.taskExecution.taskPromptTemplate,
userFeedback userFeedback
); );
const taskMaxTurns = sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS;
logger.info(
`[executeTasksLoop] Feature ${featureId}, task ${task.id} (${taskIndex + 1}/${tasks.length}): ` +
`maxTurns=${taskMaxTurns} (sdkOptions.maxTurns=${sdkOptions?.maxTurns ?? 'undefined'})`
);
const taskStream = provider.executeQuery( const taskStream = provider.executeQuery(
this.buildExecOpts(options, taskPrompt, Math.min(sdkOptions?.maxTurns ?? 100, 100)) this.buildExecOpts(options, taskPrompt, taskMaxTurns)
); );
let taskOutput = '', let taskOutput = '',
taskStartDetected = false, taskStartDetected = false,
taskCompleteDetected = false; taskCompleteDetected = false;
for await (const msg of taskStream) { for await (const msg of taskStream) {
if (msg.session_id && msg.session_id !== options.sdkSessionId) {
options.sdkSessionId = msg.session_id;
}
if (msg.type === 'assistant' && msg.message?.content) { if (msg.type === 'assistant' && msg.message?.content) {
for (const b of msg.message.content) { for (const b of msg.message.content) {
if (b.type === 'text') { if (b.type === 'text') {
@@ -384,14 +521,15 @@ export class AgentExecutor {
} }
} }
if (!taskCompleteDetected) { if (!taskCompleteDetected) {
const cid = detectTaskCompleteMarker(taskOutput); const completeMarker = detectTaskCompleteMarker(taskOutput);
if (cid) { if (completeMarker) {
taskCompleteDetected = true; taskCompleteDetected = true;
await this.featureStateManager.updateTaskStatus( await this.featureStateManager.updateTaskStatus(
projectPath, projectPath,
featureId, featureId,
cid, completeMarker.id,
'completed' 'completed',
completeMarker.summary
); );
} }
} }
@@ -412,16 +550,28 @@ export class AgentExecutor {
}); });
} }
} else if (msg.type === 'error') { } else if (msg.type === 'error') {
// Clean the error: strip ANSI codes and redundant "Error: " prefix const fallback = `Error during task ${task.id}`;
const cleanedError = const sanitized = AgentExecutor.sanitizeProviderError(msg.error || fallback);
(msg.error || `Error during task ${task.id}`) logger.error(
.replace(/\x1b\[[0-9;]*m/g, '') `[executeTasksLoop] Feature ${featureId} task ${task.id} received error from provider. ` +
.replace(/^Error:\s*/i, '') `raw="${msg.error}", sanitized="${sanitized}", session_id=${msg.session_id ?? 'none'}`
.trim() || `Error during task ${task.id}`; );
throw new Error(cleanedError); throw new Error(sanitized);
} else if (msg.type === 'result' && msg.subtype === 'success') { } else if (msg.type === 'result') {
if (msg.subtype === 'success') {
taskOutput += msg.result || ''; taskOutput += msg.result || '';
responseText += msg.result || ''; responseText += msg.result || '';
} else if (msg.subtype?.startsWith('error')) {
logger.error(
`[executeTasksLoop] Feature ${featureId} task ${task.id} ended with error subtype: ${msg.subtype}. ` +
`session_id=${msg.session_id ?? 'none'}`
);
throw new Error(`Agent execution ended with: ${msg.subtype}`);
} else {
logger.warn(
`[executeTasksLoop] Feature ${featureId} task ${task.id} received unhandled result subtype: ${msg.subtype}`
);
}
} }
} }
if (!taskCompleteDetected) if (!taskCompleteDetected)
@@ -457,8 +607,6 @@ export class AgentExecutor {
} }
} }
} }
const summary = extractSummary(responseText);
if (summary) await callbacks.saveFeatureSummary(projectPath, featureId, summary);
return { responseText, tasksCompleted, aborted: false }; return { responseText, tasksCompleted, aborted: false };
} }
@@ -571,8 +719,11 @@ export class AgentExecutor {
}); });
let revText = ''; let revText = '';
for await (const msg of provider.executeQuery( for await (const msg of provider.executeQuery(
this.buildExecOpts(options, revPrompt, sdkOptions?.maxTurns ?? 100) this.buildExecOpts(options, revPrompt, sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS)
)) { )) {
if (msg.session_id && msg.session_id !== options.sdkSessionId) {
options.sdkSessionId = msg.session_id;
}
if (msg.type === 'assistant' && msg.message?.content) if (msg.type === 'assistant' && msg.message?.content)
for (const b of msg.message.content) for (const b of msg.message.content)
if (b.type === 'text') { if (b.type === 'text') {
@@ -652,12 +803,10 @@ export class AgentExecutor {
); );
responseText = r.responseText; responseText = r.responseText;
} }
const summary = extractSummary(responseText);
if (summary) await callbacks.saveFeatureSummary(projectPath, featureId, summary);
return { responseText, tasksCompleted }; return { responseText, tasksCompleted };
} }
private buildExecOpts(o: AgentExecutionOptions, prompt: string, maxTurns?: number) { private buildExecOpts(o: AgentExecutionOptions, prompt: string, maxTurns: number) {
return { return {
prompt, prompt,
model: o.effectiveBareModel, model: o.effectiveBareModel,
@@ -666,12 +815,14 @@ export class AgentExecutor {
allowedTools: o.sdkOptions?.allowedTools as string[] | undefined, allowedTools: o.sdkOptions?.allowedTools as string[] | undefined,
abortController: o.abortController, abortController: o.abortController,
thinkingLevel: o.thinkingLevel, thinkingLevel: o.thinkingLevel,
reasoningEffort: o.reasoningEffort,
mcpServers: mcpServers:
o.mcpServers && Object.keys(o.mcpServers).length > 0 o.mcpServers && Object.keys(o.mcpServers).length > 0
? (o.mcpServers as Record<string, { command: string }>) ? (o.mcpServers as Record<string, { command: string }>)
: undefined, : undefined,
credentials: o.credentials, credentials: o.credentials,
claudeCompatibleProvider: o.claudeCompatibleProvider, claudeCompatibleProvider: o.claudeCompatibleProvider,
sdkSessionId: o.sdkSessionId,
}; };
} }
@@ -689,8 +840,11 @@ export class AgentExecutor {
.replace(/\{\{approvedPlan\}\}/g, planContent); .replace(/\{\{approvedPlan\}\}/g, planContent);
let responseText = initialResponseText; let responseText = initialResponseText;
for await (const msg of provider.executeQuery( for await (const msg of provider.executeQuery(
this.buildExecOpts(options, contPrompt, options.sdkOptions?.maxTurns) this.buildExecOpts(options, contPrompt, options.sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS)
)) { )) {
if (msg.session_id && msg.session_id !== options.sdkSessionId) {
options.sdkSessionId = msg.session_id;
}
if (msg.type === 'assistant' && msg.message?.content) if (msg.type === 'assistant' && msg.message?.content)
for (const b of msg.message.content) { for (const b of msg.message.content) {
if (b.type === 'text') { if (b.type === 'text') {

View File

@@ -21,6 +21,7 @@ import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.
import type { SettingsService } from './settings-service.js'; import type { SettingsService } from './settings-service.js';
import { import {
getAutoLoadClaudeMdSetting, getAutoLoadClaudeMdSetting,
getUseClaudeCodeSystemPromptSetting,
filterClaudeMdFromContext, filterClaudeMdFromContext,
getMCPServersFromSettings, getMCPServersFromSettings,
getPromptCustomization, getPromptCustomization,
@@ -28,6 +29,7 @@ import {
getSubagentsConfiguration, getSubagentsConfiguration,
getCustomSubagents, getCustomSubagents,
getProviderByModelId, getProviderByModelId,
getDefaultMaxTurnsSetting,
} from '../lib/settings-helpers.js'; } from '../lib/settings-helpers.js';
interface Message { interface Message {
@@ -328,12 +330,6 @@ export class AgentService {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
// Build conversation history from existing messages BEFORE adding current message
const conversationHistory = session.messages.map((msg) => ({
role: msg.role,
content: msg.content,
}));
session.messages.push(userMessage); session.messages.push(userMessage);
session.isRunning = true; session.isRunning = true;
session.abortController = new AbortController(); session.abortController = new AbortController();
@@ -362,6 +358,22 @@ export class AgentService {
'[AgentService]' '[AgentService]'
); );
// Load useClaudeCodeSystemPrompt setting (project setting takes precedence over global)
// Wrap in try/catch so transient settingsService errors don't abort message processing
let useClaudeCodeSystemPrompt = true;
try {
useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting(
effectiveWorkDir,
this.settingsService,
'[AgentService]'
);
} catch (err) {
this.logger.error(
'[AgentService] getUseClaudeCodeSystemPromptSetting failed, defaulting to true',
err
);
}
// Load MCP servers from settings (global setting only) // Load MCP servers from settings (global setting only)
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]'); const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]');
@@ -405,6 +417,7 @@ export class AgentService {
} }
} }
let combinedSystemPrompt: string | undefined;
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files
// Use the user's message as task context for smart memory selection // Use the user's message as task context for smart memory selection
const contextResult = await loadContextFiles({ const contextResult = await loadContextFiles({
@@ -422,7 +435,7 @@ export class AgentService {
// Build combined system prompt with base prompt and context files // Build combined system prompt with base prompt and context files
const baseSystemPrompt = await this.getSystemPrompt(); const baseSystemPrompt = await this.getSystemPrompt();
const combinedSystemPrompt = contextFilesPrompt combinedSystemPrompt = contextFilesPrompt
? `${contextFilesPrompt}\n\n${baseSystemPrompt}` ? `${contextFilesPrompt}\n\n${baseSystemPrompt}`
: baseSystemPrompt; : baseSystemPrompt;
@@ -437,6 +450,9 @@ export class AgentService {
const modelForSdk = providerResolvedModel || model; const modelForSdk = providerResolvedModel || model;
const sessionModelForSdk = providerResolvedModel ? undefined : session.model; const sessionModelForSdk = providerResolvedModel ? undefined : session.model;
// Read user-configured max turns from settings
const userMaxTurns = await getDefaultMaxTurnsSetting(this.settingsService, '[AgentService]');
const sdkOptions = createChatOptions({ const sdkOptions = createChatOptions({
cwd: effectiveWorkDir, cwd: effectiveWorkDir,
model: modelForSdk, model: modelForSdk,
@@ -444,7 +460,9 @@ export class AgentService {
systemPrompt: combinedSystemPrompt, systemPrompt: combinedSystemPrompt,
abortController: session.abortController!, abortController: session.abortController!,
autoLoadClaudeMd, autoLoadClaudeMd,
useClaudeCodeSystemPrompt,
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
maxTurns: userMaxTurns, // User-configured max turns from settings
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
}); });
@@ -469,7 +487,19 @@ export class AgentService {
Object.keys(customSubagents).length > 0; Object.keys(customSubagents).length > 0;
// Base tools that match the provider's default set // Base tools that match the provider's default set
const baseTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch']; const baseTools = [
'Read',
'Write',
'Edit',
'MultiEdit',
'Glob',
'Grep',
'LS',
'Bash',
'WebSearch',
'WebFetch',
'TodoWrite',
];
if (allowedTools) { if (allowedTools) {
allowedTools = [...allowedTools]; // Create a copy to avoid mutating SDK options allowedTools = [...allowedTools]; // Create a copy to avoid mutating SDK options
@@ -508,6 +538,14 @@ export class AgentService {
: stripProviderPrefix(effectiveModel); : stripProviderPrefix(effectiveModel);
// Build options for provider // Build options for provider
const conversationHistory = session.messages
.slice(0, -1)
.map((msg) => ({
role: msg.role,
content: msg.content,
}))
.filter((msg) => msg.content.trim().length > 0);
const options: ExecuteOptions = { const options: ExecuteOptions = {
prompt: '', // Will be set below based on images prompt: '', // Will be set below based on images
model: bareModel, // Bare model ID (e.g., "gpt-5.1-codex-max", "composer-1") model: bareModel, // Bare model ID (e.g., "gpt-5.1-codex-max", "composer-1")
@@ -517,7 +555,8 @@ export class AgentService {
maxTurns: maxTurns, maxTurns: maxTurns,
allowedTools: allowedTools, allowedTools: allowedTools,
abortController: session.abortController!, abortController: session.abortController!,
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, conversationHistory:
conversationHistory && conversationHistory.length > 0 ? conversationHistory : undefined,
settingSources: settingSources.length > 0 ? settingSources : undefined, settingSources: settingSources.length > 0 ? settingSources : undefined,
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
@@ -545,6 +584,7 @@ export class AgentService {
let currentAssistantMessage: Message | null = null; let currentAssistantMessage: Message | null = null;
let responseText = ''; let responseText = '';
const toolUses: Array<{ name: string; input: unknown }> = []; const toolUses: Array<{ name: string; input: unknown }> = [];
const toolNamesById = new Map<string, string>();
for await (const msg of stream) { for await (const msg of stream) {
// Capture SDK session ID from any message and persist it. // Capture SDK session ID from any message and persist it.
@@ -589,11 +629,50 @@ export class AgentService {
input: block.input, input: block.input,
}; };
toolUses.push(toolUse); toolUses.push(toolUse);
if (block.tool_use_id) {
toolNamesById.set(block.tool_use_id, toolUse.name);
}
this.emitAgentEvent(sessionId, { this.emitAgentEvent(sessionId, {
type: 'tool_use', type: 'tool_use',
tool: toolUse, tool: toolUse,
}); });
} else if (block.type === 'tool_result') {
const toolUseId = block.tool_use_id;
const toolName = toolUseId ? toolNamesById.get(toolUseId) : undefined;
// Normalize block.content to a string for the emitted event
const rawContent: unknown = block.content;
let contentString: string;
if (typeof rawContent === 'string') {
contentString = rawContent;
} else if (Array.isArray(rawContent)) {
// Extract text from content blocks (TextBlock, ImageBlock, etc.)
contentString = rawContent
.map((part: { text?: string; type?: string }) => {
if (typeof part === 'string') return part;
if (part.text) return part.text;
// For non-text blocks (e.g., images), represent as type indicator
if (part.type) return `[${part.type}]`;
return JSON.stringify(part);
})
.join('\n');
} else if (rawContent !== undefined && rawContent !== null) {
contentString = JSON.stringify(rawContent);
} else {
contentString = '';
}
this.emitAgentEvent(sessionId, {
type: 'tool_result',
tool: {
name: toolName || 'unknown',
input: {
toolUseId,
content: contentString,
},
},
});
} }
} }
} }

View File

@@ -15,6 +15,12 @@ const logger = createLogger('AutoLoopCoordinator');
const CONSECUTIVE_FAILURE_THRESHOLD = 3; const CONSECUTIVE_FAILURE_THRESHOLD = 3;
const FAILURE_WINDOW_MS = 60000; const FAILURE_WINDOW_MS = 60000;
// Sleep intervals for the auto-loop (in milliseconds)
const SLEEP_INTERVAL_CAPACITY_MS = 5000;
const SLEEP_INTERVAL_IDLE_MS = 10000;
const SLEEP_INTERVAL_NORMAL_MS = 2000;
const SLEEP_INTERVAL_ERROR_MS = 5000;
export interface AutoModeConfig { export interface AutoModeConfig {
maxConcurrency: number; maxConcurrency: number;
useWorktrees: boolean; useWorktrees: boolean;
@@ -169,12 +175,23 @@ export class AutoLoopCoordinator {
// presence is accounted for when deciding whether to dispatch new auto-mode tasks. // presence is accounted for when deciding whether to dispatch new auto-mode tasks.
const runningCount = await this.getRunningCountForWorktree(projectPath, branchName); const runningCount = await this.getRunningCountForWorktree(projectPath, branchName);
if (runningCount >= projectState.config.maxConcurrency) { if (runningCount >= projectState.config.maxConcurrency) {
await this.sleep(5000, projectState.abortController.signal); await this.sleep(SLEEP_INTERVAL_CAPACITY_MS, projectState.abortController.signal);
continue; continue;
} }
const pendingFeatures = await this.loadPendingFeaturesFn(projectPath, branchName); const pendingFeatures = await this.loadPendingFeaturesFn(projectPath, branchName);
if (pendingFeatures.length === 0) { if (pendingFeatures.length === 0) {
if (runningCount === 0 && !projectState.hasEmittedIdleEvent) { if (runningCount === 0 && !projectState.hasEmittedIdleEvent) {
// Double-check that we have no features in 'in_progress' state that might
// have been released from the concurrency manager but not yet updated to
// their final status. This prevents auto_mode_idle from firing prematurely
// when features are transitioning states (e.g., during status update).
const hasInProgressFeatures = await this.hasInProgressFeaturesForWorktree(
projectPath,
branchName
);
// Only emit auto_mode_idle if we're truly done with all features
if (!hasInProgressFeatures) {
this.eventBus.emitAutoModeEvent('auto_mode_idle', { this.eventBus.emitAutoModeEvent('auto_mode_idle', {
message: 'No pending features - auto mode idle', message: 'No pending features - auto mode idle',
projectPath, projectPath,
@@ -182,7 +199,8 @@ export class AutoLoopCoordinator {
}); });
projectState.hasEmittedIdleEvent = true; projectState.hasEmittedIdleEvent = true;
} }
await this.sleep(10000, projectState.abortController.signal); }
await this.sleep(SLEEP_INTERVAL_IDLE_MS, projectState.abortController.signal);
continue; continue;
} }
@@ -228,10 +246,10 @@ export class AutoLoopCoordinator {
} }
}); });
} }
await this.sleep(2000, projectState.abortController.signal); await this.sleep(SLEEP_INTERVAL_NORMAL_MS, projectState.abortController.signal);
} catch { } catch {
if (projectState.abortController.signal.aborted) break; if (projectState.abortController.signal.aborted) break;
await this.sleep(5000, projectState.abortController.signal); await this.sleep(SLEEP_INTERVAL_ERROR_MS, projectState.abortController.signal);
} }
} }
projectState.isRunning = false; projectState.isRunning = false;
@@ -462,4 +480,48 @@ export class AutoLoopCoordinator {
signal?.addEventListener('abort', onAbort); signal?.addEventListener('abort', onAbort);
}); });
} }
/**
* Check if a feature belongs to the current worktree based on branch name.
* For main worktree (branchName === null or 'main'): includes features with no branchName or branchName === 'main'.
* For feature worktrees (branchName !== null and !== 'main'): only includes features with matching branchName.
*/
private featureBelongsToWorktree(feature: Feature, branchName: string | null): boolean {
const isMainWorktree = branchName === null || branchName === 'main';
if (isMainWorktree) {
// Main worktree: include features with no branchName or branchName === 'main'
return !feature.branchName || feature.branchName === 'main';
} else {
// Feature worktree: only include exact branch match
return feature.branchName === branchName;
}
}
/**
* Check if there are features in 'in_progress' status for the current worktree.
* This prevents auto_mode_idle from firing prematurely when features are
* transitioning states (e.g., during status update from in_progress to completed).
*/
private async hasInProgressFeaturesForWorktree(
projectPath: string,
branchName: string | null
): Promise<boolean> {
if (!this.loadAllFeaturesFn) {
return false;
}
try {
const allFeatures = await this.loadAllFeaturesFn(projectPath);
return allFeatures.some(
(f) => f.status === 'in_progress' && this.featureBelongsToWorktree(f, branchName)
);
} catch (error) {
const errorInfo = classifyError(error);
logger.warn(
`Failed to load all features for idle check (projectPath=${projectPath}, branchName=${branchName}): ${errorInfo.message}`,
error
);
return false;
}
}
} }

View File

@@ -232,9 +232,10 @@ export class AutoModeServiceCompat {
} }
async detectOrphanedFeatures( async detectOrphanedFeatures(
projectPath: string projectPath: string,
preloadedFeatures?: Feature[]
): Promise<Array<{ feature: Feature; missingBranch: string }>> { ): Promise<Array<{ feature: Feature; missingBranch: string }>> {
const facade = this.createFacade(projectPath); const facade = this.createFacade(projectPath);
return facade.detectOrphanedFeatures(); return facade.detectOrphanedFeatures(preloadedFeatures);
} }
} }

View File

@@ -14,14 +14,24 @@
import path from 'path'; import path from 'path';
import { exec } from 'child_process'; import { exec } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import type { Feature, PlanningMode, ThinkingLevel } from '@automaker/types'; import type { Feature, PlanningMode, ThinkingLevel, ReasoningEffort } from '@automaker/types';
import { DEFAULT_MAX_CONCURRENCY, DEFAULT_MODELS, stripProviderPrefix } from '@automaker/types'; import {
DEFAULT_MAX_CONCURRENCY,
DEFAULT_MODELS,
stripProviderPrefix,
isPipelineStatus,
} from '@automaker/types';
import { resolveModelString } from '@automaker/model-resolver'; import { resolveModelString } from '@automaker/model-resolver';
import { createLogger, loadContextFiles, classifyError } from '@automaker/utils'; import { createLogger, loadContextFiles, classifyError } from '@automaker/utils';
import { getFeatureDir } from '@automaker/platform'; import { getFeatureDir } from '@automaker/platform';
import * as secureFs from '../../lib/secure-fs.js'; import * as secureFs from '../../lib/secure-fs.js';
import { validateWorkingDirectory } from '../../lib/sdk-options.js'; import { validateWorkingDirectory, createAutoModeOptions } from '../../lib/sdk-options.js';
import { getPromptCustomization, getProviderByModelId } from '../../lib/settings-helpers.js'; import {
getPromptCustomization,
resolveProviderContext,
getMCPServersFromSettings,
getDefaultMaxTurnsSetting,
} from '../../lib/settings-helpers.js';
import { execGitCommand } from '@automaker/git-utils'; import { execGitCommand } from '@automaker/git-utils';
import { TypedEventBus } from '../typed-event-bus.js'; import { TypedEventBus } from '../typed-event-bus.js';
import { ConcurrencyManager } from '../concurrency-manager.js'; import { ConcurrencyManager } from '../concurrency-manager.js';
@@ -74,6 +84,37 @@ export class AutoModeServiceFacade {
private readonly settingsService: SettingsService | null private readonly settingsService: SettingsService | null
) {} ) {}
/**
* Determine if a feature is eligible to be picked up by the auto-mode loop.
*
* @param feature - The feature to check
* @param branchName - The current worktree branch name (null for main)
* @param primaryBranch - The resolved primary branch name for the project
* @returns True if the feature is eligible for auto-dispatch
*/
public static isFeatureEligibleForAutoMode(
feature: Feature,
branchName: string | null,
primaryBranch: string | null
): boolean {
const isEligibleStatus =
feature.status === 'backlog' ||
feature.status === 'ready' ||
feature.status === 'interrupted' ||
isPipelineStatus(feature.status);
if (!isEligibleStatus) return false;
// Filter by branch/worktree alignment
if (branchName === null) {
// For main worktree, include features with no branch or matching primary branch
return !feature.branchName || (primaryBranch != null && feature.branchName === primaryBranch);
} else {
// For named worktrees, only include features matching that branch
return feature.branchName === branchName;
}
}
/** /**
* Classify and log an error at the facade boundary. * Classify and log an error at the facade boundary.
* Emits an error event to the UI so failures are surfaced to the user. * Emits an error event to the UI so failures are surfaced to the user.
@@ -185,8 +226,7 @@ export class AutoModeServiceFacade {
/** /**
* Shared agent-run helper used by both PipelineOrchestrator and ExecutionService. * Shared agent-run helper used by both PipelineOrchestrator and ExecutionService.
* *
* Resolves the model string, looks up the custom provider/credentials via * Resolves provider/model context, then delegates to agentExecutor.execute with the
* getProviderByModelId, then delegates to agentExecutor.execute with the
* full payload. The opts parameter uses an index-signature union so it * full payload. The opts parameter uses an index-signature union so it
* accepts both the typed ExecutionService opts object and the looser * accepts both the typed ExecutionService opts object and the looser
* Record<string, unknown> used by PipelineOrchestrator without requiring * Record<string, unknown> used by PipelineOrchestrator without requiring
@@ -208,8 +248,11 @@ export class AutoModeServiceFacade {
previousContent?: string; previousContent?: string;
systemPrompt?: string; systemPrompt?: string;
autoLoadClaudeMd?: boolean; autoLoadClaudeMd?: boolean;
useClaudeCodeSystemPrompt?: boolean;
thinkingLevel?: ThinkingLevel; thinkingLevel?: ThinkingLevel;
reasoningEffort?: ReasoningEffort;
branchName?: string | null; branchName?: string | null;
status?: string; // Feature status for pipeline summary check
[key: string]: unknown; [key: string]: unknown;
} }
): Promise<void> => { ): Promise<void> => {
@@ -222,17 +265,67 @@ export class AutoModeServiceFacade {
| import('@automaker/types').ClaudeCompatibleProvider | import('@automaker/types').ClaudeCompatibleProvider
| undefined; | undefined;
let credentials: import('@automaker/types').Credentials | undefined; let credentials: import('@automaker/types').Credentials | undefined;
let providerResolvedModel: string | undefined;
if (settingsService) { if (settingsService) {
const providerResult = await getProviderByModelId( const providerId = opts?.providerId as string | undefined;
resolvedModel, const result = await resolveProviderContext(
settingsService, settingsService,
resolvedModel,
providerId,
'[AutoModeFacade]' '[AutoModeFacade]'
); );
if (providerResult.provider) { claudeCompatibleProvider = result.provider;
claudeCompatibleProvider = providerResult.provider; credentials = result.credentials;
credentials = providerResult.credentials; providerResolvedModel = result.resolvedModel;
}
// Build sdkOptions with proper maxTurns and allowedTools for auto-mode.
// Without this, maxTurns would be undefined, causing providers to use their
// internal defaults which may be much lower than intended (e.g., Codex CLI's
// default turn limit can cause feature runs to stop prematurely).
const autoLoadClaudeMd = opts?.autoLoadClaudeMd ?? false;
const useClaudeCodeSystemPrompt = opts?.useClaudeCodeSystemPrompt ?? true;
let mcpServers: Record<string, unknown> | undefined;
try {
if (settingsService) {
const servers = await getMCPServersFromSettings(settingsService, '[AutoModeFacade]');
if (Object.keys(servers).length > 0) {
mcpServers = servers;
} }
} }
} catch {
// MCP servers are optional - continue without them
}
// Read user-configured max turns from settings
const userMaxTurns = await getDefaultMaxTurnsSetting(settingsService, '[AutoModeFacade]');
const sdkOpts = createAutoModeOptions({
cwd: workDir,
model: providerResolvedModel || resolvedModel,
systemPrompt: opts?.systemPrompt,
abortController,
autoLoadClaudeMd,
useClaudeCodeSystemPrompt,
thinkingLevel: opts?.thinkingLevel,
maxTurns: userMaxTurns,
mcpServers: mcpServers as
| Record<string, import('@automaker/types').McpServerConfig>
| undefined,
});
if (!sdkOpts) {
logger.error(
`[createRunAgentFn] sdkOpts is UNDEFINED! createAutoModeOptions type: ${typeof createAutoModeOptions}`
);
}
logger.info(
`[createRunAgentFn] Feature ${featureId}: model=${resolvedModel} (resolved=${providerResolvedModel || resolvedModel}), ` +
`maxTurns=${sdkOpts.maxTurns}, allowedTools=${(sdkOpts.allowedTools as string[])?.length ?? 'default'}, ` +
`provider=${provider.getName()}`
);
await agentExecutor.execute( await agentExecutor.execute(
{ {
@@ -248,12 +341,24 @@ export class AutoModeServiceFacade {
previousContent: opts?.previousContent as string | undefined, previousContent: opts?.previousContent as string | undefined,
systemPrompt: opts?.systemPrompt as string | undefined, systemPrompt: opts?.systemPrompt as string | undefined,
autoLoadClaudeMd: opts?.autoLoadClaudeMd as boolean | undefined, autoLoadClaudeMd: opts?.autoLoadClaudeMd as boolean | undefined,
useClaudeCodeSystemPrompt,
thinkingLevel: opts?.thinkingLevel as ThinkingLevel | undefined, thinkingLevel: opts?.thinkingLevel as ThinkingLevel | undefined,
reasoningEffort: opts?.reasoningEffort as ReasoningEffort | undefined,
branchName: opts?.branchName as string | null | undefined, branchName: opts?.branchName as string | null | undefined,
status: opts?.status as string | undefined,
provider, provider,
effectiveBareModel, effectiveBareModel,
credentials, credentials,
claudeCompatibleProvider, claudeCompatibleProvider,
mcpServers,
sdkOptions: {
maxTurns: sdkOpts.maxTurns,
allowedTools: sdkOpts.allowedTools as string[] | undefined,
systemPrompt: sdkOpts.systemPrompt,
settingSources: sdkOpts.settingSources as
| Array<'user' | 'project' | 'local'>
| undefined,
},
}, },
{ {
waitForApproval: (fId, projPath) => planApprovalService.waitForApproval(fId, projPath), waitForApproval: (fId, projPath) => planApprovalService.waitForApproval(fId, projPath),
@@ -314,12 +419,8 @@ export class AutoModeServiceFacade {
if (branchName === null) { if (branchName === null) {
primaryBranch = await worktreeResolver.getCurrentBranch(pPath); primaryBranch = await worktreeResolver.getCurrentBranch(pPath);
} }
return features.filter( return features.filter((f) =>
(f) => AutoModeServiceFacade.isFeatureEligibleForAutoMode(f, branchName, primaryBranch)
(f.status === 'backlog' || f.status === 'ready') &&
(branchName === null
? !f.branchName || (primaryBranch && f.branchName === primaryBranch)
: f.branchName === branchName)
); );
}, },
(pPath, branchName, maxConcurrency) => (pPath, branchName, maxConcurrency) =>
@@ -362,9 +463,25 @@ export class AutoModeServiceFacade {
(pPath, featureId, status) => (pPath, featureId, status) =>
featureStateManager.updateFeatureStatus(pPath, featureId, status), featureStateManager.updateFeatureStatus(pPath, featureId, status),
(pPath, featureId) => featureStateManager.loadFeature(pPath, featureId), (pPath, featureId) => featureStateManager.loadFeature(pPath, featureId),
async (_feature) => { async (feature) => {
// getPlanningPromptPrefixFn - planning prompts handled by AutoModeService // getPlanningPromptPrefixFn - select appropriate planning prompt based on feature's planningMode
if (!feature.planningMode || feature.planningMode === 'skip') {
return ''; return '';
}
const prompts = await getPromptCustomization(settingsService, '[PlanningPromptPrefix]');
const autoModePrompts = prompts.autoMode;
switch (feature.planningMode) {
case 'lite':
return feature.requirePlanApproval
? autoModePrompts.planningLiteWithApproval + '\n\n'
: autoModePrompts.planningLite + '\n\n';
case 'spec':
return autoModePrompts.planningSpec + '\n\n';
case 'full':
return autoModePrompts.planningFull + '\n\n';
default:
return '';
}
}, },
(pPath, featureId, summary) => (pPath, featureId, summary) =>
featureStateManager.saveFeatureSummary(pPath, featureId, summary), featureStateManager.saveFeatureSummary(pPath, featureId, summary),
@@ -702,16 +819,20 @@ export class AutoModeServiceFacade {
} }
} }
const runningEntryForVerify = this.concurrencyManager.getRunningFeature(featureId);
if (runningEntryForVerify?.isAutoMode) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
featureName: feature?.title, featureName: feature?.title,
branchName: feature?.branchName ?? null, branchName: feature?.branchName ?? null,
executionMode: 'auto',
passes: allPassed, passes: allPassed,
message: allPassed message: allPassed
? 'All verification checks passed' ? 'All verification checks passed'
: `Verification failed: ${results.find((r) => !r.passed)?.check || 'Unknown'}`, : `Verification failed: ${results.find((r) => !r.passed)?.check || 'Unknown'}`,
projectPath: this.projectPath, projectPath: this.projectPath,
}); });
}
return allPassed; return allPassed;
} }
@@ -761,14 +882,18 @@ export class AutoModeServiceFacade {
await execGitCommand(['commit', '-m', commitMessage], workDir); await execGitCommand(['commit', '-m', commitMessage], workDir);
const hash = await execGitCommand(['rev-parse', 'HEAD'], workDir); const hash = await execGitCommand(['rev-parse', 'HEAD'], workDir);
const runningEntryForCommit = this.concurrencyManager.getRunningFeature(featureId);
if (runningEntryForCommit?.isAutoMode) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
featureName: feature?.title, featureName: feature?.title,
branchName: feature?.branchName ?? null, branchName: feature?.branchName ?? null,
executionMode: 'auto',
passes: true, passes: true,
message: `Changes committed: ${hash.trim().substring(0, 8)}`, message: `Changes committed: ${hash.trim().substring(0, 8)}`,
projectPath: this.projectPath, projectPath: this.projectPath,
}); });
}
return hash.trim(); return hash.trim();
} catch (error) { } catch (error) {
@@ -851,7 +976,7 @@ export class AutoModeServiceFacade {
if (feature) { if (feature) {
title = feature.title; title = feature.title;
description = feature.description; description = feature.description;
branchName = feature.branchName; branchName = feature.branchName ?? undefined;
} }
} catch { } catch {
// Silently ignore // Silently ignore
@@ -1008,12 +1133,13 @@ export class AutoModeServiceFacade {
/** /**
* Detect orphaned features (features with missing branches) * Detect orphaned features (features with missing branches)
* @param preloadedFeatures - Optional pre-loaded features to avoid redundant disk reads
*/ */
async detectOrphanedFeatures(): Promise<OrphanedFeatureInfo[]> { async detectOrphanedFeatures(preloadedFeatures?: Feature[]): Promise<OrphanedFeatureInfo[]> {
const orphanedFeatures: OrphanedFeatureInfo[] = []; const orphanedFeatures: OrphanedFeatureInfo[] = [];
try { try {
const allFeatures = await this.featureLoader.getAll(this.projectPath); const allFeatures = preloadedFeatures ?? (await this.featureLoader.getAll(this.projectPath));
const featuresWithBranches = allFeatures.filter( const featuresWithBranches = allFeatures.filter(
(f) => f.branchName && f.branchName.trim() !== '' (f) => f.branchName && f.branchName.trim() !== ''
); );
@@ -1081,12 +1207,33 @@ export class AutoModeServiceFacade {
// =========================================================================== // ===========================================================================
/** /**
* Save execution state for recovery * Save execution state for recovery.
*
* Uses the active auto-loop config for each worktree so that the persisted
* state reflects the real branch and maxConcurrency values rather than the
* hard-coded fallbacks (null / DEFAULT_MAX_CONCURRENCY).
*/ */
private async saveExecutionState(): Promise<void> { private async saveExecutionState(): Promise<void> {
const projectWorktrees = this.autoLoopCoordinator
.getActiveWorktrees()
.filter((w) => w.projectPath === this.projectPath);
if (projectWorktrees.length === 0) {
// No active auto loops — save with defaults as a best-effort fallback.
return this.saveExecutionStateForProject(null, DEFAULT_MAX_CONCURRENCY); return this.saveExecutionStateForProject(null, DEFAULT_MAX_CONCURRENCY);
} }
// Save state for every active worktree using its real config values.
for (const { branchName } of projectWorktrees) {
const config = this.autoLoopCoordinator.getAutoLoopConfigForProject(
this.projectPath,
branchName
);
const maxConcurrency = config?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY;
await this.saveExecutionStateForProject(branchName, maxConcurrency);
}
}
/** /**
* Save execution state for a specific worktree * Save execution state for a specific worktree
*/ */

View File

@@ -159,7 +159,7 @@ export class GlobalAutoModeService {
if (feature) { if (feature) {
title = feature.title; title = feature.title;
description = feature.description; description = feature.description;
branchName = feature.branchName; branchName = feature.branchName ?? undefined;
} }
} catch { } catch {
// Silently ignore // Silently ignore

View File

@@ -0,0 +1,426 @@
/**
* branch-sync-service - Sync a local base branch with its remote tracking branch
*
* Provides logic to detect remote tracking branches, check whether a branch
* is checked out in any worktree, and fast-forward a local branch to match
* its remote counterpart. Extracted from the worktree create route so
* the git logic is decoupled from HTTP request/response handling.
*/
import { createLogger, getErrorMessage } from '@automaker/utils';
import { execGitCommand } from '../lib/git.js';
const logger = createLogger('BranchSyncService');
/** Timeout for git fetch operations (30 seconds) */
const FETCH_TIMEOUT_MS = 30_000;
// ============================================================================
// Types
// ============================================================================
/**
* Result of attempting to sync a base branch with its remote.
*/
export interface BaseBranchSyncResult {
/** Whether the sync was attempted */
attempted: boolean;
/** Whether the sync succeeded */
synced: boolean;
/** Whether the ref was resolved (but not synced, e.g. remote ref, tag, or commit hash) */
resolved?: boolean;
/** The remote that was synced from (e.g. 'origin') */
remote?: string;
/** The commit hash the base branch points to after sync */
commitHash?: string;
/** Human-readable message about the sync result */
message?: string;
/** Whether the branch had diverged (local commits ahead of remote) */
diverged?: boolean;
/** Whether the user can proceed with a stale local copy */
canProceedWithStale?: boolean;
}
// ============================================================================
// Helpers
// ============================================================================
/**
* Detect the remote tracking branch for a given local branch.
*
* @param projectPath - Path to the git repository
* @param branchName - Local branch name to check (e.g. 'main')
* @returns Object with remote name and remote branch, or null if no tracking branch
*/
export async function getTrackingBranch(
projectPath: string,
branchName: string
): Promise<{ remote: string; remoteBranch: string } | null> {
try {
// git rev-parse --abbrev-ref <branch>@{upstream} returns e.g. "origin/main"
const upstream = await execGitCommand(
['rev-parse', '--abbrev-ref', `${branchName}@{upstream}`],
projectPath
);
const trimmed = upstream.trim();
if (!trimmed) return null;
// First, attempt to determine the remote name explicitly via git config
// so that remotes whose names contain slashes are handled correctly.
let remote: string | null = null;
try {
const configRemote = await execGitCommand(
['config', '--get', `branch.${branchName}.remote`],
projectPath
);
const configRemoteTrimmed = configRemote.trim();
if (configRemoteTrimmed) {
remote = configRemoteTrimmed;
}
} catch {
// git config lookup failed — will fall back to string splitting below
}
if (remote) {
// Strip the known remote prefix (plus the separating '/') to get the remote branch.
// The upstream string is expected to be "<remote>/<remoteBranch>".
const prefix = `${remote}/`;
if (trimmed.startsWith(prefix)) {
return {
remote,
remoteBranch: trimmed.substring(prefix.length),
};
}
// Upstream doesn't start with the expected prefix — fall through to split
}
// Fall back: split on the FIRST slash, which favors the common case of
// single-name remotes with slash-containing branch names (e.g.
// "origin/feature/foo" → remote="origin", remoteBranch="feature/foo").
// Remotes with slashes in their names are uncommon and are already handled
// by the git-config lookup above; this fallback only runs when that lookup
// fails, so optimizing for single-name remotes is the safer default.
const slashIndex = trimmed.indexOf('/');
if (slashIndex > 0) {
return {
remote: trimmed.substring(0, slashIndex),
remoteBranch: trimmed.substring(slashIndex + 1),
};
}
return null;
} catch {
// No upstream tracking branch configured
return null;
}
}
/**
* Check whether a branch is checked out in ANY worktree (main or linked).
* Uses `git worktree list --porcelain` to enumerate all worktrees and
* checks if any of them has the given branch as their HEAD.
*
* Returns the absolute path of the worktree where the branch is checked out,
* or null if the branch is not checked out anywhere. Callers can use the
* returned path to run commands (e.g. `git merge`) inside the correct worktree.
*
* This prevents using `git update-ref` on a branch that is checked out in
* a linked worktree, which would desync that worktree's HEAD.
*/
export async function isBranchCheckedOut(
projectPath: string,
branchName: string
): Promise<string | null> {
try {
const stdout = await execGitCommand(['worktree', 'list', '--porcelain'], projectPath);
const lines = stdout.split('\n');
let currentWorktreePath: string | null = null;
let currentBranch: string | null = null;
for (const line of lines) {
if (line.startsWith('worktree ')) {
currentWorktreePath = line.slice(9);
} else if (line.startsWith('branch ')) {
currentBranch = line.slice(7).replace('refs/heads/', '');
} else if (line === '') {
// End of a worktree entry — check for match, then reset for the next
if (currentBranch === branchName && currentWorktreePath) {
return currentWorktreePath;
}
currentWorktreePath = null;
currentBranch = null;
}
}
// Check the last entry (if output doesn't end with a blank line)
if (currentBranch === branchName && currentWorktreePath) {
return currentWorktreePath;
}
return null;
} catch {
return null;
}
}
/**
* Build a BaseBranchSyncResult for cases where we proceed with a stale local copy.
* Extracts the repeated pattern of getting the short commit hash with a fallback.
*/
export async function buildStaleResult(
projectPath: string,
branchName: string,
remote: string | undefined,
message: string,
extra?: Partial<BaseBranchSyncResult>
): Promise<BaseBranchSyncResult> {
let commitHash: string | undefined;
try {
const hash = await execGitCommand(['rev-parse', '--short', branchName], projectPath);
commitHash = hash.trim();
} catch {
/* ignore — commit hash is non-critical */
}
return {
attempted: true,
synced: false,
remote,
commitHash,
message,
canProceedWithStale: true,
...extra,
};
}
// ============================================================================
// Main Sync Function
// ============================================================================
/**
* Sync a local base branch with its remote tracking branch using fast-forward only.
*
* This function:
* 1. Detects the remote tracking branch for the given local branch
* 2. Fetches latest from that remote (unless skipFetch is true)
* 3. Attempts a fast-forward-only update of the local branch
* 4. If the branch has diverged, reports the divergence and allows proceeding with stale copy
* 5. If no remote tracking branch exists, skips silently
*
* @param projectPath - Path to the git repository
* @param branchName - The local branch name to sync (e.g. 'main')
* @param skipFetch - When true, skip the internal git fetch (caller has already fetched)
* @returns Sync result with status information
*/
export async function syncBaseBranch(
projectPath: string,
branchName: string,
skipFetch = false
): Promise<BaseBranchSyncResult> {
// Check if the branch exists as a local branch (under refs/heads/).
// This correctly handles branch names containing slashes (e.g. "feature/abc",
// "fix/issue-123") which are valid local branch names, not remote refs.
let existsLocally = false;
try {
await execGitCommand(['rev-parse', '--verify', `refs/heads/${branchName}`], projectPath);
existsLocally = true;
} catch {
existsLocally = false;
}
if (!existsLocally) {
// Not a local branch — check if it's a valid ref (remote ref, tag, or commit hash).
// No synchronization is performed here; we only resolve the ref to a commit hash.
try {
const commitHash = await execGitCommand(['rev-parse', '--short', branchName], projectPath);
return {
attempted: false,
synced: false,
resolved: true,
commitHash: commitHash.trim(),
message: `Ref '${branchName}' resolved (not a local branch; no sync performed)`,
};
} catch {
return {
attempted: false,
synced: false,
message: `Ref '${branchName}' not found`,
};
}
}
// Detect remote tracking branch
const tracking = await getTrackingBranch(projectPath, branchName);
if (!tracking) {
// No remote tracking branch — skip silently
logger.info(`Branch '${branchName}' has no remote tracking branch, skipping sync`);
try {
const commitHash = await execGitCommand(['rev-parse', '--short', branchName], projectPath);
return {
attempted: false,
synced: false,
commitHash: commitHash.trim(),
message: `Branch '${branchName}' has no remote tracking branch`,
};
} catch {
return {
attempted: false,
synced: false,
message: `Branch '${branchName}' has no remote tracking branch`,
};
}
}
logger.info(
`Syncing base branch '${branchName}' from ${tracking.remote}/${tracking.remoteBranch}`
);
// Fetch the specific remote unless the caller has already performed a fetch
// (e.g. via `git fetch --all`) and passed skipFetch=true to avoid redundant work.
if (!skipFetch) {
try {
const fetchController = new AbortController();
const fetchTimer = setTimeout(() => fetchController.abort(), FETCH_TIMEOUT_MS);
try {
await execGitCommand(
['fetch', tracking.remote, tracking.remoteBranch, '--quiet'],
projectPath,
undefined,
fetchController
);
} finally {
clearTimeout(fetchTimer);
}
} catch (fetchErr) {
// Fetch failed — network error, auth error, etc.
// Allow proceeding with stale local copy
const errMsg = getErrorMessage(fetchErr);
logger.warn(`Failed to fetch ${tracking.remote}/${tracking.remoteBranch}: ${errMsg}`);
return buildStaleResult(
projectPath,
branchName,
tracking.remote,
`Failed to fetch from remote: ${errMsg}. Proceeding with local copy.`
);
}
} else {
logger.info(`Skipping fetch for '${branchName}' (caller already fetched from remotes)`);
}
// Check if the local branch is behind, ahead, or diverged from the remote
const remoteRef = `${tracking.remote}/${tracking.remoteBranch}`;
try {
// Count commits ahead and behind
const revListOutput = await execGitCommand(
['rev-list', '--left-right', '--count', `${branchName}...${remoteRef}`],
projectPath
);
const parts = revListOutput.trim().split(/\s+/);
const ahead = parseInt(parts[0], 10) || 0;
const behind = parseInt(parts[1], 10) || 0;
if (ahead === 0 && behind === 0) {
// Already up to date
const commitHash = await execGitCommand(['rev-parse', '--short', branchName], projectPath);
logger.info(`Branch '${branchName}' is already up to date with ${remoteRef}`);
return {
attempted: true,
synced: true,
remote: tracking.remote,
commitHash: commitHash.trim(),
message: `Branch '${branchName}' is already up to date`,
};
}
if (ahead > 0 && behind > 0) {
// Branch has diverged — cannot fast-forward
logger.warn(
`Branch '${branchName}' has diverged from ${remoteRef} (${ahead} ahead, ${behind} behind)`
);
return buildStaleResult(
projectPath,
branchName,
tracking.remote,
`Branch '${branchName}' has diverged from ${remoteRef} (${ahead} commit(s) ahead, ${behind} behind). Using local copy to avoid overwriting local commits.`,
{ diverged: true }
);
}
if (ahead > 0 && behind === 0) {
// Local is ahead — nothing to pull, already has everything from remote plus more
const commitHash = await execGitCommand(['rev-parse', '--short', branchName], projectPath);
logger.info(`Branch '${branchName}' is ${ahead} commit(s) ahead of ${remoteRef}`);
return {
attempted: true,
synced: true,
remote: tracking.remote,
commitHash: commitHash.trim(),
message: `Branch '${branchName}' is ${ahead} commit(s) ahead of remote`,
};
}
// behind > 0 && ahead === 0 — can fast-forward
logger.info(
`Branch '${branchName}' is ${behind} commit(s) behind ${remoteRef}, fast-forwarding`
);
// Determine whether the branch is currently checked out (returns the
// worktree path where it is checked out, or null if not checked out)
const worktreePath = await isBranchCheckedOut(projectPath, branchName);
if (worktreePath) {
// Branch is checked out in a worktree — use git merge --ff-only
// Run the merge inside the worktree that has the branch checked out
try {
await execGitCommand(['merge', '--ff-only', remoteRef], worktreePath);
} catch (mergeErr) {
const errMsg = getErrorMessage(mergeErr);
logger.warn(`Fast-forward merge failed for '${branchName}': ${errMsg}`);
return buildStaleResult(
projectPath,
branchName,
tracking.remote,
`Fast-forward merge failed: ${errMsg}. Proceeding with local copy.`
);
}
} else {
// Branch is NOT checked out — use git update-ref to fast-forward without checkout
// This is safe because we already verified the branch is strictly behind (ahead === 0)
try {
const remoteCommit = await execGitCommand(['rev-parse', remoteRef], projectPath);
await execGitCommand(
['update-ref', `refs/heads/${branchName}`, remoteCommit.trim()],
projectPath
);
} catch (updateErr) {
const errMsg = getErrorMessage(updateErr);
logger.warn(`update-ref failed for '${branchName}': ${errMsg}`);
return buildStaleResult(
projectPath,
branchName,
tracking.remote,
`Failed to fast-forward branch: ${errMsg}. Proceeding with local copy.`
);
}
}
// Successfully fast-forwarded
const commitHash = await execGitCommand(['rev-parse', '--short', branchName], projectPath);
logger.info(`Successfully synced '${branchName}' to ${commitHash.trim()} from ${remoteRef}`);
return {
attempted: true,
synced: true,
remote: tracking.remote,
commitHash: commitHash.trim(),
message: `Fast-forwarded '${branchName}' by ${behind} commit(s) from ${remoteRef}`,
};
} catch (err) {
// Unexpected error during rev-list or merge — proceed with stale
const errMsg = getErrorMessage(err);
logger.warn(`Unexpected error syncing '${branchName}': ${errMsg}`);
return buildStaleResult(
projectPath,
branchName,
tracking.remote,
`Sync failed: ${errMsg}. Proceeding with local copy.`
);
}
}

View File

@@ -193,7 +193,11 @@ export class CodexModelCacheService {
* Infer tier from model ID * Infer tier from model ID
*/ */
private inferTier(modelId: string): 'premium' | 'standard' | 'basic' { private inferTier(modelId: string): 'premium' | 'standard' | 'basic' {
if (modelId.includes('max') || modelId.includes('gpt-5.2-codex')) { if (
modelId.includes('max') ||
modelId.includes('gpt-5.2-codex') ||
modelId.includes('gpt-5.3-codex')
) {
return 'premium'; return 'premium';
} }
if (modelId.includes('mini')) { if (modelId.includes('mini')) {

View File

@@ -13,12 +13,18 @@ import path from 'path';
import net from 'net'; import net from 'net';
import { createLogger } from '@automaker/utils'; import { createLogger } from '@automaker/utils';
import type { EventEmitter } from '../lib/events.js'; import type { EventEmitter } from '../lib/events.js';
import fs from 'fs/promises';
import { constants } from 'fs';
const logger = createLogger('DevServerService'); const logger = createLogger('DevServerService');
// Maximum scrollback buffer size (characters) - matches TerminalService pattern // Maximum scrollback buffer size (characters) - matches TerminalService pattern
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per dev server const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per dev server
// Timeout (ms) before falling back to the allocated port if URL detection hasn't succeeded.
// This handles cases where the dev server output format is not recognized by any pattern.
const URL_DETECTION_TIMEOUT_MS = 30_000;
// URL patterns for detecting full URLs from dev server output. // URL patterns for detecting full URLs from dev server output.
// Defined once at module level to avoid reallocation on every call to detectUrlFromOutput. // Defined once at module level to avoid reallocation on every call to detectUrlFromOutput.
// Ordered from most specific (framework-specific) to least specific. // Ordered from most specific (framework-specific) to least specific.
@@ -82,12 +88,18 @@ const PORT_PATTERNS: Array<{ pattern: RegExp; description: string }> = [
}, },
]; ];
// Throttle output to prevent overwhelming WebSocket under heavy load // Throttle output to prevent overwhelming WebSocket under heavy load.
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive feedback // 100ms (~10fps) is sufficient for readable log streaming while keeping
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency // WebSocket traffic manageable. The previous 4ms rate (~250fps) generated
// up to 250 events/sec which caused progressive browser slowdown from
// accumulated console logs, JSON serialization overhead, and React re-renders.
const OUTPUT_THROTTLE_MS = 100; // ~10fps max update rate
const OUTPUT_BATCH_SIZE = 8192; // Larger batches to compensate for lower frequency
export interface DevServerInfo { export interface DevServerInfo {
worktreePath: string; worktreePath: string;
/** The port originally reserved by findAvailablePort() never mutated after startDevServer sets it */
allocatedPort: number;
port: number; port: number;
url: string; url: string;
process: ChildProcess | null; process: ChildProcess | null;
@@ -102,6 +114,23 @@ export interface DevServerInfo {
stopping: boolean; stopping: boolean;
// Flag to indicate if URL has been detected from output // Flag to indicate if URL has been detected from output
urlDetected: boolean; urlDetected: boolean;
// Timer for URL detection timeout fallback
urlDetectionTimeout: NodeJS.Timeout | null;
// Custom command used to start the server
customCommand?: string;
}
/**
* Persistable subset of DevServerInfo for survival across server restarts
*/
interface PersistedDevServerInfo {
worktreePath: string;
allocatedPort: number;
port: number;
url: string;
startedAt: string;
urlDetected: boolean;
customCommand?: string;
} }
// Port allocation starts at 3001 to avoid conflicts with common dev ports // Port allocation starts at 3001 to avoid conflicts with common dev ports
@@ -113,8 +142,20 @@ const LIVERELOAD_PORTS = [35729, 35730, 35731] as const;
class DevServerService { class DevServerService {
private runningServers: Map<string, DevServerInfo> = new Map(); private runningServers: Map<string, DevServerInfo> = new Map();
private startingServers: Set<string> = new Set();
private allocatedPorts: Set<number> = new Set(); private allocatedPorts: Set<number> = new Set();
private emitter: EventEmitter | null = null; private emitter: EventEmitter | null = null;
private dataDir: string | null = null;
private saveQueue: Promise<void> = Promise.resolve();
/**
* Initialize the service with data directory for persistence
*/
async initialize(dataDir: string, emitter: EventEmitter): Promise<void> {
this.dataDir = dataDir;
this.emitter = emitter;
await this.loadState();
}
/** /**
* Set the event emitter for streaming log events * Set the event emitter for streaming log events
@@ -124,6 +165,161 @@ class DevServerService {
this.emitter = emitter; this.emitter = emitter;
} }
/**
* Save the current state of running servers to disk
*/
private async saveState(): Promise<void> {
if (!this.dataDir) return;
// Queue the save operation to prevent concurrent writes
this.saveQueue = this.saveQueue
.then(async () => {
if (!this.dataDir) return;
try {
const statePath = path.join(this.dataDir, 'dev-servers.json');
const persistedInfo: PersistedDevServerInfo[] = Array.from(
this.runningServers.values()
).map((s) => ({
worktreePath: s.worktreePath,
allocatedPort: s.allocatedPort,
port: s.port,
url: s.url,
startedAt: s.startedAt.toISOString(),
urlDetected: s.urlDetected,
customCommand: s.customCommand,
}));
await fs.writeFile(statePath, JSON.stringify(persistedInfo, null, 2));
logger.debug(`Saved dev server state to ${statePath}`);
} catch (error) {
logger.error('Failed to save dev server state:', error);
}
})
.catch((error) => {
logger.error('Error in save queue:', error);
});
return this.saveQueue;
}
/**
* Load the state of running servers from disk
*/
private async loadState(): Promise<void> {
if (!this.dataDir) return;
try {
const statePath = path.join(this.dataDir, 'dev-servers.json');
try {
await fs.access(statePath, constants.F_OK);
} catch {
// File doesn't exist, which is fine
return;
}
const content = await fs.readFile(statePath, 'utf-8');
const rawParsed: unknown = JSON.parse(content);
if (!Array.isArray(rawParsed)) {
logger.warn('Dev server state file is not an array, skipping load');
return;
}
const persistedInfo: PersistedDevServerInfo[] = rawParsed.filter((entry: unknown) => {
if (entry === null || typeof entry !== 'object') {
logger.warn('Dropping invalid dev server entry (not an object):', entry);
return false;
}
const e = entry as Record<string, unknown>;
const valid =
typeof e.worktreePath === 'string' &&
e.worktreePath.length > 0 &&
typeof e.allocatedPort === 'number' &&
Number.isInteger(e.allocatedPort) &&
e.allocatedPort >= 1 &&
e.allocatedPort <= 65535 &&
typeof e.port === 'number' &&
Number.isInteger(e.port) &&
e.port >= 1 &&
e.port <= 65535 &&
typeof e.url === 'string' &&
typeof e.startedAt === 'string' &&
typeof e.urlDetected === 'boolean' &&
(e.customCommand === undefined || typeof e.customCommand === 'string');
if (!valid) {
logger.warn('Dropping malformed dev server entry:', e);
}
return valid;
}) as PersistedDevServerInfo[];
logger.info(`Loading ${persistedInfo.length} dev servers from state`);
for (const info of persistedInfo) {
// Check if the process is still running on the port
// Since we can't reliably re-attach to the process for output,
// we'll just check if the port is in use.
const portInUse = !(await this.isPortAvailable(info.port));
if (portInUse) {
logger.info(`Re-attached to dev server on port ${info.port} for ${info.worktreePath}`);
const serverInfo: DevServerInfo = {
...info,
startedAt: new Date(info.startedAt),
process: null, // Process object is lost, but we know it's running
scrollbackBuffer: '',
outputBuffer: '',
flushTimeout: null,
stopping: false,
urlDetectionTimeout: null,
};
this.runningServers.set(info.worktreePath, serverInfo);
this.allocatedPorts.add(info.allocatedPort);
} else {
logger.info(
`Dev server on port ${info.port} for ${info.worktreePath} is no longer running`
);
}
}
// Cleanup stale entries from the file if any
if (this.runningServers.size !== persistedInfo.length) {
await this.saveState();
}
} catch (error) {
logger.error('Failed to load dev server state:', error);
}
}
/**
* Prune a stale server entry whose process has exited without cleanup.
* Clears any pending timers, removes the port from allocatedPorts, deletes
* the entry from runningServers, and emits the "dev-server:stopped" event
* so all callers consistently notify the frontend when pruning entries.
*
* @param worktreePath - The key used in runningServers
* @param server - The DevServerInfo entry to prune
*/
private pruneStaleServer(worktreePath: string, server: DevServerInfo): void {
if (server.flushTimeout) clearTimeout(server.flushTimeout);
if (server.urlDetectionTimeout) clearTimeout(server.urlDetectionTimeout);
// Use allocatedPort (immutable) to free the reserved slot; server.port may have
// been mutated by detectUrlFromOutput to reflect the actual detected port.
this.allocatedPorts.delete(server.allocatedPort);
this.runningServers.delete(worktreePath);
// Persist state change
this.saveState().catch((err) => logger.error('Failed to save state in pruneStaleServer:', err));
if (this.emitter) {
this.emitter.emit('dev-server:stopped', {
worktreePath,
port: server.port, // Report the externally-visible (detected) port
exitCode: server.process?.exitCode ?? null,
timestamp: new Date().toISOString(),
});
}
}
/** /**
* Append data to scrollback buffer with size limit enforcement * Append data to scrollback buffer with size limit enforcement
* Evicts oldest data when buffer exceeds MAX_SCROLLBACK_SIZE * Evicts oldest data when buffer exceeds MAX_SCROLLBACK_SIZE
@@ -215,7 +411,7 @@ class DevServerService {
* - PHP: "Development Server (http://localhost:8000) started" * - PHP: "Development Server (http://localhost:8000) started"
* - Generic: Any localhost URL with a port * - Generic: Any localhost URL with a port
*/ */
private detectUrlFromOutput(server: DevServerInfo, content: string): void { private async detectUrlFromOutput(server: DevServerInfo, content: string): Promise<void> {
// Skip if URL already detected // Skip if URL already detected
if (server.urlDetected) { if (server.urlDetected) {
return; return;
@@ -253,6 +449,12 @@ class DevServerService {
server.url = detectedUrl; server.url = detectedUrl;
server.urlDetected = true; server.urlDetected = true;
// Clear the URL detection timeout since we found the URL
if (server.urlDetectionTimeout) {
clearTimeout(server.urlDetectionTimeout);
server.urlDetectionTimeout = null;
}
// Update the port to match the detected URL's actual port // Update the port to match the detected URL's actual port
const detectedPort = this.extractPortFromUrl(detectedUrl); const detectedPort = this.extractPortFromUrl(detectedUrl);
if (detectedPort && detectedPort !== server.port) { if (detectedPort && detectedPort !== server.port) {
@@ -264,6 +466,11 @@ class DevServerService {
logger.info(`Detected server URL via ${description}: ${detectedUrl}`); logger.info(`Detected server URL via ${description}: ${detectedUrl}`);
// Persist state change
await this.saveState().catch((err) =>
logger.error('Failed to save state in detectUrlFromOutput:', err)
);
// Emit URL update event // Emit URL update event
if (this.emitter) { if (this.emitter) {
this.emitter.emit('dev-server:url-detected', { this.emitter.emit('dev-server:url-detected', {
@@ -291,6 +498,12 @@ class DevServerService {
server.url = detectedUrl; server.url = detectedUrl;
server.urlDetected = true; server.urlDetected = true;
// Clear the URL detection timeout since we found the port
if (server.urlDetectionTimeout) {
clearTimeout(server.urlDetectionTimeout);
server.urlDetectionTimeout = null;
}
if (detectedPort !== server.port) { if (detectedPort !== server.port) {
logger.info( logger.info(
`Port mismatch: allocated ${server.port}, detected ${detectedPort} from ${description}` `Port mismatch: allocated ${server.port}, detected ${detectedPort} from ${description}`
@@ -300,6 +513,11 @@ class DevServerService {
logger.info(`Detected server port via ${description}: ${detectedPort}${detectedUrl}`); logger.info(`Detected server port via ${description}: ${detectedPort}${detectedUrl}`);
// Persist state change
await this.saveState().catch((err) =>
logger.error('Failed to save state in detectUrlFromOutput Phase 2:', err)
);
// Emit URL update event // Emit URL update event
if (this.emitter) { if (this.emitter) {
this.emitter.emit('dev-server:url-detected', { this.emitter.emit('dev-server:url-detected', {
@@ -319,7 +537,7 @@ class DevServerService {
* Handle incoming stdout/stderr data from dev server process * Handle incoming stdout/stderr data from dev server process
* Buffers data for scrollback replay and schedules throttled emission * Buffers data for scrollback replay and schedules throttled emission
*/ */
private handleProcessOutput(server: DevServerInfo, data: Buffer): void { private async handleProcessOutput(server: DevServerInfo, data: Buffer): Promise<void> {
// Skip output if server is stopping // Skip output if server is stopping
if (server.stopping) { if (server.stopping) {
return; return;
@@ -328,7 +546,7 @@ class DevServerService {
const content = data.toString(); const content = data.toString();
// Try to detect actual server URL from output // Try to detect actual server URL from output
this.detectUrlFromOutput(server, content); await this.detectUrlFromOutput(server, content);
// Append to scrollback buffer for replay on reconnect // Append to scrollback buffer for replay on reconnect
this.appendToScrollback(server, content); this.appendToScrollback(server, content);
@@ -548,9 +766,10 @@ class DevServerService {
}; };
error?: string; error?: string;
}> { }> {
// Check if already running // Check if already running or starting
if (this.runningServers.has(worktreePath)) { if (this.runningServers.has(worktreePath) || this.startingServers.has(worktreePath)) {
const existing = this.runningServers.get(worktreePath)!; const existing = this.runningServers.get(worktreePath);
if (existing) {
return { return {
success: true, success: true,
result: { result: {
@@ -561,7 +780,15 @@ class DevServerService {
}, },
}; };
} }
return {
success: false,
error: 'Dev server is already starting',
};
}
this.startingServers.add(worktreePath);
try {
// Verify the worktree exists // Verify the worktree exists
if (!(await this.fileExists(worktreePath))) { if (!(await this.fileExists(worktreePath))) {
return { return {
@@ -634,6 +861,14 @@ class DevServerService {
logger.debug(`Working directory (cwd): ${worktreePath}`); logger.debug(`Working directory (cwd): ${worktreePath}`);
logger.debug(`Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`); logger.debug(`Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`);
// Emit starting only after preflight checks pass to avoid dangling starting state.
if (this.emitter) {
this.emitter.emit('dev-server:starting', {
worktreePath,
timestamp: new Date().toISOString(),
});
}
// Spawn the dev process with PORT environment variable // Spawn the dev process with PORT environment variable
// FORCE_COLOR enables colored output even when not running in a TTY // FORCE_COLOR enables colored output even when not running in a TTY
const env = { const env = {
@@ -657,11 +892,12 @@ class DevServerService {
// Create server info early so we can reference it in handlers // Create server info early so we can reference it in handlers
// We'll add it to runningServers after verifying the process started successfully // We'll add it to runningServers after verifying the process started successfully
const hostname = process.env.HOSTNAME || 'localhost'; const fallbackHost = 'localhost';
const serverInfo: DevServerInfo = { const serverInfo: DevServerInfo = {
worktreePath, worktreePath,
allocatedPort: port, // Immutable: records which port we reserved; never changed after this point
port, port,
url: `http://${hostname}:${port}`, // Initial URL, may be updated by detectUrlFromOutput url: `http://${fallbackHost}:${port}`, // Initial URL, may be updated by detectUrlFromOutput
process: devProcess, process: devProcess,
startedAt: new Date(), startedAt: new Date(),
scrollbackBuffer: '', scrollbackBuffer: '',
@@ -669,19 +905,25 @@ class DevServerService {
flushTimeout: null, flushTimeout: null,
stopping: false, stopping: false,
urlDetected: false, // Will be set to true when actual URL is detected from output urlDetected: false, // Will be set to true when actual URL is detected from output
urlDetectionTimeout: null, // Will be set after server starts successfully
customCommand: normalizedCustomCommand,
}; };
// Capture stdout with buffer management and event emission // Capture stdout with buffer management and event emission
if (devProcess.stdout) { if (devProcess.stdout) {
devProcess.stdout.on('data', (data: Buffer) => { devProcess.stdout.on('data', (data: Buffer) => {
this.handleProcessOutput(serverInfo, data); this.handleProcessOutput(serverInfo, data).catch((error: unknown) => {
logger.error('Failed to handle dev server stdout output:', error);
});
}); });
} }
// Capture stderr with buffer management and event emission // Capture stderr with buffer management and event emission
if (devProcess.stderr) { if (devProcess.stderr) {
devProcess.stderr.on('data', (data: Buffer) => { devProcess.stderr.on('data', (data: Buffer) => {
this.handleProcessOutput(serverInfo, data); this.handleProcessOutput(serverInfo, data).catch((error: unknown) => {
logger.error('Failed to handle dev server stderr output:', error);
});
}); });
} }
@@ -692,19 +934,28 @@ class DevServerService {
serverInfo.flushTimeout = null; serverInfo.flushTimeout = null;
} }
// Clear URL detection timeout to prevent stale fallback emission
if (serverInfo.urlDetectionTimeout) {
clearTimeout(serverInfo.urlDetectionTimeout);
serverInfo.urlDetectionTimeout = null;
}
// Emit stopped event (only if not already stopping - prevents duplicate events) // Emit stopped event (only if not already stopping - prevents duplicate events)
if (this.emitter && !serverInfo.stopping) { if (this.emitter && !serverInfo.stopping) {
this.emitter.emit('dev-server:stopped', { this.emitter.emit('dev-server:stopped', {
worktreePath, worktreePath,
port, port: serverInfo.port, // Use the detected port (may differ from allocated port if detectUrlFromOutput updated it)
exitCode, exitCode,
error: errorMessage, error: errorMessage,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
} }
this.allocatedPorts.delete(port); this.allocatedPorts.delete(serverInfo.allocatedPort);
this.runningServers.delete(worktreePath); this.runningServers.delete(worktreePath);
// Persist state change
this.saveState().catch((err) => logger.error('Failed to save state in cleanup:', err));
}; };
devProcess.on('error', (error) => { devProcess.on('error', (error) => {
@@ -739,6 +990,11 @@ class DevServerService {
// Server started successfully - add to running servers map // Server started successfully - add to running servers map
this.runningServers.set(worktreePath, serverInfo); this.runningServers.set(worktreePath, serverInfo);
// Persist state change
await this.saveState().catch((err) =>
logger.error('Failed to save state in startDevServer:', err)
);
// Emit started event for WebSocket subscribers // Emit started event for WebSocket subscribers
if (this.emitter) { if (this.emitter) {
this.emitter.emit('dev-server:started', { this.emitter.emit('dev-server:started', {
@@ -749,15 +1005,66 @@ class DevServerService {
}); });
} }
// Set up URL detection timeout fallback.
// If URL detection hasn't succeeded after URL_DETECTION_TIMEOUT_MS, check if
// the allocated port is actually in use (server probably started successfully)
// and emit a url-detected event with the allocated port as fallback.
// Also re-scan the scrollback buffer in case the URL was printed before
// our patterns could match (e.g., it was split across multiple data chunks).
serverInfo.urlDetectionTimeout = setTimeout(async () => {
serverInfo.urlDetectionTimeout = null;
// Only run fallback if server is still running and URL wasn't detected
if (
serverInfo.stopping ||
serverInfo.urlDetected ||
!this.runningServers.has(worktreePath)
) {
return;
}
// Re-scan the entire scrollback buffer for URL patterns
// This catches cases where the URL was split across multiple output chunks
logger.info(`URL detection timeout for ${worktreePath}, re-scanning scrollback buffer`);
await this.detectUrlFromOutput(serverInfo, serverInfo.scrollbackBuffer).catch((err) =>
logger.error('Failed to re-scan scrollback buffer:', err)
);
// If still not detected after full rescan, use the allocated port as fallback
if (!serverInfo.urlDetected) {
logger.info(`URL detection fallback: using allocated port ${port} for ${worktreePath}`);
const fallbackUrl = `http://${fallbackHost}:${port}`;
serverInfo.url = fallbackUrl;
serverInfo.urlDetected = true;
// Persist state change
await this.saveState().catch((err) =>
logger.error('Failed to save state in URL detection fallback:', err)
);
if (this.emitter) {
this.emitter.emit('dev-server:url-detected', {
worktreePath: serverInfo.worktreePath,
url: fallbackUrl,
port,
timestamp: new Date().toISOString(),
});
}
}
}, URL_DETECTION_TIMEOUT_MS);
return { return {
success: true, success: true,
result: { result: {
worktreePath, worktreePath: serverInfo.worktreePath,
port, port: serverInfo.port,
url: `http://${hostname}:${port}`, url: serverInfo.url,
message: `Dev server started on port ${port}`, message: `Dev server started on port ${port}`,
}, },
}; };
} finally {
this.startingServers.delete(worktreePath);
}
} }
/** /**
@@ -794,6 +1101,12 @@ class DevServerService {
server.flushTimeout = null; server.flushTimeout = null;
} }
// Clean up URL detection timeout
if (server.urlDetectionTimeout) {
clearTimeout(server.urlDetectionTimeout);
server.urlDetectionTimeout = null;
}
// Clear any pending output buffer // Clear any pending output buffer
server.outputBuffer = ''; server.outputBuffer = '';
@@ -807,15 +1120,24 @@ class DevServerService {
}); });
} }
// Kill the process // Kill the process; persisted/re-attached entries may not have a process handle.
if (server.process && !server.process.killed) { if (server.process && !server.process.killed) {
server.process.kill('SIGTERM'); server.process.kill('SIGTERM');
} else {
this.killProcessOnPort(server.port);
} }
// Free the port // Free the originally-reserved port slot (allocatedPort is immutable and always
this.allocatedPorts.delete(server.port); // matches what was added to allocatedPorts in startDevServer; server.port may
// have been updated by detectUrlFromOutput to the actual detected port).
this.allocatedPorts.delete(server.allocatedPort);
this.runningServers.delete(worktreePath); this.runningServers.delete(worktreePath);
// Persist state change
await this.saveState().catch((err) =>
logger.error('Failed to save state in stopDevServer:', err)
);
return { return {
success: true, success: true,
result: { result: {
@@ -827,6 +1149,7 @@ class DevServerService {
/** /**
* List all running dev servers * List all running dev servers
* Also verifies that each server's process is still alive, removing stale entries
*/ */
listDevServers(): { listDevServers(): {
success: boolean; success: boolean;
@@ -836,14 +1159,37 @@ class DevServerService {
port: number; port: number;
url: string; url: string;
urlDetected: boolean; urlDetected: boolean;
startedAt: string;
}>; }>;
}; };
} { } {
// Prune any servers whose process has died without us being notified
// This handles edge cases where the process exited but the 'exit' event was missed
const stalePaths: string[] = [];
for (const [worktreePath, server] of this.runningServers) {
// Check if exitCode is a number (not null/undefined) - indicates process has exited
if (server.process && typeof server.process.exitCode === 'number') {
logger.info(
`Pruning stale server entry for ${worktreePath} (process exited with code ${server.process.exitCode})`
);
stalePaths.push(worktreePath);
}
}
for (const stalePath of stalePaths) {
const server = this.runningServers.get(stalePath);
if (server) {
// Delegate to the shared helper so timers, ports, and the stopped event
// are all handled consistently with isRunning and getServerInfo.
this.pruneStaleServer(stalePath, server);
}
}
const servers = Array.from(this.runningServers.values()).map((s) => ({ const servers = Array.from(this.runningServers.values()).map((s) => ({
worktreePath: s.worktreePath, worktreePath: s.worktreePath,
port: s.port, port: s.port,
url: s.url, url: s.url,
urlDetected: s.urlDetected, urlDetected: s.urlDetected,
startedAt: s.startedAt.toISOString(),
})); }));
return { return {
@@ -853,17 +1199,33 @@ class DevServerService {
} }
/** /**
* Check if a worktree has a running dev server * Check if a worktree has a running dev server.
* Also prunes stale entries where the process has exited.
*/ */
isRunning(worktreePath: string): boolean { isRunning(worktreePath: string): boolean {
return this.runningServers.has(worktreePath); const server = this.runningServers.get(worktreePath);
if (!server) return false;
// Prune stale entry if the process has exited
if (server.process && typeof server.process.exitCode === 'number') {
this.pruneStaleServer(worktreePath, server);
return false;
}
return true;
} }
/** /**
* Get info for a specific worktree's dev server * Get info for a specific worktree's dev server.
* Also prunes stale entries where the process has exited.
*/ */
getServerInfo(worktreePath: string): DevServerInfo | undefined { getServerInfo(worktreePath: string): DevServerInfo | undefined {
return this.runningServers.get(worktreePath); const server = this.runningServers.get(worktreePath);
if (!server) return undefined;
// Prune stale entry if the process has exited
if (server.process && typeof server.process.exitCode === 'number') {
this.pruneStaleServer(worktreePath, server);
return undefined;
}
return server;
} }
/** /**
@@ -891,6 +1253,15 @@ class DevServerService {
}; };
} }
// Prune stale entry if the process has been killed or has exited
if (server.process && (server.process.killed || server.process.exitCode != null)) {
this.pruneStaleServer(worktreePath, server);
return {
success: false,
error: `No dev server running for worktree: ${worktreePath}`,
};
}
return { return {
success: true, success: true,
result: { result: {

View File

@@ -27,7 +27,11 @@ import type {
EventHookTrigger, EventHookTrigger,
EventHookShellAction, EventHookShellAction,
EventHookHttpAction, EventHookHttpAction,
EventHookNtfyAction,
NtfyEndpointConfig,
EventHookContext,
} from '@automaker/types'; } from '@automaker/types';
import { ntfyService, type NtfyContext } from './ntfy-service.js';
const execAsync = promisify(exec); const execAsync = promisify(exec);
const logger = createLogger('EventHooks'); const logger = createLogger('EventHooks');
@@ -38,19 +42,8 @@ const DEFAULT_SHELL_TIMEOUT = 30000;
/** Default timeout for HTTP requests (10 seconds) */ /** Default timeout for HTTP requests (10 seconds) */
const DEFAULT_HTTP_TIMEOUT = 10000; const DEFAULT_HTTP_TIMEOUT = 10000;
/** // Use the shared EventHookContext type (aliased locally as HookContext for clarity)
* Context available for variable substitution in hooks type HookContext = EventHookContext;
*/
interface HookContext {
featureId?: string;
featureName?: string;
projectPath?: string;
projectName?: string;
error?: string;
errorType?: string;
timestamp: string;
eventType: EventHookTrigger;
}
/** /**
* Auto-mode event payload structure * Auto-mode event payload structure
@@ -60,10 +53,13 @@ interface AutoModeEventPayload {
featureId?: string; featureId?: string;
featureName?: string; featureName?: string;
passes?: boolean; passes?: boolean;
executionMode?: 'auto' | 'manual';
message?: string; message?: string;
error?: string; error?: string;
errorType?: string; errorType?: string;
projectPath?: string; projectPath?: string;
/** Status field present when type === 'feature_status_changed' */
status?: string;
} }
/** /**
@@ -75,6 +71,40 @@ interface FeatureCreatedPayload {
projectPath: string; projectPath: string;
} }
/**
* Feature status changed event payload structure
*/
interface FeatureStatusChangedPayload {
featureId: string;
projectPath: string;
status: string;
}
/**
* Type guard to safely narrow AutoModeEventPayload to FeatureStatusChangedPayload
*/
function isFeatureStatusChangedPayload(
payload: AutoModeEventPayload
): payload is AutoModeEventPayload & FeatureStatusChangedPayload {
return (
typeof payload.featureId === 'string' &&
typeof payload.projectPath === 'string' &&
typeof payload.status === 'string'
);
}
/**
* Feature completed event payload structure
*/
interface FeatureCompletedPayload {
featureId: string;
featureName?: string;
projectPath: string;
passes?: boolean;
message?: string;
executionMode?: 'auto' | 'manual';
}
/** /**
* Event Hook Service * Event Hook Service
* *
@@ -82,12 +112,30 @@ interface FeatureCreatedPayload {
* Also stores events to history for debugging and replay. * Also stores events to history for debugging and replay.
*/ */
export class EventHookService { export class EventHookService {
/** Feature status that indicates agent work is done and awaiting human review (tests skipped) */
private static readonly STATUS_WAITING_APPROVAL = 'waiting_approval';
/** Feature status that indicates agent work passed automated verification */
private static readonly STATUS_VERIFIED = 'verified';
private emitter: EventEmitter | null = null; private emitter: EventEmitter | null = null;
private settingsService: SettingsService | null = null; private settingsService: SettingsService | null = null;
private eventHistoryService: EventHistoryService | null = null; private eventHistoryService: EventHistoryService | null = null;
private featureLoader: FeatureLoader | null = null; private featureLoader: FeatureLoader | null = null;
private unsubscribe: (() => void) | null = null; private unsubscribe: (() => void) | null = null;
/**
* Track feature IDs that have already had hooks fired via auto_mode_feature_complete
* to prevent double-firing when feature_status_changed also fires for the same feature.
* Entries are automatically cleaned up after 30 seconds.
*/
private recentlyHandledFeatures = new Set<string>();
/**
* Timer IDs for pending cleanup of recentlyHandledFeatures entries,
* keyed by featureId. Stored so they can be cancelled in destroy().
*/
private recentlyHandledTimers = new Map<string, ReturnType<typeof setTimeout>>();
/** /**
* Initialize the service with event emitter, settings service, event history service, and feature loader * Initialize the service with event emitter, settings service, event history service, and feature loader
*/ */
@@ -108,6 +156,8 @@ export class EventHookService {
this.handleAutoModeEvent(payload as AutoModeEventPayload); this.handleAutoModeEvent(payload as AutoModeEventPayload);
} else if (type === 'feature:created') { } else if (type === 'feature:created') {
this.handleFeatureCreatedEvent(payload as FeatureCreatedPayload); this.handleFeatureCreatedEvent(payload as FeatureCreatedPayload);
} else if (type === 'feature:completed') {
this.handleFeatureCompletedEvent(payload as FeatureCompletedPayload);
} }
}); });
@@ -122,6 +172,12 @@ export class EventHookService {
this.unsubscribe(); this.unsubscribe();
this.unsubscribe = null; this.unsubscribe = null;
} }
// Cancel all pending cleanup timers to avoid cross-session mutations
for (const timerId of this.recentlyHandledTimers.values()) {
clearTimeout(timerId);
}
this.recentlyHandledTimers.clear();
this.recentlyHandledFeatures.clear();
this.emitter = null; this.emitter = null;
this.settingsService = null; this.settingsService = null;
this.eventHistoryService = null; this.eventHistoryService = null;
@@ -139,15 +195,31 @@ export class EventHookService {
switch (payload.type) { switch (payload.type) {
case 'auto_mode_feature_complete': case 'auto_mode_feature_complete':
// Only map explicit auto-mode completion events.
// Manual feature completions are emitted as feature:completed.
if (payload.executionMode !== 'auto') return;
trigger = payload.passes ? 'feature_success' : 'feature_error'; trigger = payload.passes ? 'feature_success' : 'feature_error';
// Track this feature so feature_status_changed doesn't double-fire hooks
if (payload.featureId) {
this.markFeatureHandled(payload.featureId);
}
break; break;
case 'auto_mode_error': case 'auto_mode_error':
// Feature-level error (has featureId) vs auto-mode level error // Feature-level error (has featureId) vs auto-mode level error
trigger = payload.featureId ? 'feature_error' : 'auto_mode_error'; trigger = payload.featureId ? 'feature_error' : 'auto_mode_error';
// Track this feature so feature_status_changed doesn't double-fire hooks
if (payload.featureId) {
this.markFeatureHandled(payload.featureId);
}
break; break;
case 'auto_mode_idle': case 'auto_mode_idle':
trigger = 'auto_mode_complete'; trigger = 'auto_mode_complete';
break; break;
case 'feature_status_changed':
if (isFeatureStatusChangedPayload(payload)) {
this.handleFeatureStatusChanged(payload);
}
return;
default: default:
// Other event types don't trigger hooks // Other event types don't trigger hooks
return; return;
@@ -170,13 +242,15 @@ export class EventHookService {
// Build context for variable substitution // Build context for variable substitution
// Use loaded featureName (from feature.title) or fall back to payload.featureName // Use loaded featureName (from feature.title) or fall back to payload.featureName
// Only populate error/errorType for error triggers - don't leak success messages into error fields
const isErrorTrigger = trigger === 'feature_error' || trigger === 'auto_mode_error';
const context: HookContext = { const context: HookContext = {
featureId: payload.featureId, featureId: payload.featureId,
featureName: featureName || payload.featureName, featureName: featureName || payload.featureName,
projectPath: payload.projectPath, projectPath: payload.projectPath,
projectName: payload.projectPath ? this.extractProjectName(payload.projectPath) : undefined, projectName: payload.projectPath ? this.extractProjectName(payload.projectPath) : undefined,
error: payload.error || payload.message, error: isErrorTrigger ? payload.error || payload.message : undefined,
errorType: payload.errorType, errorType: isErrorTrigger ? payload.errorType : undefined,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
eventType: trigger, eventType: trigger,
}; };
@@ -185,6 +259,46 @@ export class EventHookService {
await this.executeHooksForTrigger(trigger, context, { passes: payload.passes }); await this.executeHooksForTrigger(trigger, context, { passes: payload.passes });
} }
/**
* Handle feature:completed events and trigger matching hooks
*/
private async handleFeatureCompletedEvent(payload: FeatureCompletedPayload): Promise<void> {
if (!payload.featureId || !payload.projectPath) return;
// Mark as handled to prevent duplicate firing if feature_status_changed also fires
this.markFeatureHandled(payload.featureId);
const passes = payload.passes ?? true;
const trigger: EventHookTrigger = passes ? 'feature_success' : 'feature_error';
// Load feature name if we have featureId but no featureName
let featureName: string | undefined = undefined;
if (payload.projectPath && this.featureLoader) {
try {
const feature = await this.featureLoader.get(payload.projectPath, payload.featureId);
if (feature?.title) {
featureName = feature.title;
}
} catch (error) {
logger.warn(`Failed to load feature ${payload.featureId} for event hook:`, error);
}
}
const isErrorTrigger = trigger === 'feature_error';
const context: HookContext = {
featureId: payload.featureId,
featureName: featureName || payload.featureName,
projectPath: payload.projectPath,
projectName: this.extractProjectName(payload.projectPath),
error: isErrorTrigger ? payload.message : undefined,
errorType: undefined,
timestamp: new Date().toISOString(),
eventType: trigger,
};
await this.executeHooksForTrigger(trigger, context, { passes });
}
/** /**
* Handle feature:created events and trigger matching hooks * Handle feature:created events and trigger matching hooks
*/ */
@@ -201,6 +315,74 @@ export class EventHookService {
await this.executeHooksForTrigger('feature_created', context); await this.executeHooksForTrigger('feature_created', context);
} }
/**
* Handle feature_status_changed events for non-auto-mode feature completion.
*
* Auto-mode features already emit auto_mode_feature_complete which triggers hooks.
* This handler catches manual (non-auto-mode) feature completions by detecting
* status transitions to completion states (verified, waiting_approval).
*/
private async handleFeatureStatusChanged(payload: FeatureStatusChangedPayload): Promise<void> {
// Skip if this feature was already handled via auto_mode_feature_complete
if (this.recentlyHandledFeatures.has(payload.featureId)) {
return;
}
let trigger: EventHookTrigger | null = null;
if (
payload.status === EventHookService.STATUS_VERIFIED ||
payload.status === EventHookService.STATUS_WAITING_APPROVAL
) {
trigger = 'feature_success';
} else {
// Only completion statuses trigger hooks from status changes
return;
}
// Load feature name
let featureName: string | undefined = undefined;
if (this.featureLoader) {
try {
const feature = await this.featureLoader.get(payload.projectPath, payload.featureId);
if (feature?.title) {
featureName = feature.title;
}
} catch (error) {
logger.warn(`Failed to load feature ${payload.featureId} for status change hook:`, error);
}
}
const context: HookContext = {
featureId: payload.featureId,
featureName,
projectPath: payload.projectPath,
projectName: this.extractProjectName(payload.projectPath),
timestamp: new Date().toISOString(),
eventType: trigger,
};
await this.executeHooksForTrigger(trigger, context, { passes: true });
}
/**
* Mark a feature as recently handled to prevent double-firing hooks.
* Entries are cleaned up after 30 seconds.
*/
private markFeatureHandled(featureId: string): void {
// Cancel any existing timer for this feature before setting a new one
const existing = this.recentlyHandledTimers.get(featureId);
if (existing !== undefined) {
clearTimeout(existing);
}
this.recentlyHandledFeatures.add(featureId);
const timerId = setTimeout(() => {
this.recentlyHandledFeatures.delete(featureId);
this.recentlyHandledTimers.delete(featureId);
}, 30000);
this.recentlyHandledTimers.set(featureId, timerId);
}
/** /**
* Execute all enabled hooks matching the given trigger and store event to history * Execute all enabled hooks matching the given trigger and store event to history
*/ */
@@ -262,6 +444,8 @@ export class EventHookService {
await this.executeShellHook(hook.action, context, hookName); await this.executeShellHook(hook.action, context, hookName);
} else if (hook.action.type === 'http') { } else if (hook.action.type === 'http') {
await this.executeHttpHook(hook.action, context, hookName); await this.executeHttpHook(hook.action, context, hookName);
} else if (hook.action.type === 'ntfy') {
await this.executeNtfyHook(hook.action, context, hookName);
} }
} catch (error) { } catch (error) {
logger.error(`Hook "${hookName}" failed:`, error); logger.error(`Hook "${hookName}" failed:`, error);
@@ -369,6 +553,86 @@ export class EventHookService {
} }
} }
/**
* Execute an ntfy.sh notification hook
*/
private async executeNtfyHook(
action: EventHookNtfyAction,
context: HookContext,
hookName: string
): Promise<void> {
if (!this.settingsService) {
logger.warn('Settings service not available for ntfy hook');
return;
}
// Get the endpoint configuration
const settings = await this.settingsService.getGlobalSettings();
const endpoints = settings.ntfyEndpoints || [];
const endpoint = endpoints.find((e) => e.id === action.endpointId);
if (!endpoint) {
logger.error(`Ntfy hook "${hookName}" references unknown endpoint: ${action.endpointId}`);
return;
}
// Convert HookContext to NtfyContext
const ntfyContext: NtfyContext = {
featureId: context.featureId,
featureName: context.featureName,
projectPath: context.projectPath,
projectName: context.projectName,
error: context.error,
errorType: context.errorType,
timestamp: context.timestamp,
eventType: context.eventType,
};
// Resolve click URL: action-level overrides endpoint default
let clickUrl = action.clickUrl || endpoint.defaultClickUrl;
// Apply deep-link parameters to the resolved click URL
if (clickUrl && context.projectPath) {
try {
const url = new URL(clickUrl);
url.pathname = '/board';
// Add projectPath so the UI can switch to the correct project
url.searchParams.set('projectPath', context.projectPath);
// Add featureId as query param for deep linking to board with feature output modal
if (context.featureId) {
url.searchParams.set('featureId', context.featureId);
}
clickUrl = url.toString();
} catch (error) {
// If URL parsing fails, log warning and use as-is
logger.warn(
`Failed to parse click URL "${clickUrl}" for deep linking: ${error instanceof Error ? error.message : String(error)}`
);
}
}
logger.info(`Executing ntfy hook "${hookName}" to endpoint "${endpoint.name}"`);
const result = await ntfyService.sendNotification(
endpoint,
{
title: action.title,
body: action.body,
tags: action.tags,
emoji: action.emoji,
clickUrl,
priority: action.priority,
},
ntfyContext
);
if (!result.success) {
logger.warn(`Ntfy hook "${hookName}" failed: ${result.error}`);
} else {
logger.info(`Ntfy hook "${hookName}" completed successfully`);
}
}
/** /**
* Substitute {{variable}} placeholders in a string * Substitute {{variable}} placeholders in a string
*/ */

View File

@@ -12,6 +12,7 @@ import * as secureFs from '../lib/secure-fs.js';
import { import {
getPromptCustomization, getPromptCustomization,
getAutoLoadClaudeMdSetting, getAutoLoadClaudeMdSetting,
getUseClaudeCodeSystemPromptSetting,
filterClaudeMdFromContext, filterClaudeMdFromContext,
} from '../lib/settings-helpers.js'; } from '../lib/settings-helpers.js';
import { validateWorkingDirectory } from '../lib/sdk-options.js'; import { validateWorkingDirectory } from '../lib/sdk-options.js';
@@ -59,6 +60,12 @@ import type {
const logger = createLogger('ExecutionService'); const logger = createLogger('ExecutionService');
/** Marker written by agent-executor for each tool invocation. */
const TOOL_USE_MARKER = '🔧 Tool:';
/** Minimum trimmed output length to consider agent work meaningful. */
const MIN_MEANINGFUL_OUTPUT_LENGTH = 200;
export class ExecutionService { export class ExecutionService {
constructor( constructor(
private eventBus: TypedEventBus, private eventBus: TypedEventBus,
@@ -101,16 +108,14 @@ export class ExecutionService {
return firstLine.length <= 60 ? firstLine : firstLine.substring(0, 57) + '...'; return firstLine.length <= 60 ? firstLine : firstLine.substring(0, 57) + '...';
} }
buildFeaturePrompt( /**
feature: Feature, * Build feature description section (without implementation instructions).
taskExecutionPrompts: { * Used when planning mode is active — the planning prompt provides its own instructions.
implementationInstructions: string; */
playwrightVerificationInstructions: string; buildFeatureDescription(feature: Feature): string {
}
): string {
const title = this.extractTitleFromDescription(feature.description); const title = this.extractTitleFromDescription(feature.description);
let prompt = `## Feature Implementation Task let prompt = `## Feature Task
**Feature ID:** ${feature.id} **Feature ID:** ${feature.id}
**Title:** ${title} **Title:** ${title}
@@ -139,6 +144,18 @@ ${feature.spec}
prompt += `\n**Context Images Attached:**\n${feature.imagePaths.length} image(s) attached:\n${imagesList}\n`; prompt += `\n**Context Images Attached:**\n${feature.imagePaths.length} image(s) attached:\n${imagesList}\n`;
} }
return prompt;
}
buildFeaturePrompt(
feature: Feature,
taskExecutionPrompts: {
implementationInstructions: string;
playwrightVerificationInstructions: string;
}
): string {
let prompt = this.buildFeatureDescription(feature);
prompt += feature.skipTests prompt += feature.skipTests
? `\n${taskExecutionPrompts.implementationInstructions}` ? `\n${taskExecutionPrompts.implementationInstructions}`
: `\n${taskExecutionPrompts.implementationInstructions}\n\n${taskExecutionPrompts.playwrightVerificationInstructions}`; : `\n${taskExecutionPrompts.implementationInstructions}\n\n${taskExecutionPrompts.playwrightVerificationInstructions}`;
@@ -162,6 +179,7 @@ ${feature.spec}
const abortController = tempRunningFeature.abortController; const abortController = tempRunningFeature.abortController;
if (isAutoMode) await this.saveExecutionStateFn(projectPath); if (isAutoMode) await this.saveExecutionStateFn(projectPath);
let feature: Feature | null = null; let feature: Feature | null = null;
let pipelineCompleted = false;
try { try {
validateWorkingDirectory(projectPath); validateWorkingDirectory(projectPath);
@@ -207,7 +225,12 @@ ${feature.spec}
const branchName = feature.branchName; const branchName = feature.branchName;
if (!worktreePath && useWorktrees && branchName) { if (!worktreePath && useWorktrees && branchName) {
worktreePath = await this.worktreeResolver.findWorktreeForBranch(projectPath, branchName); worktreePath = await this.worktreeResolver.findWorktreeForBranch(projectPath, branchName);
if (worktreePath) logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`); if (!worktreePath) {
throw new Error(
`Worktree enabled but no worktree found for feature branch "${branchName}".`
);
}
logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`);
} }
const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath); const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath);
validateWorkingDirectory(workDir); validateWorkingDirectory(workDir);
@@ -241,6 +264,11 @@ ${feature.spec}
this.settingsService, this.settingsService,
'[ExecutionService]' '[ExecutionService]'
); );
const useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting(
projectPath,
this.settingsService,
'[ExecutionService]'
);
const prompts = await getPromptCustomization(this.settingsService, '[ExecutionService]'); const prompts = await getPromptCustomization(this.settingsService, '[ExecutionService]');
let prompt: string; let prompt: string;
const contextResult = await this.loadContextFilesFn({ const contextResult = await this.loadContextFilesFn({
@@ -256,9 +284,15 @@ ${feature.spec}
if (options?.continuationPrompt) { if (options?.continuationPrompt) {
prompt = options.continuationPrompt; prompt = options.continuationPrompt;
} else { } else {
prompt = const planningPrefix = await this.getPlanningPromptPrefixFn(feature);
(await this.getPlanningPromptPrefixFn(feature)) + if (planningPrefix) {
this.buildFeaturePrompt(feature, prompts.taskExecution); // Planning mode active: use planning instructions + feature description only.
// Do NOT include implementationInstructions — they conflict with the planning
// prompt's "DO NOT proceed with implementation until approval" directive.
prompt = planningPrefix + '\n\n' + this.buildFeatureDescription(feature);
} else {
prompt = this.buildFeaturePrompt(feature, prompts.taskExecution);
}
if (feature.planningMode && feature.planningMode !== 'skip') { if (feature.planningMode && feature.planningMode !== 'skip') {
this.eventBus.emitAutoModeEvent('planning_started', { this.eventBus.emitAutoModeEvent('planning_started', {
featureId: feature.id, featureId: feature.id,
@@ -289,7 +323,10 @@ ${feature.spec}
requirePlanApproval: feature.requirePlanApproval, requirePlanApproval: feature.requirePlanApproval,
systemPrompt: combinedSystemPrompt || undefined, systemPrompt: combinedSystemPrompt || undefined,
autoLoadClaudeMd, autoLoadClaudeMd,
useClaudeCodeSystemPrompt,
thinkingLevel: feature.thinkingLevel, thinkingLevel: feature.thinkingLevel,
reasoningEffort: feature.reasoningEffort,
providerId: feature.providerId,
branchName: feature.branchName ?? null, branchName: feature.branchName ?? null,
} }
); );
@@ -353,7 +390,10 @@ Please continue from where you left off and complete all remaining tasks. Use th
requirePlanApproval: false, requirePlanApproval: false,
systemPrompt: combinedSystemPrompt || undefined, systemPrompt: combinedSystemPrompt || undefined,
autoLoadClaudeMd, autoLoadClaudeMd,
useClaudeCodeSystemPrompt,
thinkingLevel: feature.thinkingLevel, thinkingLevel: feature.thinkingLevel,
reasoningEffort: feature.reasoningEffort,
providerId: feature.providerId,
branchName: feature.branchName ?? null, branchName: feature.branchName ?? null,
} }
); );
@@ -388,9 +428,11 @@ Please continue from where you left off and complete all remaining tasks. Use th
branchName: feature.branchName ?? null, branchName: feature.branchName ?? null,
abortController, abortController,
autoLoadClaudeMd, autoLoadClaudeMd,
useClaudeCodeSystemPrompt,
testAttempts: 0, testAttempts: 0,
maxTestAttempts: 5, maxTestAttempts: 5,
}); });
pipelineCompleted = true;
// Check if pipeline set a terminal status (e.g., merge_conflict) — don't overwrite it // Check if pipeline set a terminal status (e.g., merge_conflict) — don't overwrite it
const refreshed = await this.loadFeatureFn(projectPath, featureId); const refreshed = await this.loadFeatureFn(projectPath, featureId);
if (refreshed?.status === 'merge_conflict') { if (refreshed?.status === 'merge_conflict') {
@@ -398,7 +440,41 @@ Please continue from where you left off and complete all remaining tasks. Use th
} }
} }
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; // Read agent output before determining final status.
// CLI-based providers (Cursor, Codex, etc.) may exit quickly without doing
// meaningful work. Check output to avoid prematurely marking as 'verified'.
const outputPath = path.join(getFeatureDir(projectPath, featureId), 'agent-output.md');
let agentOutput = '';
try {
agentOutput = (await secureFs.readFile(outputPath, 'utf-8')) as string;
} catch {
/* */
}
// Determine if the agent did meaningful work by checking for tool usage
// indicators in the output. The agent executor writes "🔧 Tool:" markers
// each time a tool is invoked. No tool usage suggests the CLI exited
// without performing implementation work.
const hasToolUsage = agentOutput.includes(TOOL_USE_MARKER);
const isOutputTooShort = agentOutput.trim().length < MIN_MEANINGFUL_OUTPUT_LENGTH;
const agentDidWork = hasToolUsage && !isOutputTooShort;
let finalStatus: 'verified' | 'waiting_approval';
if (feature.skipTests) {
finalStatus = 'waiting_approval';
} else if (!agentDidWork) {
// Agent didn't produce meaningful output (e.g., CLI exited quickly).
// Route to waiting_approval so the user can review and re-run.
finalStatus = 'waiting_approval';
logger.warn(
`[executeFeature] Feature ${featureId}: agent produced insufficient output ` +
`(${agentOutput.trim().length}/${MIN_MEANINGFUL_OUTPUT_LENGTH} chars, toolUsage=${hasToolUsage}). ` +
`Setting status to waiting_approval instead of verified.`
);
} else {
finalStatus = 'verified';
}
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus); await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
this.recordSuccessFn(); this.recordSuccessFn();
@@ -410,14 +486,10 @@ Please continue from where you left off and complete all remaining tasks. Use th
const hasIncompleteTasks = totalTasks > 0 && completedTasks < totalTasks; const hasIncompleteTasks = totalTasks > 0 && completedTasks < totalTasks;
try { try {
const outputPath = path.join(getFeatureDir(projectPath, featureId), 'agent-output.md'); // Only save summary if feature doesn't already have one (e.g., accumulated from pipeline steps)
let agentOutput = ''; // This prevents overwriting accumulated summaries with just the last step's output
try { // The agent-executor already extracts and saves summaries during execution
agentOutput = (await secureFs.readFile(outputPath, 'utf-8')) as string; if (agentOutput && !completedFeature?.summary) {
} catch {
/* */
}
if (agentOutput) {
const summary = extractSummary(agentOutput); const summary = extractSummary(agentOutput);
if (summary) await this.saveFeatureSummaryFn(projectPath, featureId, summary); if (summary) await this.saveFeatureSummaryFn(projectPath, featureId, summary);
} }
@@ -441,31 +513,60 @@ Please continue from where you left off and complete all remaining tasks. Use th
if (hasIncompleteTasks) if (hasIncompleteTasks)
completionMessage += ` (${completedTasks}/${totalTasks} tasks completed)`; completionMessage += ` (${completedTasks}/${totalTasks} tasks completed)`;
if (isAutoMode) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
featureName: feature.title, featureName: feature.title,
branchName: feature.branchName ?? null, branchName: feature.branchName ?? null,
executionMode: 'auto',
passes: true, passes: true,
message: completionMessage, message: completionMessage,
projectPath, projectPath,
model: tempRunningFeature.model, model: tempRunningFeature.model,
provider: tempRunningFeature.provider, provider: tempRunningFeature.provider,
}); });
}
} catch (error) { } catch (error) {
const errorInfo = classifyError(error); const errorInfo = classifyError(error);
if (errorInfo.isAbort) { if (errorInfo.isAbort) {
await this.updateFeatureStatusFn(projectPath, featureId, 'interrupted'); await this.updateFeatureStatusFn(projectPath, featureId, 'interrupted');
if (isAutoMode) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
featureName: feature?.title, featureName: feature?.title,
branchName: feature?.branchName ?? null, branchName: feature?.branchName ?? null,
executionMode: 'auto',
passes: false, passes: false,
message: 'Feature stopped by user', message: 'Feature stopped by user',
projectPath, projectPath,
}); });
}
} else { } else {
logger.error(`Feature ${featureId} failed:`, error); logger.error(`Feature ${featureId} failed:`, error);
await this.updateFeatureStatusFn(projectPath, featureId, 'backlog'); // If pipeline steps completed successfully, don't send the feature back to backlog.
// The pipeline work is done — set to waiting_approval so the user can review.
const fallbackStatus = pipelineCompleted ? 'waiting_approval' : 'backlog';
if (pipelineCompleted) {
logger.info(
`[executeFeature] Feature ${featureId} failed after pipeline completed. ` +
`Setting status to waiting_approval instead of backlog to preserve pipeline work.`
);
}
// Don't overwrite terminal states like 'merge_conflict' that were set during pipeline execution
let currentStatus: string | undefined;
try {
const currentFeature = await this.loadFeatureFn(projectPath, featureId);
currentStatus = currentFeature?.status;
} catch (loadErr) {
// If loading fails, log it and proceed with the status update anyway
logger.warn(
`[executeFeature] Failed to reload feature ${featureId} for status check:`,
loadErr
);
}
if (currentStatus !== 'merge_conflict') {
await this.updateFeatureStatusFn(projectPath, featureId, fallbackStatus);
}
this.eventBus.emitAutoModeEvent('auto_mode_error', { this.eventBus.emitAutoModeEvent('auto_mode_error', {
featureId, featureId,
featureName: feature?.title, featureName: feature?.title,
@@ -487,6 +588,22 @@ Please continue from where you left off and complete all remaining tasks. Use th
async stopFeature(featureId: string): Promise<boolean> { async stopFeature(featureId: string): Promise<boolean> {
const running = this.concurrencyManager.getRunningFeature(featureId); const running = this.concurrencyManager.getRunningFeature(featureId);
if (!running) return false; if (!running) return false;
const { projectPath } = running;
// Immediately update feature status to 'interrupted' so the UI reflects
// the stop right away. CLI-based providers can take seconds to terminate
// their subprocess after the abort signal fires, leaving the feature stuck
// in 'in_progress' on the Kanban board until the executeFeature catch block
// eventually runs. By persisting and emitting the status change here, the
// board updates immediately regardless of how long the subprocess takes to stop.
try {
await this.updateFeatureStatusFn(projectPath, featureId, 'interrupted');
} catch (err) {
// Non-fatal: the abort still proceeds and executeFeature's catch block
// will attempt the same update once the subprocess terminates.
logger.warn(`stopFeature: failed to immediately update status for ${featureId}:`, err);
}
running.abortController.abort(); running.abortController.abort();
this.releaseRunningFeature(featureId, { force: true }); this.releaseRunningFeature(featureId, { force: true });
return true; return true;

View File

@@ -5,7 +5,7 @@
* allowing the service to delegate to other services without circular dependencies. * allowing the service to delegate to other services without circular dependencies.
*/ */
import type { Feature, PlanningMode, ThinkingLevel } from '@automaker/types'; import type { Feature, PlanningMode, ThinkingLevel, ReasoningEffort } from '@automaker/types';
import type { loadContextFiles } from '@automaker/utils'; import type { loadContextFiles } from '@automaker/utils';
import type { PipelineContext } from './pipeline-orchestrator.js'; import type { PipelineContext } from './pipeline-orchestrator.js';
@@ -31,7 +31,10 @@ export type RunAgentFn = (
previousContent?: string; previousContent?: string;
systemPrompt?: string; systemPrompt?: string;
autoLoadClaudeMd?: boolean; autoLoadClaudeMd?: boolean;
useClaudeCodeSystemPrompt?: boolean;
thinkingLevel?: ThinkingLevel; thinkingLevel?: ThinkingLevel;
reasoningEffort?: ReasoningEffort;
providerId?: string;
branchName?: string | null; branchName?: string | null;
} }
) => Promise<void>; ) => Promise<void>;

View File

@@ -378,6 +378,7 @@ export class FeatureLoader {
description: featureData.description || '', description: featureData.description || '',
...featureData, ...featureData,
id: featureId, id: featureId,
createdAt: featureData.createdAt || new Date().toISOString(),
imagePaths: migratedImagePaths, imagePaths: migratedImagePaths,
descriptionHistory: initialHistory, descriptionHistory: initialHistory,
}; };

View File

@@ -14,7 +14,8 @@
*/ */
import path from 'path'; import path from 'path';
import type { Feature, ParsedTask, PlanSpec } from '@automaker/types'; import type { Feature, FeatureStatusWithPipeline, ParsedTask, PlanSpec } from '@automaker/types';
import { isPipelineStatus } from '@automaker/types';
import { import {
atomicWriteJson, atomicWriteJson,
readJsonWithRecovery, readJsonWithRecovery,
@@ -28,9 +29,40 @@ import type { EventEmitter } from '../lib/events.js';
import type { AutoModeEventType } from './typed-event-bus.js'; import type { AutoModeEventType } from './typed-event-bus.js';
import { getNotificationService } from './notification-service.js'; import { getNotificationService } from './notification-service.js';
import { FeatureLoader } from './feature-loader.js'; import { FeatureLoader } from './feature-loader.js';
import { pipelineService } from './pipeline-service.js';
const logger = createLogger('FeatureStateManager'); const logger = createLogger('FeatureStateManager');
// Notification type constants
const NOTIFICATION_TYPE_WAITING_APPROVAL = 'feature_waiting_approval';
const NOTIFICATION_TYPE_VERIFIED = 'feature_verified';
const NOTIFICATION_TYPE_FEATURE_ERROR = 'feature_error';
const NOTIFICATION_TYPE_AUTO_MODE_ERROR = 'auto_mode_error';
// Notification title constants
const NOTIFICATION_TITLE_WAITING_APPROVAL = 'Feature Ready for Review';
const NOTIFICATION_TITLE_VERIFIED = 'Feature Verified';
const NOTIFICATION_TITLE_FEATURE_ERROR = 'Feature Failed';
const NOTIFICATION_TITLE_AUTO_MODE_ERROR = 'Auto Mode Error';
/**
* Auto-mode event payload structure
* This is the payload that comes with 'auto-mode:event' events
*/
interface AutoModeEventPayload {
type?: string;
featureId?: string;
featureName?: string;
passes?: boolean;
executionMode?: 'auto' | 'manual';
message?: string;
error?: string;
errorType?: string;
projectPath?: string;
/** Status field present when type === 'feature_status_changed' */
status?: string;
}
/** /**
* FeatureStateManager handles feature status updates with persistence guarantees. * FeatureStateManager handles feature status updates with persistence guarantees.
* *
@@ -43,10 +75,28 @@ const logger = createLogger('FeatureStateManager');
export class FeatureStateManager { export class FeatureStateManager {
private events: EventEmitter; private events: EventEmitter;
private featureLoader: FeatureLoader; private featureLoader: FeatureLoader;
private unsubscribe: (() => void) | null = null;
constructor(events: EventEmitter, featureLoader: FeatureLoader) { constructor(events: EventEmitter, featureLoader: FeatureLoader) {
this.events = events; this.events = events;
this.featureLoader = featureLoader; this.featureLoader = featureLoader;
// Subscribe to error events to create notifications
this.unsubscribe = events.subscribe((type, payload) => {
if (type === 'auto-mode:event') {
this.handleAutoModeEventError(payload as AutoModeEventPayload);
}
});
}
/**
* Cleanup subscriptions
*/
destroy(): void {
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = null;
}
} }
/** /**
@@ -104,77 +154,18 @@ export class FeatureStateManager {
feature.status = status; feature.status = status;
feature.updatedAt = new Date().toISOString(); feature.updatedAt = new Date().toISOString();
// Set justFinishedAt timestamp when moving to waiting_approval (agent just completed) // Handle justFinishedAt timestamp based on status
// Badge will show for 2 minutes after this timestamp const shouldSetJustFinishedAt = status === 'waiting_approval';
if (status === 'waiting_approval') { const shouldClearJustFinishedAt = status !== 'waiting_approval';
if (shouldSetJustFinishedAt) {
feature.justFinishedAt = new Date().toISOString(); feature.justFinishedAt = new Date().toISOString();
} else if (shouldClearJustFinishedAt) {
feature.justFinishedAt = undefined;
}
// Finalize task statuses when feature is done: // Finalize in-progress tasks when reaching terminal states (waiting_approval or verified)
// - Mark any in_progress tasks as completed (agent finished but didn't explicitly complete them) if (status === 'waiting_approval' || status === 'verified') {
// - Do NOT mark pending tasks as completed (they were never started) this.finalizeInProgressTasks(feature, featureId, status);
// - Clear currentTaskId since no task is actively running
// This prevents cards in "waiting for review" from appearing to still have running tasks
if (feature.planSpec?.tasks) {
let tasksFinalized = 0;
let tasksPending = 0;
for (const task of feature.planSpec.tasks) {
if (task.status === 'in_progress') {
task.status = 'completed';
tasksFinalized++;
} else if (task.status === 'pending') {
tasksPending++;
}
}
if (tasksFinalized > 0) {
logger.info(
`[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to waiting_approval`
);
}
if (tasksPending > 0) {
logger.warn(
`[updateFeatureStatus] Feature ${featureId} moving to waiting_approval with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total`
);
}
// Update tasksCompleted count to reflect actual completed tasks
feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter(
(t) => t.status === 'completed'
).length;
feature.planSpec.currentTaskId = undefined;
}
} else if (status === 'verified') {
// Also finalize in_progress tasks when moving directly to verified (skipTests=false)
// Do NOT mark pending tasks as completed - they were never started
if (feature.planSpec?.tasks) {
let tasksFinalized = 0;
let tasksPending = 0;
for (const task of feature.planSpec.tasks) {
if (task.status === 'in_progress') {
task.status = 'completed';
tasksFinalized++;
} else if (task.status === 'pending') {
tasksPending++;
}
}
if (tasksFinalized > 0) {
logger.info(
`[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to verified`
);
}
if (tasksPending > 0) {
logger.warn(
`[updateFeatureStatus] Feature ${featureId} moving to verified with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total`
);
}
feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter(
(t) => t.status === 'completed'
).length;
feature.planSpec.currentTaskId = undefined;
}
// Clear the timestamp when moving to other statuses
feature.justFinishedAt = undefined;
} else {
// Clear the timestamp when moving to other statuses
feature.justFinishedAt = undefined;
} }
// PERSIST BEFORE EMIT (Pitfall 2) // PERSIST BEFORE EMIT (Pitfall 2)
@@ -191,19 +182,21 @@ export class FeatureStateManager {
// Wrapped in try-catch so failures don't block syncFeatureToAppSpec below // Wrapped in try-catch so failures don't block syncFeatureToAppSpec below
try { try {
const notificationService = getNotificationService(); const notificationService = getNotificationService();
const displayName = this.getFeatureDisplayName(feature, featureId);
if (status === 'waiting_approval') { if (status === 'waiting_approval') {
await notificationService.createNotification({ await notificationService.createNotification({
type: 'feature_waiting_approval', type: NOTIFICATION_TYPE_WAITING_APPROVAL,
title: 'Feature Ready for Review', title: displayName,
message: `"${feature.name || featureId}" is ready for your review and approval.`, message: NOTIFICATION_TITLE_WAITING_APPROVAL,
featureId, featureId,
projectPath, projectPath,
}); });
} else if (status === 'verified') { } else if (status === 'verified') {
await notificationService.createNotification({ await notificationService.createNotification({
type: 'feature_verified', type: NOTIFICATION_TYPE_VERIFIED,
title: 'Feature Verified', title: displayName,
message: `"${feature.name || featureId}" has been verified and is complete.`, message: NOTIFICATION_TITLE_VERIFIED,
featureId, featureId,
projectPath, projectPath,
}); });
@@ -252,7 +245,7 @@ export class FeatureStateManager {
const currentStatus = feature?.status; const currentStatus = feature?.status;
// Preserve pipeline_* statuses so resumePipelineFeature can resume from the correct step // Preserve pipeline_* statuses so resumePipelineFeature can resume from the correct step
if (currentStatus && currentStatus.startsWith('pipeline_')) { if (isPipelineStatus(currentStatus)) {
logger.info( logger.info(
`Feature ${featureId} was in ${currentStatus}; preserving pipeline status for resume` `Feature ${featureId} was in ${currentStatus}; preserving pipeline status for resume`
); );
@@ -270,7 +263,8 @@ export class FeatureStateManager {
/** /**
* Shared helper that scans features in a project directory and resets any stuck * Shared helper that scans features in a project directory and resets any stuck
* in transient states (in_progress, interrupted, pipeline_*) back to resting states. * in transient states (in_progress, interrupted) back to resting states.
* Pipeline_* statuses are preserved so they can be resumed.
* *
* Also resets: * Also resets:
* - generating planSpec status back to pending * - generating planSpec status back to pending
@@ -324,10 +318,7 @@ export class FeatureStateManager {
// Reset features in active execution states back to a resting state // Reset features in active execution states back to a resting state
// After a server restart, no processes are actually running // After a server restart, no processes are actually running
const isActiveState = const isActiveState = originalStatus === 'in_progress' || originalStatus === 'interrupted';
originalStatus === 'in_progress' ||
originalStatus === 'interrupted' ||
(originalStatus != null && originalStatus.startsWith('pipeline_'));
if (isActiveState) { if (isActiveState) {
const hasApprovedPlan = feature.planSpec?.status === 'approved'; const hasApprovedPlan = feature.planSpec?.status === 'approved';
@@ -338,6 +329,17 @@ export class FeatureStateManager {
); );
} }
// Handle pipeline_* statuses separately: preserve them so they can be resumed
// but still count them as needing attention if they were stuck.
if (isPipelineStatus(originalStatus)) {
// We don't change the status, but we still want to reset planSpec/task states
// if they were stuck in transient generation/execution modes.
// No feature.status change here.
logger.debug(
`[${callerLabel}] Preserving pipeline status for feature ${feature.id}: ${originalStatus}`
);
}
// Reset generating planSpec status back to pending (spec generation was interrupted) // Reset generating planSpec status back to pending (spec generation was interrupted)
if (feature.planSpec?.status === 'generating') { if (feature.planSpec?.status === 'generating') {
feature.planSpec.status = 'pending'; feature.planSpec.status = 'pending';
@@ -396,10 +398,12 @@ export class FeatureStateManager {
* Resets: * Resets:
* - in_progress features back to ready (if has plan) or backlog (if no plan) * - in_progress features back to ready (if has plan) or backlog (if no plan)
* - interrupted features back to ready (if has plan) or backlog (if no plan) * - interrupted features back to ready (if has plan) or backlog (if no plan)
* - pipeline_* features back to ready (if has plan) or backlog (if no plan)
* - generating planSpec status back to pending * - generating planSpec status back to pending
* - in_progress tasks back to pending * - in_progress tasks back to pending
* *
* Preserves:
* - pipeline_* statuses (so resumePipelineFeature can resume from correct step)
*
* @param projectPath - The project path to reset features for * @param projectPath - The project path to reset features for
*/ */
async resetStuckFeatures(projectPath: string): Promise<void> { async resetStuckFeatures(projectPath: string): Promise<void> {
@@ -530,6 +534,10 @@ export class FeatureStateManager {
* This is called after agent execution completes to save a summary * This is called after agent execution completes to save a summary
* extracted from the agent's output using <summary> tags. * extracted from the agent's output using <summary> tags.
* *
* For pipeline features (status starts with pipeline_), summaries are accumulated
* across steps with a header identifying each step. For non-pipeline features,
* the summary is replaced entirely.
*
* @param projectPath - The project path * @param projectPath - The project path
* @param featureId - The feature ID * @param featureId - The feature ID
* @param summary - The summary text to save * @param summary - The summary text to save
@@ -537,6 +545,7 @@ export class FeatureStateManager {
async saveFeatureSummary(projectPath: string, featureId: string, summary: string): Promise<void> { async saveFeatureSummary(projectPath: string, featureId: string, summary: string): Promise<void> {
const featureDir = getFeatureDir(projectPath, featureId); const featureDir = getFeatureDir(projectPath, featureId);
const featurePath = path.join(featureDir, 'feature.json'); const featurePath = path.join(featureDir, 'feature.json');
const normalizedSummary = summary.trim();
try { try {
const result = await readJsonWithRecovery<Feature | null>(featurePath, null, { const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
@@ -552,7 +561,63 @@ export class FeatureStateManager {
return; return;
} }
feature.summary = summary; if (!normalizedSummary) {
logger.debug(
`[saveFeatureSummary] Skipping empty summary for feature ${featureId} (status="${feature.status}")`
);
return;
}
// For pipeline features, accumulate summaries across steps
if (isPipelineStatus(feature.status)) {
// If we already have a non-phase summary (typically the initial implementation
// summary from in_progress), normalize it into a named phase before appending
// pipeline step summaries. This keeps the format consistent for UI phase parsing.
const implementationHeader = '### Implementation';
if (feature.summary && !feature.summary.trimStart().startsWith('### ')) {
feature.summary = `${implementationHeader}\n\n${feature.summary.trim()}`;
}
const stepName = await this.getPipelineStepName(projectPath, feature.status);
const stepHeader = `### ${stepName}`;
const stepSection = `${stepHeader}\n\n${normalizedSummary}`;
if (feature.summary) {
// Check if this step already exists in the summary (e.g., if retried)
// Use section splitting to only match real section boundaries, not text in body content
const separator = '\n\n---\n\n';
const sections = feature.summary.split(separator);
let replaced = false;
const updatedSections = sections.map((section) => {
if (section.startsWith(`${stepHeader}\n\n`)) {
replaced = true;
return stepSection;
}
return section;
});
if (replaced) {
feature.summary = updatedSections.join(separator);
logger.info(
`[saveFeatureSummary] Updated existing pipeline step summary for feature ${featureId}: step="${stepName}"`
);
} else {
// Append as a new section
feature.summary = `${feature.summary}${separator}${stepSection}`;
logger.info(
`[saveFeatureSummary] Appended new pipeline step summary for feature ${featureId}: step="${stepName}"`
);
}
} else {
feature.summary = stepSection;
logger.info(
`[saveFeatureSummary] Initialized pipeline summary for feature ${featureId}: step="${stepName}"`
);
}
} else {
feature.summary = normalizedSummary;
}
feature.updatedAt = new Date().toISOString(); feature.updatedAt = new Date().toISOString();
// PERSIST BEFORE EMIT // PERSIST BEFORE EMIT
@@ -562,13 +627,42 @@ export class FeatureStateManager {
this.emitAutoModeEvent('auto_mode_summary', { this.emitAutoModeEvent('auto_mode_summary', {
featureId, featureId,
projectPath, projectPath,
summary, summary: feature.summary,
}); });
} catch (error) { } catch (error) {
logger.error(`Failed to save summary for ${featureId}:`, error); logger.error(`Failed to save summary for ${featureId}:`, error);
} }
} }
/**
* Look up the pipeline step name from the current pipeline status.
*
* @param projectPath - The project path
* @param status - The current pipeline status (e.g., 'pipeline_abc123')
* @returns The step name, or a fallback based on the step ID
*/
private async getPipelineStepName(projectPath: string, status: string): Promise<string> {
try {
const stepId = pipelineService.getStepIdFromStatus(status as FeatureStatusWithPipeline);
if (stepId) {
const step = await pipelineService.getStep(projectPath, stepId);
if (step) return step.name;
}
} catch (error) {
logger.debug(
`[getPipelineStepName] Failed to look up step name for status "${status}", using fallback:`,
error
);
}
// Fallback: derive a human-readable name from the status suffix
// e.g., 'pipeline_code_review' → 'Code Review'
const suffix = status.replace('pipeline_', '');
return suffix
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
/** /**
* Update the status of a specific task within planSpec.tasks * Update the status of a specific task within planSpec.tasks
* *
@@ -581,7 +675,8 @@ export class FeatureStateManager {
projectPath: string, projectPath: string,
featureId: string, featureId: string,
taskId: string, taskId: string,
status: ParsedTask['status'] status: ParsedTask['status'],
summary?: string
): Promise<void> { ): Promise<void> {
const featureDir = getFeatureDir(projectPath, featureId); const featureDir = getFeatureDir(projectPath, featureId);
const featurePath = path.join(featureDir, 'feature.json'); const featurePath = path.join(featureDir, 'feature.json');
@@ -604,6 +699,9 @@ export class FeatureStateManager {
const task = feature.planSpec.tasks.find((t) => t.id === taskId); const task = feature.planSpec.tasks.find((t) => t.id === taskId);
if (task) { if (task) {
task.status = status; task.status = status;
if (summary) {
task.summary = summary;
}
feature.updatedAt = new Date().toISOString(); feature.updatedAt = new Date().toISOString();
// PERSIST BEFORE EMIT // PERSIST BEFORE EMIT
@@ -615,6 +713,7 @@ export class FeatureStateManager {
projectPath, projectPath,
taskId, taskId,
status, status,
summary,
tasks: feature.planSpec.tasks, tasks: feature.planSpec.tasks,
}); });
} else { } else {
@@ -628,6 +727,137 @@ export class FeatureStateManager {
} }
} }
/**
* Get the display name for a feature, preferring title over feature ID.
* Empty string titles are treated as missing and fallback to featureId.
*
* @param feature - The feature to get the display name for
* @param featureId - The feature ID to use as fallback
* @returns The display name (title or feature ID)
*/
private getFeatureDisplayName(feature: Feature, featureId: string): string {
// Use title if it's a non-empty string, otherwise fallback to featureId
return feature.title && feature.title.trim() ? feature.title : featureId;
}
/**
* Handle auto-mode events to create error notifications.
* This listens for error events and creates notifications to alert users.
*/
private async handleAutoModeEventError(payload: AutoModeEventPayload): Promise<void> {
if (!payload.type) return;
// Only handle error events
if (payload.type !== 'auto_mode_error' && payload.type !== 'auto_mode_feature_complete') {
return;
}
// For auto_mode_feature_complete, only notify on failures (passes === false)
if (payload.type === 'auto_mode_feature_complete' && payload.passes !== false) {
return;
}
// Get project path - handle different event formats
const projectPath = payload.projectPath;
if (!projectPath) return;
try {
const notificationService = getNotificationService();
// Determine notification type and title based on event type
// Only auto_mode_feature_complete events should create feature_error notifications
const isFeatureError = payload.type === 'auto_mode_feature_complete';
const notificationType = isFeatureError
? NOTIFICATION_TYPE_FEATURE_ERROR
: NOTIFICATION_TYPE_AUTO_MODE_ERROR;
const notificationTitle = isFeatureError
? NOTIFICATION_TITLE_FEATURE_ERROR
: NOTIFICATION_TITLE_AUTO_MODE_ERROR;
// Build error message
let errorMessage = payload.message || 'An error occurred';
if (payload.error) {
errorMessage = payload.error;
}
// Use feature title as notification title when available, fall back to gesture name
let title = notificationTitle;
if (payload.featureId) {
const displayName = await this.getFeatureDisplayNameById(projectPath, payload.featureId);
if (displayName) {
title = displayName;
errorMessage = `${notificationTitle}: ${errorMessage}`;
}
}
await notificationService.createNotification({
type: notificationType,
title,
message: errorMessage,
featureId: payload.featureId,
projectPath,
});
} catch (notificationError) {
logger.warn(`Failed to create error notification:`, notificationError);
}
}
/**
* Get feature display name by loading the feature directly.
*/
private async getFeatureDisplayNameById(
projectPath: string,
featureId: string
): Promise<string | null> {
const feature = await this.loadFeature(projectPath, featureId);
if (!feature) return null;
return this.getFeatureDisplayName(feature, featureId);
}
/**
* Finalize in-progress tasks when a feature reaches a terminal state.
* Marks in_progress tasks as completed but leaves pending tasks untouched.
*
* @param feature - The feature whose tasks should be finalized
* @param featureId - The feature ID for logging
* @param targetStatus - The status the feature is transitioning to
*/
private finalizeInProgressTasks(feature: Feature, featureId: string, targetStatus: string): void {
if (!feature.planSpec?.tasks) {
return;
}
let tasksFinalized = 0;
let tasksPending = 0;
for (const task of feature.planSpec.tasks) {
if (task.status === 'in_progress') {
task.status = 'completed';
tasksFinalized++;
} else if (task.status === 'pending') {
tasksPending++;
}
}
// Update tasksCompleted count to reflect actual completed tasks
feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter(
(t) => t.status === 'completed'
).length;
feature.planSpec.currentTaskId = undefined;
if (tasksFinalized > 0) {
logger.info(
`[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to ${targetStatus}`
);
}
if (tasksPending > 0) {
logger.warn(
`[updateFeatureStatus] Feature ${featureId} moving to ${targetStatus} with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total`
);
}
}
/** /**
* Emit an auto-mode event via the event emitter * Emit an auto-mode event via the event emitter
* *

View File

@@ -0,0 +1,103 @@
/**
* GitHub PR Comment Service
*
* Domain logic for resolving/unresolving PR review threads via the
* GitHub GraphQL API. Extracted from the route handler so the route
* only deals with request/response plumbing.
*/
import { spawn } from 'child_process';
import { execEnv } from '../lib/exec-utils.js';
/** Timeout for GitHub GraphQL API requests in milliseconds */
const GITHUB_API_TIMEOUT_MS = 30000;
interface GraphQLMutationResponse {
data?: {
resolveReviewThread?: {
thread?: { isResolved: boolean; id: string } | null;
} | null;
unresolveReviewThread?: {
thread?: { isResolved: boolean; id: string } | null;
} | null;
};
errors?: Array<{ message: string }>;
}
/**
* Execute a GraphQL mutation to resolve or unresolve a review thread.
*/
export async function executeReviewThreadMutation(
projectPath: string,
threadId: string,
resolve: boolean
): Promise<{ isResolved: boolean }> {
const mutationName = resolve ? 'resolveReviewThread' : 'unresolveReviewThread';
const mutation = `
mutation ${resolve ? 'ResolveThread' : 'UnresolveThread'}($threadId: ID!) {
${mutationName}(input: { threadId: $threadId }) {
thread {
id
isResolved
}
}
}`;
const variables = { threadId };
const requestBody = JSON.stringify({ query: mutation, variables });
// Declare timeoutId before registering the error handler to avoid TDZ confusion
let timeoutId: NodeJS.Timeout | undefined;
const response = await new Promise<GraphQLMutationResponse>((res, rej) => {
const gh = spawn('gh', ['api', 'graphql', '--input', '-'], {
cwd: projectPath,
env: execEnv,
});
gh.on('error', (err) => {
clearTimeout(timeoutId);
rej(err);
});
timeoutId = setTimeout(() => {
gh.kill();
rej(new Error('GitHub GraphQL API request timed out'));
}, GITHUB_API_TIMEOUT_MS);
let stdout = '';
let stderr = '';
gh.stdout.on('data', (data: Buffer) => (stdout += data.toString()));
gh.stderr.on('data', (data: Buffer) => (stderr += data.toString()));
gh.on('close', (code) => {
clearTimeout(timeoutId);
if (code !== 0) {
return rej(new Error(`gh process exited with code ${code}: ${stderr}`));
}
try {
res(JSON.parse(stdout));
} catch (e) {
rej(e);
}
});
gh.stdin.write(requestBody);
gh.stdin.end();
});
if (response.errors && response.errors.length > 0) {
throw new Error(response.errors[0].message);
}
const threadData = resolve
? response.data?.resolveReviewThread?.thread
: response.data?.unresolveReviewThread?.thread;
if (!threadData) {
throw new Error('No thread data returned from GitHub API');
}
return { isResolved: threadData.isResolved };
}

View File

@@ -0,0 +1,282 @@
/**
* Ntfy Service - Sends push notifications via ntfy.sh
*
* Provides integration with ntfy.sh for push notifications.
* Supports custom servers, authentication, tags, emojis, and click actions.
*
* @see https://docs.ntfy.sh/publish/
*/
import { createLogger } from '@automaker/utils';
import type { NtfyEndpointConfig, EventHookContext } from '@automaker/types';
const logger = createLogger('Ntfy');
/** Default timeout for ntfy HTTP requests (10 seconds) */
const DEFAULT_NTFY_TIMEOUT = 10000;
// Re-export EventHookContext as NtfyContext for backward compatibility
export type NtfyContext = EventHookContext;
/**
* Ntfy Service
*
* Handles sending notifications to ntfy.sh endpoints.
*/
export class NtfyService {
/**
* Send a notification to a ntfy.sh endpoint
*
* @param endpoint The ntfy.sh endpoint configuration
* @param options Notification options (title, body, tags, etc.)
* @param context Context for variable substitution
*/
async sendNotification(
endpoint: NtfyEndpointConfig,
options: {
title?: string;
body?: string;
tags?: string;
emoji?: string;
clickUrl?: string;
priority?: 1 | 2 | 3 | 4 | 5;
},
context: NtfyContext
): Promise<{ success: boolean; error?: string }> {
if (!endpoint.enabled) {
logger.warn(`Ntfy endpoint "${endpoint.name}" is disabled, skipping notification`);
return { success: false, error: 'Endpoint is disabled' };
}
// Validate endpoint configuration
const validationError = this.validateEndpoint(endpoint);
if (validationError) {
logger.error(`Invalid ntfy endpoint configuration: ${validationError}`);
return { success: false, error: validationError };
}
// Build URL
const serverUrl = endpoint.serverUrl.replace(/\/$/, ''); // Remove trailing slash
const url = `${serverUrl}/${encodeURIComponent(endpoint.topic)}`;
// Build headers
const headers: Record<string, string> = {
'Content-Type': 'text/plain; charset=utf-8',
};
// Title (with variable substitution)
const title = this.substituteVariables(options.title || this.getDefaultTitle(context), context);
if (title) {
headers['Title'] = title;
}
// Priority
const priority = options.priority || 3;
headers['Priority'] = String(priority);
// Tags and emoji
const tags = this.buildTags(
options.tags || endpoint.defaultTags,
options.emoji || endpoint.defaultEmoji
);
if (tags) {
headers['Tags'] = tags;
}
// Click action URL
const clickUrl = this.substituteVariables(
options.clickUrl || endpoint.defaultClickUrl || '',
context
);
if (clickUrl) {
headers['Click'] = clickUrl;
}
// Authentication
this.addAuthHeaders(headers, endpoint);
// Message body (with variable substitution)
const body = this.substituteVariables(options.body || this.getDefaultBody(context), context);
logger.info(`Sending ntfy notification to ${endpoint.name}: ${title}`);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_NTFY_TIMEOUT);
try {
const response = await fetch(url, {
method: 'POST',
headers,
body,
signal: controller.signal,
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
logger.error(`Ntfy notification failed with status ${response.status}: ${errorText}`);
return {
success: false,
error: `HTTP ${response.status}: ${errorText}`,
};
}
logger.info(`Ntfy notification sent successfully to ${endpoint.name}`);
return { success: true };
} catch (error) {
if ((error as Error).name === 'AbortError') {
logger.error(`Ntfy notification timed out after ${DEFAULT_NTFY_TIMEOUT}ms`);
return { success: false, error: 'Request timed out' };
}
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Ntfy notification failed: ${errorMessage}`);
return { success: false, error: errorMessage };
} finally {
clearTimeout(timeoutId);
}
}
/**
* Validate an ntfy endpoint configuration
*/
validateEndpoint(endpoint: NtfyEndpointConfig): string | null {
// Validate server URL
if (!endpoint.serverUrl) {
return 'Server URL is required';
}
try {
new URL(endpoint.serverUrl);
} catch {
return 'Invalid server URL format';
}
// Validate topic
if (!endpoint.topic) {
return 'Topic is required';
}
if (endpoint.topic.includes(' ') || endpoint.topic.includes('\t')) {
return 'Topic cannot contain spaces';
}
// Validate authentication
if (endpoint.authType === 'basic') {
if (!endpoint.username || !endpoint.password) {
return 'Username and password are required for basic authentication';
}
} else if (endpoint.authType === 'token') {
if (!endpoint.token) {
return 'Access token is required for token authentication';
}
}
return null;
}
/**
* Build tags string from tags and emoji
*/
private buildTags(tags?: string, emoji?: string): string {
const tagList: string[] = [];
if (tags) {
// Split by comma and trim whitespace
const parsedTags = tags
.split(',')
.map((t) => t.trim())
.filter((t) => t.length > 0);
tagList.push(...parsedTags);
}
if (emoji) {
// Add emoji as first tag if it looks like a shortcode
if (emoji.startsWith(':') && emoji.endsWith(':')) {
tagList.unshift(emoji.slice(1, -1));
} else if (!emoji.includes(' ')) {
// If it's a single emoji or shortcode without colons, add as-is
tagList.unshift(emoji);
}
}
return tagList.join(',');
}
/**
* Add authentication headers based on auth type
*/
private addAuthHeaders(headers: Record<string, string>, endpoint: NtfyEndpointConfig): void {
if (endpoint.authType === 'basic' && endpoint.username && endpoint.password) {
const credentials = Buffer.from(`${endpoint.username}:${endpoint.password}`).toString(
'base64'
);
headers['Authorization'] = `Basic ${credentials}`;
} else if (endpoint.authType === 'token' && endpoint.token) {
headers['Authorization'] = `Bearer ${endpoint.token}`;
}
}
/**
* Get default title based on event context
*/
private getDefaultTitle(context: NtfyContext): string {
const eventName = this.formatEventName(context.eventType);
if (context.featureName) {
return `${eventName}: ${context.featureName}`;
}
return eventName;
}
/**
* Get default body based on event context
*/
private getDefaultBody(context: NtfyContext): string {
const lines: string[] = [];
if (context.featureName) {
lines.push(`Feature: ${context.featureName}`);
}
if (context.featureId) {
lines.push(`ID: ${context.featureId}`);
}
if (context.projectName) {
lines.push(`Project: ${context.projectName}`);
}
if (context.error) {
lines.push(`Error: ${context.error}`);
}
lines.push(`Time: ${context.timestamp}`);
return lines.join('\n');
}
/**
* Format event type to human-readable name
*/
private formatEventName(eventType: string): string {
const eventNames: Record<string, string> = {
feature_created: 'Feature Created',
feature_success: 'Feature Completed',
feature_error: 'Feature Failed',
auto_mode_complete: 'Auto Mode Complete',
auto_mode_error: 'Auto Mode Error',
};
return eventNames[eventType] || eventType;
}
/**
* Substitute {{variable}} placeholders in a string
*/
private substituteVariables(template: string, context: NtfyContext): string {
return template.replace(/\{\{(\w+)\}\}/g, (match, variable) => {
const value = context[variable as keyof NtfyContext];
if (value === undefined || value === null) {
return '';
}
return String(value);
});
}
}
// Singleton instance
export const ntfyService = new NtfyService();

View File

@@ -16,6 +16,7 @@ import * as secureFs from '../lib/secure-fs.js';
import { import {
getPromptCustomization, getPromptCustomization,
getAutoLoadClaudeMdSetting, getAutoLoadClaudeMdSetting,
getUseClaudeCodeSystemPromptSetting,
filterClaudeMdFromContext, filterClaudeMdFromContext,
} from '../lib/settings-helpers.js'; } from '../lib/settings-helpers.js';
import { validateWorkingDirectory } from '../lib/sdk-options.js'; import { validateWorkingDirectory } from '../lib/sdk-options.js';
@@ -70,8 +71,16 @@ export class PipelineOrchestrator {
) {} ) {}
async executePipeline(ctx: PipelineContext): Promise<void> { async executePipeline(ctx: PipelineContext): Promise<void> {
const { projectPath, featureId, feature, steps, workDir, abortController, autoLoadClaudeMd } = const {
ctx; projectPath,
featureId,
feature,
steps,
workDir,
abortController,
autoLoadClaudeMd,
useClaudeCodeSystemPrompt,
} = ctx;
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
const contextResult = await this.loadContextFilesFn({ const contextResult = await this.loadContextFilesFn({
projectPath, projectPath,
@@ -106,6 +115,7 @@ export class PipelineOrchestrator {
projectPath, projectPath,
}); });
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude); const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
const currentStatus = `pipeline_${step.id}`;
await this.runAgentFn( await this.runAgentFn(
workDir, workDir,
featureId, featureId,
@@ -121,7 +131,11 @@ export class PipelineOrchestrator {
previousContent: previousContext, previousContent: previousContext,
systemPrompt: contextFilesPrompt || undefined, systemPrompt: contextFilesPrompt || undefined,
autoLoadClaudeMd, autoLoadClaudeMd,
useClaudeCodeSystemPrompt,
thinkingLevel: feature.thinkingLevel, thinkingLevel: feature.thinkingLevel,
reasoningEffort: feature.reasoningEffort,
status: currentStatus,
providerId: feature.providerId,
} }
); );
try { try {
@@ -154,7 +168,18 @@ export class PipelineOrchestrator {
if (previousContext) prompt += `### Previous Work\n${previousContext}\n\n`; if (previousContext) prompt += `### Previous Work\n${previousContext}\n\n`;
return ( return (
prompt + prompt +
`### Pipeline Step Instructions\n${step.instructions}\n\n### Task\nComplete the pipeline step instructions above.` `### Pipeline Step Instructions\n${step.instructions}\n\n### Task\nComplete the pipeline step instructions above.\n\n` +
`**CRITICAL: After completing the instructions, you MUST output a summary using this EXACT format:**\n\n` +
`<summary>\n` +
`## Summary: ${step.name}\n\n` +
`### Changes Implemented\n` +
`- [List all changes made in this step]\n\n` +
`### Files Modified\n` +
`- [List all files modified in this step]\n\n` +
`### Outcome\n` +
`- [Describe the result of this step]\n` +
`</summary>\n\n` +
`The <summary> and </summary> tags MUST be on their own lines. This is REQUIRED.`
); );
} }
@@ -226,14 +251,18 @@ export class PipelineOrchestrator {
logger.warn(`Step ${pipelineInfo.stepId} no longer exists, completing feature`); logger.warn(`Step ${pipelineInfo.stepId} no longer exists, completing feature`);
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus); await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
const runningEntryForStep = this.concurrencyManager.getRunningFeature(featureId);
if (runningEntryForStep?.isAutoMode) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
featureName: feature.title, featureName: feature.title,
branchName: feature.branchName ?? null, branchName: feature.branchName ?? null,
executionMode: 'auto',
passes: true, passes: true,
message: 'Pipeline step no longer exists', message: 'Pipeline step no longer exists',
projectPath, projectPath,
}); });
}
return; return;
} }
@@ -272,14 +301,18 @@ export class PipelineOrchestrator {
); );
if (!pipelineService.isPipelineStatus(nextStatus)) { if (!pipelineService.isPipelineStatus(nextStatus)) {
await this.updateFeatureStatusFn(projectPath, featureId, nextStatus); await this.updateFeatureStatusFn(projectPath, featureId, nextStatus);
const runningEntryForExcluded = this.concurrencyManager.getRunningFeature(featureId);
if (runningEntryForExcluded?.isAutoMode) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
featureName: feature.title, featureName: feature.title,
branchName: feature.branchName ?? null, branchName: feature.branchName ?? null,
executionMode: 'auto',
passes: true, passes: true,
message: 'Pipeline completed (remaining steps excluded)', message: 'Pipeline completed (remaining steps excluded)',
projectPath, projectPath,
}); });
}
return; return;
} }
const nextStepId = pipelineService.getStepIdFromStatus(nextStatus); const nextStepId = pipelineService.getStepIdFromStatus(nextStatus);
@@ -294,14 +327,18 @@ export class PipelineOrchestrator {
if (stepsToExecute.length === 0) { if (stepsToExecute.length === 0) {
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus); await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
const runningEntryForAllExcluded = this.concurrencyManager.getRunningFeature(featureId);
if (runningEntryForAllExcluded?.isAutoMode) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
featureName: feature.title, featureName: feature.title,
branchName: feature.branchName ?? null, branchName: feature.branchName ?? null,
executionMode: 'auto',
passes: true, passes: true,
message: 'Pipeline completed (all steps excluded)', message: 'Pipeline completed (all steps excluded)',
projectPath, projectPath,
}); });
}
return; return;
} }
@@ -313,6 +350,7 @@ export class PipelineOrchestrator {
}); });
const abortController = runningEntry.abortController; const abortController = runningEntry.abortController;
runningEntry.branchName = feature.branchName ?? null; runningEntry.branchName = feature.branchName ?? null;
let pipelineCompleted = false;
try { try {
validateWorkingDirectory(projectPath); validateWorkingDirectory(projectPath);
@@ -345,6 +383,11 @@ export class PipelineOrchestrator {
this.settingsService, this.settingsService,
'[AutoMode]' '[AutoMode]'
); );
const useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting(
projectPath,
this.settingsService,
'[AutoMode]'
);
const context: PipelineContext = { const context: PipelineContext = {
projectPath, projectPath,
featureId, featureId,
@@ -355,11 +398,13 @@ export class PipelineOrchestrator {
branchName: branchName ?? null, branchName: branchName ?? null,
abortController, abortController,
autoLoadClaudeMd, autoLoadClaudeMd,
useClaudeCodeSystemPrompt,
testAttempts: 0, testAttempts: 0,
maxTestAttempts: 5, maxTestAttempts: 5,
}; };
await this.executePipeline(context); await this.executePipeline(context);
pipelineCompleted = true;
// Re-fetch feature to check if executePipeline set a terminal status (e.g., merge_conflict) // Re-fetch feature to check if executePipeline set a terminal status (e.g., merge_conflict)
const reloadedFeature = await this.featureStateManager.loadFeature(projectPath, featureId); const reloadedFeature = await this.featureStateManager.loadFeature(projectPath, featureId);
@@ -370,28 +415,47 @@ export class PipelineOrchestrator {
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus); await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
} }
logger.info(`Pipeline resume completed for feature ${featureId}`); logger.info(`Pipeline resume completed for feature ${featureId}`);
if (runningEntry.isAutoMode) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
featureName: feature.title, featureName: feature.title,
branchName: feature.branchName ?? null, branchName: feature.branchName ?? null,
executionMode: 'auto',
passes: true, passes: true,
message: 'Pipeline resumed successfully', message: 'Pipeline resumed successfully',
projectPath, projectPath,
}); });
}
} catch (error) { } catch (error) {
const errorInfo = classifyError(error); const errorInfo = classifyError(error);
if (errorInfo.isAbort) { if (errorInfo.isAbort) {
if (runningEntry.isAutoMode) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
featureName: feature.title, featureName: feature.title,
branchName: feature.branchName ?? null, branchName: feature.branchName ?? null,
executionMode: 'auto',
passes: false, passes: false,
message: 'Pipeline stopped by user', message: 'Pipeline stopped by user',
projectPath, projectPath,
}); });
}
} else { } else {
// If pipeline steps completed successfully, don't send the feature back to backlog.
// The pipeline work is done — set to waiting_approval so the user can review.
const fallbackStatus = pipelineCompleted ? 'waiting_approval' : 'backlog';
if (pipelineCompleted) {
logger.info(
`[resumeFromStep] Feature ${featureId} failed after pipeline completed. ` +
`Setting status to waiting_approval instead of backlog to preserve pipeline work.`
);
}
logger.error(`Pipeline resume failed for ${featureId}:`, error); logger.error(`Pipeline resume failed for ${featureId}:`, error);
await this.updateFeatureStatusFn(projectPath, featureId, 'backlog'); // Don't overwrite terminal states like 'merge_conflict' that were set during pipeline execution
const currentFeature = await this.featureStateManager.loadFeature(projectPath, featureId);
if (currentFeature?.status !== 'merge_conflict') {
await this.updateFeatureStatusFn(projectPath, featureId, fallbackStatus);
}
this.eventBus.emitAutoModeEvent('auto_mode_error', { this.eventBus.emitAutoModeEvent('auto_mode_error', {
featureId, featureId,
featureName: feature.title, featureName: feature.title,
@@ -449,7 +513,17 @@ export class PipelineOrchestrator {
projectPath, projectPath,
undefined, undefined,
undefined, undefined,
{ projectPath, planningMode: 'skip', requirePlanApproval: false } {
projectPath,
planningMode: 'skip',
requirePlanApproval: false,
useClaudeCodeSystemPrompt: context.useClaudeCodeSystemPrompt,
autoLoadClaudeMd: context.autoLoadClaudeMd,
thinkingLevel: context.feature.thinkingLevel,
reasoningEffort: context.feature.reasoningEffort,
status: context.feature.status,
providerId: context.feature.providerId,
}
); );
} }
} }
@@ -537,14 +611,18 @@ export class PipelineOrchestrator {
} }
logger.info(`Auto-merge successful for feature ${featureId}`); logger.info(`Auto-merge successful for feature ${featureId}`);
const runningEntryForMerge = this.concurrencyManager.getRunningFeature(featureId);
if (runningEntryForMerge?.isAutoMode) {
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
featureName: feature.title, featureName: feature.title,
branchName, branchName,
executionMode: 'auto',
passes: true, passes: true,
message: 'Pipeline completed and merged', message: 'Pipeline completed and merged',
projectPath, projectPath,
}); });
}
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
logger.error(`Merge failed for ${featureId}:`, error); logger.error(`Merge failed for ${featureId}:`, error);
@@ -580,7 +658,7 @@ export class PipelineOrchestrator {
} }
// Only capture assertion details when they appear in failure context // Only capture assertion details when they appear in failure context
// or match explicit assertion error / expect patterns // or match explicit assertion error / expect patterns
if (trimmed.includes('AssertionError') || trimmed.includes('AssertionError')) { if (trimmed.includes('AssertionError')) {
failedTests.push(trimmed); failedTests.push(trimmed);
} else if ( } else if (
inFailureContext && inFailureContext &&

View File

@@ -14,6 +14,7 @@ export interface PipelineContext {
branchName: string | null; branchName: string | null;
abortController: AbortController; abortController: AbortController;
autoLoadClaudeMd: boolean; autoLoadClaudeMd: boolean;
useClaudeCodeSystemPrompt?: boolean;
testAttempts: number; testAttempts: number;
maxTestAttempts: number; maxTestAttempts: number;
} }

View File

@@ -0,0 +1,431 @@
/**
* PR Review Comments Service
*
* Domain logic for fetching PR review comments, enriching them with
* resolved-thread status, and sorting. Extracted from the route handler
* so the route only deals with request/response plumbing.
*/
import { spawn, execFile } from 'child_process';
import { promisify } from 'util';
import { createLogger } from '@automaker/utils';
import { execEnv, logError } from '../lib/exec-utils.js';
const execFileAsync = promisify(execFile);
// ── Public types (re-exported for callers) ──
export interface PRReviewComment {
id: string;
author: string;
avatarUrl?: string;
body: string;
path?: string;
line?: number;
createdAt: string;
updatedAt?: string;
isReviewComment: boolean;
/** Whether this is an outdated review comment (code has changed since) */
isOutdated?: boolean;
/** Whether the review thread containing this comment has been resolved */
isResolved?: boolean;
/** The GraphQL node ID of the review thread (used for resolve/unresolve mutations) */
threadId?: string;
/** The diff hunk context for the comment */
diffHunk?: string;
/** The side of the diff (LEFT or RIGHT) */
side?: string;
/** The commit ID the comment was made on */
commitId?: string;
/** Whether the comment author is a bot/app account */
isBot?: boolean;
}
export interface ListPRReviewCommentsResult {
success: boolean;
comments?: PRReviewComment[];
totalCount?: number;
error?: string;
}
// ── Internal types ──
/** Timeout for GitHub GraphQL API requests in milliseconds */
const GITHUB_API_TIMEOUT_MS = 30000;
/** Maximum number of pagination pages to prevent infinite loops */
const MAX_PAGINATION_PAGES = 20;
interface GraphQLReviewThreadComment {
databaseId: number;
}
interface GraphQLReviewThread {
id: string;
isResolved: boolean;
comments: {
pageInfo?: {
hasNextPage: boolean;
endCursor?: string | null;
};
nodes: GraphQLReviewThreadComment[];
};
}
interface GraphQLResponse {
data?: {
repository?: {
pullRequest?: {
reviewThreads?: {
nodes: GraphQLReviewThread[];
pageInfo?: {
hasNextPage: boolean;
endCursor?: string | null;
};
};
} | null;
};
};
errors?: Array<{ message: string }>;
}
interface ReviewThreadInfo {
isResolved: boolean;
threadId: string;
}
// ── Logger ──
const logger = createLogger('PRReviewCommentsService');
// ── Service functions ──
/**
* Execute a GraphQL query via the `gh` CLI and return the parsed response.
*/
async function executeGraphQL(projectPath: string, requestBody: string): Promise<GraphQLResponse> {
let timeoutId: NodeJS.Timeout | undefined;
const response = await new Promise<GraphQLResponse>((resolve, reject) => {
const gh = spawn('gh', ['api', 'graphql', '--input', '-'], {
cwd: projectPath,
env: execEnv,
});
gh.on('error', (err) => {
clearTimeout(timeoutId);
reject(err);
});
timeoutId = setTimeout(() => {
gh.kill();
reject(new Error('GitHub GraphQL API request timed out'));
}, GITHUB_API_TIMEOUT_MS);
let stdout = '';
let stderr = '';
gh.stdout.on('data', (data: Buffer) => (stdout += data.toString()));
gh.stderr.on('data', (data: Buffer) => (stderr += data.toString()));
gh.on('close', (code) => {
clearTimeout(timeoutId);
if (code !== 0) {
return reject(new Error(`gh process exited with code ${code}: ${stderr}`));
}
try {
resolve(JSON.parse(stdout));
} catch (e) {
reject(e);
}
});
gh.stdin.on('error', () => {
// Ignore stdin errors (e.g. when the child process is killed)
});
gh.stdin.write(requestBody);
gh.stdin.end();
});
if (response.errors && response.errors.length > 0) {
throw new Error(response.errors[0].message);
}
return response;
}
/**
* Fetch review thread resolved status and thread IDs using GitHub GraphQL API.
* Uses cursor-based pagination to handle PRs with more than 100 review threads.
* Returns a map of comment ID (string) -> { isResolved, threadId }.
*/
export async function fetchReviewThreadResolvedStatus(
projectPath: string,
owner: string,
repo: string,
prNumber: number
): Promise<Map<string, ReviewThreadInfo>> {
const resolvedMap = new Map<string, ReviewThreadInfo>();
const query = `
query GetPRReviewThreads(
$owner: String!
$repo: String!
$prNumber: Int!
$cursor: String
) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $prNumber) {
reviewThreads(first: 100, after: $cursor) {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
isResolved
comments(first: 100) {
pageInfo {
hasNextPage
endCursor
}
nodes {
databaseId
}
}
}
}
}
}
}`;
try {
let cursor: string | null = null;
let pageCount = 0;
do {
const variables = { owner, repo, prNumber, cursor };
const requestBody = JSON.stringify({ query, variables });
const response = await executeGraphQL(projectPath, requestBody);
const reviewThreads = response.data?.repository?.pullRequest?.reviewThreads;
const threads = reviewThreads?.nodes ?? [];
for (const thread of threads) {
if (thread.comments.pageInfo?.hasNextPage) {
logger.debug(
`Review thread ${thread.id} in PR #${prNumber} has >100 comments — ` +
'some comments may be missing resolved status'
);
}
const info: ReviewThreadInfo = { isResolved: thread.isResolved, threadId: thread.id };
for (const comment of thread.comments.nodes) {
resolvedMap.set(String(comment.databaseId), info);
}
}
const pageInfo = reviewThreads?.pageInfo;
if (pageInfo?.hasNextPage && pageInfo.endCursor) {
cursor = pageInfo.endCursor;
pageCount++;
logger.debug(
`Fetching next page of review threads for PR #${prNumber} (page ${pageCount + 1})`
);
} else {
cursor = null;
}
} while (cursor && pageCount < MAX_PAGINATION_PAGES);
if (pageCount >= MAX_PAGINATION_PAGES) {
logger.warn(
`PR #${prNumber} in ${owner}/${repo} has more than ${MAX_PAGINATION_PAGES * 100} review threads — ` +
'pagination limit reached. Some comments may be missing resolved status.'
);
}
} catch (error) {
// Log but don't fail — resolved status is best-effort
logError(error, 'Failed to fetch PR review thread resolved status');
}
return resolvedMap;
}
/**
* Fetch all comments for a PR (regular, inline review, and review body comments)
*/
export async function fetchPRReviewComments(
projectPath: string,
owner: string,
repo: string,
prNumber: number
): Promise<PRReviewComment[]> {
const allComments: PRReviewComment[] = [];
// Fetch review thread resolved status in parallel with comment fetching
const resolvedStatusPromise = fetchReviewThreadResolvedStatus(projectPath, owner, repo, prNumber);
// 1. Fetch regular PR comments (issue-level comments)
// Uses the REST API issues endpoint instead of `gh pr view --json comments`
// because the latter uses GraphQL internally where bot/app authors can return
// null, causing bot comments to be silently dropped or display as "unknown".
try {
const issueCommentsEndpoint = `repos/${owner}/${repo}/issues/${prNumber}/comments`;
const { stdout: commentsOutput } = await execFileAsync(
'gh',
['api', issueCommentsEndpoint, '--paginate'],
{
cwd: projectPath,
env: execEnv,
maxBuffer: 1024 * 1024 * 10, // 10MB buffer for large PRs
timeout: GITHUB_API_TIMEOUT_MS,
}
);
const commentsData = JSON.parse(commentsOutput);
const regularComments = (Array.isArray(commentsData) ? commentsData : []).map(
(c: {
id: number;
user: { login: string; avatar_url?: string; type?: string } | null;
body: string;
created_at: string;
updated_at?: string;
performed_via_github_app?: { slug: string } | null;
}) => ({
id: String(c.id),
author: c.user?.login || c.performed_via_github_app?.slug || 'unknown',
avatarUrl: c.user?.avatar_url,
body: c.body,
createdAt: c.created_at,
updatedAt: c.updated_at,
isReviewComment: false,
isOutdated: false,
isBot: c.user?.type === 'Bot' || !!c.performed_via_github_app,
// Regular PR comments are not part of review threads, so not resolvable
isResolved: false,
})
);
allComments.push(...regularComments);
} catch (error) {
logError(error, 'Failed to fetch regular PR comments');
}
// 2. Fetch inline review comments (code-level comments with file/line info)
try {
const reviewsEndpoint = `repos/${owner}/${repo}/pulls/${prNumber}/comments`;
const { stdout: reviewsOutput } = await execFileAsync(
'gh',
['api', reviewsEndpoint, '--paginate'],
{
cwd: projectPath,
env: execEnv,
maxBuffer: 1024 * 1024 * 10, // 10MB buffer for large PRs
timeout: GITHUB_API_TIMEOUT_MS,
}
);
const reviewsData = JSON.parse(reviewsOutput);
const reviewComments = (Array.isArray(reviewsData) ? reviewsData : []).map(
(c: {
id: number;
user: { login: string; avatar_url?: string; type?: string } | null;
body: string;
path: string;
line?: number;
original_line?: number;
created_at: string;
updated_at?: string;
diff_hunk?: string;
side?: string;
commit_id?: string;
position?: number | null;
performed_via_github_app?: { slug: string } | null;
}) => ({
id: String(c.id),
author: c.user?.login || c.performed_via_github_app?.slug || 'unknown',
avatarUrl: c.user?.avatar_url,
body: c.body,
path: c.path,
line: c.line ?? c.original_line,
createdAt: c.created_at,
updatedAt: c.updated_at,
isReviewComment: true,
// A review comment is "outdated" if position is null (code has changed)
isOutdated: c.position === null,
// isResolved will be filled in below from GraphQL data
isResolved: false,
isBot: c.user?.type === 'Bot' || !!c.performed_via_github_app,
diffHunk: c.diff_hunk,
side: c.side,
commitId: c.commit_id,
})
);
allComments.push(...reviewComments);
} catch (error) {
logError(error, 'Failed to fetch inline review comments');
}
// 3. Fetch review body comments (summary text submitted with each review)
// These are the top-level comments written when submitting a review
// (Approve, Request Changes, Comment). They are separate from inline code comments
// and issue-level comments. Only include reviews that have a non-empty body.
try {
const reviewsEndpoint = `repos/${owner}/${repo}/pulls/${prNumber}/reviews`;
const { stdout: reviewBodiesOutput } = await execFileAsync(
'gh',
['api', reviewsEndpoint, '--paginate'],
{
cwd: projectPath,
env: execEnv,
maxBuffer: 1024 * 1024 * 10, // 10MB buffer for large PRs
timeout: GITHUB_API_TIMEOUT_MS,
}
);
const reviewBodiesData = JSON.parse(reviewBodiesOutput);
const reviewBodyComments = (Array.isArray(reviewBodiesData) ? reviewBodiesData : [])
.filter(
(r: { body?: string; state?: string }) =>
r.body && r.body.trim().length > 0 && r.state !== 'PENDING'
)
.map(
(r: {
id: number;
user: { login: string; avatar_url?: string; type?: string } | null;
body: string;
state: string;
submitted_at: string;
performed_via_github_app?: { slug: string } | null;
}) => ({
id: `review-${r.id}`,
author: r.user?.login || r.performed_via_github_app?.slug || 'unknown',
avatarUrl: r.user?.avatar_url,
body: r.body,
createdAt: r.submitted_at,
isReviewComment: false,
isOutdated: false,
isResolved: false,
isBot: r.user?.type === 'Bot' || !!r.performed_via_github_app,
})
);
allComments.push(...reviewBodyComments);
} catch (error) {
logError(error, 'Failed to fetch review body comments');
}
// Wait for resolved status and apply to inline review comments
const resolvedMap = await resolvedStatusPromise;
for (const comment of allComments) {
if (comment.isReviewComment && resolvedMap.has(comment.id)) {
const info = resolvedMap.get(comment.id)!;
comment.isResolved = info.isResolved;
comment.threadId = info.threadId;
}
}
// Sort by createdAt descending (newest first)
allComments.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return allComments;
}

View File

@@ -28,6 +28,8 @@ const logger = createLogger('PullService');
export interface PullOptions { export interface PullOptions {
/** Remote name to pull from (defaults to 'origin') */ /** Remote name to pull from (defaults to 'origin') */
remote?: string; remote?: string;
/** Specific remote branch to pull (e.g. 'main'). When provided, overrides the tracking branch and fetches this branch from the remote. */
remoteBranch?: string;
/** When true, automatically stash local changes before pulling and reapply after */ /** When true, automatically stash local changes before pulling and reapply after */
stashIfNeeded?: boolean; stashIfNeeded?: boolean;
} }
@@ -243,6 +245,7 @@ export async function performPull(
): Promise<PullResult> { ): Promise<PullResult> {
const targetRemote = options?.remote || 'origin'; const targetRemote = options?.remote || 'origin';
const stashIfNeeded = options?.stashIfNeeded ?? false; const stashIfNeeded = options?.stashIfNeeded ?? false;
const targetRemoteBranch = options?.remoteBranch;
// 1. Get current branch name // 1. Get current branch name
let branchName: string; let branchName: string;
@@ -313,7 +316,11 @@ export async function performPull(
} }
// 7. Verify upstream tracking or remote branch exists // 7. Verify upstream tracking or remote branch exists
const upstreamStatus = await hasUpstreamOrRemoteBranch(worktreePath, branchName, targetRemote); // Skip this check when a specific remote branch is provided - we always use
// explicit 'git pull <remote> <branch>' args in that case.
let upstreamStatus: UpstreamStatus = 'tracking';
if (!targetRemoteBranch) {
upstreamStatus = await hasUpstreamOrRemoteBranch(worktreePath, branchName, targetRemote);
if (upstreamStatus === 'none') { if (upstreamStatus === 'none') {
let stashRecoveryFailed = false; let stashRecoveryFailed = false;
if (didStash) { if (didStash) {
@@ -326,11 +333,17 @@ export async function performPull(
stashRecoveryFailed: stashRecoveryFailed ? stashRecoveryFailed : undefined, stashRecoveryFailed: stashRecoveryFailed ? stashRecoveryFailed : undefined,
}; };
} }
}
// 8. Pull latest changes // 8. Pull latest changes
// When a specific remote branch is requested, always use explicit remote + branch args.
// When the branch has a configured upstream tracking ref, let Git use it automatically. // When the branch has a configured upstream tracking ref, let Git use it automatically.
// When only the remote branch exists (no tracking ref), explicitly specify remote and branch. // When only the remote branch exists (no tracking ref), explicitly specify remote and branch.
const pullArgs = upstreamStatus === 'tracking' ? ['pull'] : ['pull', targetRemote, branchName]; const pullArgs = targetRemoteBranch
? ['pull', targetRemote, targetRemoteBranch]
: upstreamStatus === 'tracking'
? ['pull']
: ['pull', targetRemote, branchName];
let pullConflict = false; let pullConflict = false;
let pullConflictFiles: string[] = []; let pullConflictFiles: string[] = [];

View File

@@ -0,0 +1,258 @@
/**
* PushService - Push git operations without HTTP
*
* Encapsulates the full git push workflow including:
* - Branch name and detached HEAD detection
* - Safe array-based command execution (no shell interpolation)
* - Divergent branch detection and auto-resolution via pull-then-retry
* - Structured result reporting
*
* Mirrors the pull-service.ts pattern for consistency.
*/
import { createLogger, getErrorMessage } from '@automaker/utils';
import { execGitCommand } from '@automaker/git-utils';
import { getCurrentBranch } from '../lib/git.js';
import { performPull } from './pull-service.js';
const logger = createLogger('PushService');
// ============================================================================
// Types
// ============================================================================
export interface PushOptions {
/** Remote name to push to (defaults to 'origin') */
remote?: string;
/** Force push */
force?: boolean;
/** When true and push is rejected due to divergence, pull then retry push */
autoResolve?: boolean;
}
export interface PushResult {
success: boolean;
error?: string;
branch?: string;
pushed?: boolean;
/** Whether the push was initially rejected because the branches diverged */
diverged?: boolean;
/** Whether divergence was automatically resolved via pull-then-retry */
autoResolved?: boolean;
/** Whether the auto-resolve pull resulted in merge conflicts */
hasConflicts?: boolean;
/** Files with merge conflicts (only when hasConflicts is true) */
conflictFiles?: string[];
message?: string;
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Detect whether push error output indicates a diverged/non-fast-forward rejection.
*/
function isDivergenceError(errorOutput: string): boolean {
const lower = errorOutput.toLowerCase();
// Require specific divergence indicators rather than just 'rejected' alone,
// which could match pre-receive hook rejections or protected branch errors.
const hasNonFastForward = lower.includes('non-fast-forward');
const hasFetchFirst = lower.includes('fetch first');
const hasFailedToPush = lower.includes('failed to push some refs');
const hasRejected = lower.includes('rejected');
return hasNonFastForward || hasFetchFirst || (hasRejected && hasFailedToPush);
}
// ============================================================================
// Main Service Function
// ============================================================================
/**
* Perform a git push on the given worktree.
*
* The workflow:
* 1. Get current branch name (detect detached HEAD)
* 2. Attempt `git push <remote> <branch>` with safe array args
* 3. If push fails with divergence and autoResolve is true:
* a. Pull from the same remote (with stash support)
* b. If pull succeeds without conflicts, retry push
* 4. If push fails with "no upstream" error, retry with --set-upstream
* 5. Return structured result
*
* @param worktreePath - Path to the git worktree
* @param options - Push options (remote, force, autoResolve)
* @returns PushResult with detailed status information
*/
export async function performPush(
worktreePath: string,
options?: PushOptions
): Promise<PushResult> {
const targetRemote = options?.remote || 'origin';
const force = options?.force ?? false;
const autoResolve = options?.autoResolve ?? false;
// 1. Get current branch name
let branchName: string;
try {
branchName = await getCurrentBranch(worktreePath);
} catch (err) {
return {
success: false,
error: `Failed to get current branch: ${getErrorMessage(err)}`,
};
}
// 2. Check for detached HEAD state
if (branchName === 'HEAD') {
return {
success: false,
error: 'Cannot push in detached HEAD state. Please checkout a branch first.',
};
}
// 3. Build push args (no -u flag; upstream is set in the fallback path only when needed)
const pushArgs = ['push', targetRemote, branchName];
if (force) {
pushArgs.push('--force');
}
// 4. Attempt push
try {
await execGitCommand(pushArgs, worktreePath);
return {
success: true,
branch: branchName,
pushed: true,
message: `Successfully pushed ${branchName} to ${targetRemote}`,
};
} catch (pushError: unknown) {
const err = pushError as { stderr?: string; stdout?: string; message?: string };
const errorOutput = `${err.stderr || ''} ${err.stdout || ''} ${err.message || ''}`;
// 5. Check if the error is a divergence rejection
if (isDivergenceError(errorOutput)) {
if (!autoResolve) {
return {
success: false,
branch: branchName,
pushed: false,
diverged: true,
error: `Push rejected: remote has changes not present locally. Use sync or pull first, or enable auto-resolve.`,
message: `Push to ${targetRemote} was rejected because the remote branch has diverged.`,
};
}
// 6. Auto-resolve: pull then retry push
logger.info('Push rejected due to divergence, attempting auto-resolve via pull', {
worktreePath,
remote: targetRemote,
branch: branchName,
});
try {
const pullResult = await performPull(worktreePath, {
remote: targetRemote,
stashIfNeeded: true,
});
if (!pullResult.success) {
return {
success: false,
branch: branchName,
pushed: false,
diverged: true,
autoResolved: false,
error: `Auto-resolve failed during pull: ${pullResult.error}`,
};
}
if (pullResult.hasConflicts) {
return {
success: false,
branch: branchName,
pushed: false,
diverged: true,
autoResolved: false,
hasConflicts: true,
conflictFiles: pullResult.conflictFiles,
error:
'Auto-resolve pull resulted in merge conflicts. Resolve conflicts and push again.',
};
}
// 7. Retry push after successful pull
try {
await execGitCommand(pushArgs, worktreePath);
return {
success: true,
branch: branchName,
pushed: true,
diverged: true,
autoResolved: true,
message: `Push succeeded after auto-resolving divergence (pulled from ${targetRemote} first).`,
};
} catch (retryError: unknown) {
const retryErr = retryError as { stderr?: string; message?: string };
return {
success: false,
branch: branchName,
pushed: false,
diverged: true,
autoResolved: false,
error: `Push failed after auto-resolve pull: ${retryErr.stderr || retryErr.message || 'Unknown error'}`,
};
}
} catch (pullError) {
return {
success: false,
branch: branchName,
pushed: false,
diverged: true,
autoResolved: false,
error: `Auto-resolve pull failed: ${getErrorMessage(pullError)}`,
};
}
}
// 6b. Non-divergence error (e.g. no upstream configured) - retry with --set-upstream
const isNoUpstreamError =
errorOutput.toLowerCase().includes('no upstream') ||
errorOutput.toLowerCase().includes('has no upstream branch') ||
errorOutput.toLowerCase().includes('set-upstream');
if (isNoUpstreamError) {
try {
const setUpstreamArgs = ['push', '--set-upstream', targetRemote, branchName];
if (force) {
setUpstreamArgs.push('--force');
}
await execGitCommand(setUpstreamArgs, worktreePath);
return {
success: true,
branch: branchName,
pushed: true,
message: `Successfully pushed ${branchName} to ${targetRemote} (set upstream)`,
};
} catch (upstreamError: unknown) {
const upstreamErr = upstreamError as { stderr?: string; message?: string };
return {
success: false,
branch: branchName,
pushed: false,
error: upstreamErr.stderr || upstreamErr.message || getErrorMessage(pushError),
};
}
}
// 6c. Other push error - return as-is
return {
success: false,
branch: branchName,
pushed: false,
error: err.stderr || err.message || getErrorMessage(pushError),
};
}
}

View File

@@ -31,6 +31,7 @@ import type {
WorktreeInfo, WorktreeInfo,
PhaseModelConfig, PhaseModelConfig,
PhaseModelEntry, PhaseModelEntry,
FeatureTemplate,
ClaudeApiProfile, ClaudeApiProfile,
ClaudeCompatibleProvider, ClaudeCompatibleProvider,
ProviderModel, ProviderModel,
@@ -40,6 +41,7 @@ import {
DEFAULT_CREDENTIALS, DEFAULT_CREDENTIALS,
DEFAULT_PROJECT_SETTINGS, DEFAULT_PROJECT_SETTINGS,
DEFAULT_PHASE_MODELS, DEFAULT_PHASE_MODELS,
DEFAULT_FEATURE_TEMPLATES,
SETTINGS_VERSION, SETTINGS_VERSION,
CREDENTIALS_VERSION, CREDENTIALS_VERSION,
PROJECT_SETTINGS_VERSION, PROJECT_SETTINGS_VERSION,
@@ -139,6 +141,11 @@ export class SettingsService {
// Migrate model IDs to canonical format // Migrate model IDs to canonical format
const migratedModelSettings = this.migrateModelSettings(settings); const migratedModelSettings = this.migrateModelSettings(settings);
// Merge built-in feature templates: ensure all built-in templates exist in user settings.
// User customizations (enabled/disabled state, order overrides) are preserved.
// New built-in templates added in code updates are injected for existing users.
const mergedFeatureTemplates = this.mergeBuiltInTemplates(settings.featureTemplates);
// Apply any missing defaults (for backwards compatibility) // Apply any missing defaults (for backwards compatibility)
let result: GlobalSettings = { let result: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS, ...DEFAULT_GLOBAL_SETTINGS,
@@ -149,6 +156,7 @@ export class SettingsService {
...settings.keyboardShortcuts, ...settings.keyboardShortcuts,
}, },
phaseModels: migratedPhaseModels, phaseModels: migratedPhaseModels,
featureTemplates: mergedFeatureTemplates,
}; };
// Version-based migrations // Version-based migrations
@@ -250,6 +258,32 @@ export class SettingsService {
return result; return result;
} }
/**
* Merge built-in feature templates with user's stored templates.
*
* Ensures new built-in templates added in code updates are available to existing users
* without overwriting their customizations (e.g., enabled/disabled state, custom order).
* Built-in templates missing from stored settings are appended with their defaults.
*
* @param storedTemplates - Templates from user's settings file (may be undefined for new installs)
* @returns Merged template list with all built-in templates present
*/
private mergeBuiltInTemplates(storedTemplates: FeatureTemplate[] | undefined): FeatureTemplate[] {
if (!storedTemplates) {
return DEFAULT_FEATURE_TEMPLATES;
}
const storedIds = new Set(storedTemplates.map((t) => t.id));
const missingBuiltIns = DEFAULT_FEATURE_TEMPLATES.filter((t) => !storedIds.has(t.id));
if (missingBuiltIns.length === 0) {
return storedTemplates;
}
// Append missing built-in templates after existing ones
return [...storedTemplates, ...missingBuiltIns];
}
/** /**
* Migrate legacy enhancementModel/validationModel fields to phaseModels structure * Migrate legacy enhancementModel/validationModel fields to phaseModels structure
* *
@@ -573,6 +607,47 @@ export class SettingsService {
ignoreEmptyArrayOverwrite('claudeApiProfiles'); ignoreEmptyArrayOverwrite('claudeApiProfiles');
// Note: claudeCompatibleProviders intentionally NOT guarded - users should be able to delete all providers // Note: claudeCompatibleProviders intentionally NOT guarded - users should be able to delete all providers
// Check for explicit permission to clear eventHooks (escape hatch for intentional clearing)
const allowEmptyEventHooks =
(sanitizedUpdates as Record<string, unknown>).__allowEmptyEventHooks === true;
// Remove the flag so it doesn't get persisted
delete (sanitizedUpdates as Record<string, unknown>).__allowEmptyEventHooks;
// Only guard eventHooks if explicit permission wasn't granted
if (!allowEmptyEventHooks) {
ignoreEmptyArrayOverwrite('eventHooks');
}
// Guard ntfyEndpoints against accidental wipe
// (similar to eventHooks, these are user-configured and shouldn't be lost)
// Check for explicit permission to clear ntfyEndpoints (escape hatch for intentional clearing)
const allowEmptyNtfyEndpoints =
(sanitizedUpdates as Record<string, unknown>).__allowEmptyNtfyEndpoints === true;
// Remove the flag so it doesn't get persisted
delete (sanitizedUpdates as Record<string, unknown>).__allowEmptyNtfyEndpoints;
if (!allowEmptyNtfyEndpoints) {
const currentNtfyLen = Array.isArray(current.ntfyEndpoints)
? current.ntfyEndpoints.length
: 0;
const newNtfyLen = Array.isArray(sanitizedUpdates.ntfyEndpoints)
? sanitizedUpdates.ntfyEndpoints.length
: currentNtfyLen;
if (Array.isArray(sanitizedUpdates.ntfyEndpoints) && newNtfyLen === 0 && currentNtfyLen > 0) {
logger.warn(
'[WIPE_PROTECTION] Attempted to set ntfyEndpoints to empty array! Ignoring update.',
{
currentNtfyLen,
newNtfyLen,
}
);
delete sanitizedUpdates.ntfyEndpoints;
}
} else {
logger.info('[INTENTIONAL_CLEAR] Clearing ntfyEndpoints via escape hatch');
}
// Empty object overwrite guard // Empty object overwrite guard
const ignoreEmptyObjectOverwrite = <K extends keyof GlobalSettings>(key: K): void => { const ignoreEmptyObjectOverwrite = <K extends keyof GlobalSettings>(key: K): void => {
const nextVal = sanitizedUpdates[key] as unknown; const nextVal = sanitizedUpdates[key] as unknown;
@@ -978,6 +1053,8 @@ export class SettingsService {
keyboardShortcuts: keyboardShortcuts:
(appState.keyboardShortcuts as KeyboardShortcuts) || (appState.keyboardShortcuts as KeyboardShortcuts) ||
DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts, DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
eventHooks: (appState.eventHooks as GlobalSettings['eventHooks']) || [],
ntfyEndpoints: (appState.ntfyEndpoints as GlobalSettings['ntfyEndpoints']) || [],
projects: (appState.projects as ProjectRef[]) || [], projects: (appState.projects as ProjectRef[]) || [],
trashedProjects: (appState.trashedProjects as TrashedProjectRef[]) || [], trashedProjects: (appState.trashedProjects as TrashedProjectRef[]) || [],
projectHistory: (appState.projectHistory as string[]) || [], projectHistory: (appState.projectHistory as string[]) || [],

View File

@@ -101,12 +101,32 @@ export function detectTaskStartMarker(text: string): string | null {
} }
/** /**
* Detect [TASK_COMPLETE] marker in text and extract task ID * Detect [TASK_COMPLETE] marker in text and extract task ID and summary
* Format: [TASK_COMPLETE] T###: Brief summary * Format: [TASK_COMPLETE] T###: Brief summary
*/ */
export function detectTaskCompleteMarker(text: string): string | null { export function detectTaskCompleteMarker(text: string): { id: string; summary?: string } | null {
const match = text.match(/\[TASK_COMPLETE\]\s*(T\d{3})/); // Use a regex that captures the summary until newline or next task marker
return match ? match[1] : null; // Allow brackets in summary content (e.g., "supports array[index] access")
// Pattern breakdown:
// - \[TASK_COMPLETE\]\s* - Match the marker
// - (T\d{3}) - Capture task ID
// - (?::\s*([^\n\[]+))? - Optionally capture summary (stops at newline or bracket)
// - But we want to allow brackets in summary, so we use a different approach:
// - Match summary until newline, then trim any trailing markers in post-processing
const match = text.match(/\[TASK_COMPLETE\]\s*(T\d{3})(?::\s*(.+?))?(?=\n|$)/i);
if (!match) return null;
// Post-process: remove trailing task markers from summary if present
let summary = match[2]?.trim();
if (summary) {
// Remove trailing content that looks like another marker
summary = summary.replace(/\s*\[TASK_[A-Z_]+\].*$/i, '').trim();
}
return {
id: match[1],
summary: summary || undefined,
};
} }
/** /**
@@ -194,10 +214,14 @@ export function extractSummary(text: string): string | null {
} }
// Check for ## Summary section (use last match) // Check for ## Summary section (use last match)
const sectionMatches = text.matchAll(/##\s*Summary\s*\n+([\s\S]*?)(?=\n##|\n\*\*|$)/gi); // Stop at \n## [^#] (same-level headers like "## Changes") but preserve ### subsections
// (like "### Root Cause", "### Fix Applied") that belong to the summary content.
const sectionMatches = text.matchAll(/##\s*Summary\s*\n+([\s\S]*?)(?=\n## [^#]|$)/gi);
const sectionMatch = getLastMatch(sectionMatches); const sectionMatch = getLastMatch(sectionMatches);
if (sectionMatch) { if (sectionMatch) {
return truncate(sectionMatch[1].trim(), 500); const content = sectionMatch[1].trim();
// Keep full content (including ### subsections) up to max length
return content.length > 500 ? `${content.substring(0, 500)}...` : content;
} }
// Check for **Goal**: section (lite mode, use last match) // Check for **Goal**: section (lite mode, use last match)

View File

@@ -0,0 +1,209 @@
/**
* SyncService - Pull then push in a single operation
*
* Composes performPull() and performPush() to synchronize a branch
* with its remote. Always uses stashIfNeeded for the pull step.
* If push fails with divergence after pull, retries once.
*
* Follows the same pattern as pull-service.ts and push-service.ts.
*/
import { createLogger, getErrorMessage } from '@automaker/utils';
import { performPull } from './pull-service.js';
import { performPush } from './push-service.js';
import type { PullResult } from './pull-service.js';
import type { PushResult } from './push-service.js';
const logger = createLogger('SyncService');
// ============================================================================
// Types
// ============================================================================
export interface SyncOptions {
/** Remote name (defaults to 'origin') */
remote?: string;
}
export interface SyncResult {
success: boolean;
error?: string;
branch?: string;
/** Whether the pull step was performed */
pulled?: boolean;
/** Whether the push step was performed */
pushed?: boolean;
/** Pull resulted in conflicts */
hasConflicts?: boolean;
/** Files with merge conflicts */
conflictFiles?: string[];
/** Source of conflicts ('pull' | 'stash') */
conflictSource?: 'pull' | 'stash';
/** Whether the pull was a fast-forward */
isFastForward?: boolean;
/** Whether the pull resulted in a merge commit */
isMerge?: boolean;
/** Whether push divergence was auto-resolved */
autoResolved?: boolean;
message?: string;
}
// ============================================================================
// Main Service Function
// ============================================================================
/**
* Perform a sync operation (pull then push) on the given worktree.
*
* The workflow:
* 1. Pull from remote with stashIfNeeded: true
* 2. If pull has conflicts, stop and return conflict info
* 3. Push to remote
* 4. If push fails with divergence after pull, retry once
*
* @param worktreePath - Path to the git worktree
* @param options - Sync options (remote)
* @returns SyncResult with detailed status information
*/
export async function performSync(
worktreePath: string,
options?: SyncOptions
): Promise<SyncResult> {
const targetRemote = options?.remote || 'origin';
// 1. Pull from remote
logger.info('Sync: starting pull', { worktreePath, remote: targetRemote });
let pullResult: PullResult;
try {
pullResult = await performPull(worktreePath, {
remote: targetRemote,
stashIfNeeded: true,
});
} catch (pullError) {
return {
success: false,
error: `Sync pull failed: ${getErrorMessage(pullError)}`,
};
}
if (!pullResult.success) {
return {
success: false,
branch: pullResult.branch,
pulled: false,
pushed: false,
error: `Sync pull failed: ${pullResult.error}`,
hasConflicts: pullResult.hasConflicts,
conflictFiles: pullResult.conflictFiles,
conflictSource: pullResult.conflictSource,
};
}
// 2. If pull had conflicts, stop and return conflict info
if (pullResult.hasConflicts) {
return {
success: false,
branch: pullResult.branch,
pulled: true,
pushed: false,
hasConflicts: true,
conflictFiles: pullResult.conflictFiles,
conflictSource: pullResult.conflictSource,
isFastForward: pullResult.isFastForward,
isMerge: pullResult.isMerge,
error: 'Sync stopped: pull resulted in merge conflicts. Resolve conflicts and try again.',
message: pullResult.message,
};
}
// 3. Push to remote
logger.info('Sync: pull succeeded, starting push', { worktreePath, remote: targetRemote });
let pushResult: PushResult;
try {
pushResult = await performPush(worktreePath, {
remote: targetRemote,
});
} catch (pushError) {
return {
success: false,
branch: pullResult.branch,
pulled: true,
pushed: false,
isFastForward: pullResult.isFastForward,
isMerge: pullResult.isMerge,
error: `Sync push failed: ${getErrorMessage(pushError)}`,
};
}
if (!pushResult.success) {
// 4. If push diverged after pull, retry once with autoResolve
if (pushResult.diverged) {
logger.info('Sync: push diverged after pull, retrying with autoResolve', {
worktreePath,
remote: targetRemote,
});
try {
const retryResult = await performPush(worktreePath, {
remote: targetRemote,
autoResolve: true,
});
if (retryResult.success) {
return {
success: true,
branch: retryResult.branch,
pulled: true,
pushed: true,
autoResolved: true,
isFastForward: pullResult.isFastForward,
isMerge: pullResult.isMerge,
message: 'Sync completed (push required auto-resolve).',
};
}
return {
success: false,
branch: retryResult.branch,
pulled: true,
pushed: false,
hasConflicts: retryResult.hasConflicts,
conflictFiles: retryResult.conflictFiles,
error: retryResult.error,
};
} catch (retryError) {
return {
success: false,
branch: pullResult.branch,
pulled: true,
pushed: false,
error: `Sync push retry failed: ${getErrorMessage(retryError)}`,
};
}
}
return {
success: false,
branch: pushResult.branch,
pulled: true,
pushed: false,
isFastForward: pullResult.isFastForward,
isMerge: pullResult.isMerge,
error: `Sync push failed: ${pushResult.error}`,
};
}
return {
success: true,
branch: pushResult.branch,
pulled: pullResult.pulled ?? true,
pushed: true,
isFastForward: pullResult.isFastForward,
isMerge: pullResult.isMerge,
message: pullResult.pulled
? 'Sync completed: pulled latest changes and pushed.'
: 'Sync completed: already up to date, pushed local commits.',
};
}

View File

@@ -39,6 +39,18 @@ export interface WorktreeInfo {
* 3. Listing all worktrees with normalized paths * 3. Listing all worktrees with normalized paths
*/ */
export class WorktreeResolver { export class WorktreeResolver {
private normalizeBranchName(branchName: string | null | undefined): string | null {
if (!branchName) return null;
let normalized = branchName.trim();
if (!normalized) return null;
normalized = normalized.replace(/^refs\/heads\//, '');
normalized = normalized.replace(/^refs\/remotes\/[^/]+\//, '');
normalized = normalized.replace(/^(origin|upstream)\//, '');
return normalized || null;
}
/** /**
* Get the current branch name for a git repository * Get the current branch name for a git repository
* *
@@ -64,6 +76,9 @@ export class WorktreeResolver {
*/ */
async findWorktreeForBranch(projectPath: string, branchName: string): Promise<string | null> { async findWorktreeForBranch(projectPath: string, branchName: string): Promise<string | null> {
try { try {
const normalizedTargetBranch = this.normalizeBranchName(branchName);
if (!normalizedTargetBranch) return null;
const { stdout } = await execAsync('git worktree list --porcelain', { const { stdout } = await execAsync('git worktree list --porcelain', {
cwd: projectPath, cwd: projectPath,
}); });
@@ -76,10 +91,10 @@ export class WorktreeResolver {
if (line.startsWith('worktree ')) { if (line.startsWith('worktree ')) {
currentPath = line.slice(9); currentPath = line.slice(9);
} else if (line.startsWith('branch ')) { } else if (line.startsWith('branch ')) {
currentBranch = line.slice(7).replace('refs/heads/', ''); currentBranch = this.normalizeBranchName(line.slice(7));
} else if (line === '' && currentPath && currentBranch) { } else if (line === '' && currentPath && currentBranch) {
// End of a worktree entry // End of a worktree entry
if (currentBranch === branchName) { if (currentBranch === normalizedTargetBranch) {
// Resolve to absolute path - git may return relative paths // Resolve to absolute path - git may return relative paths
// On Windows, this is critical for cwd to work correctly // On Windows, this is critical for cwd to work correctly
// On all platforms, absolute paths ensure consistent behavior // On all platforms, absolute paths ensure consistent behavior
@@ -91,7 +106,7 @@ export class WorktreeResolver {
} }
// Check the last entry (if file doesn't end with newline) // Check the last entry (if file doesn't end with newline)
if (currentPath && currentBranch && currentBranch === branchName) { if (currentPath && currentBranch && currentBranch === normalizedTargetBranch) {
return this.resolvePath(projectPath, currentPath); return this.resolvePath(projectPath, currentPath);
} }
@@ -123,7 +138,7 @@ export class WorktreeResolver {
if (line.startsWith('worktree ')) { if (line.startsWith('worktree ')) {
currentPath = line.slice(9); currentPath = line.slice(9);
} else if (line.startsWith('branch ')) { } else if (line.startsWith('branch ')) {
currentBranch = line.slice(7).replace('refs/heads/', ''); currentBranch = this.normalizeBranchName(line.slice(7));
} else if (line.startsWith('detached')) { } else if (line.startsWith('detached')) {
// Detached HEAD - branch is null // Detached HEAD - branch is null
currentBranch = null; currentBranch = null;

View File

@@ -8,9 +8,60 @@
import path from 'path'; import path from 'path';
import fs from 'fs/promises'; import fs from 'fs/promises';
import { execGitCommand } from '@automaker/git-utils';
import type { EventEmitter } from '../lib/events.js'; import type { EventEmitter } from '../lib/events.js';
import type { SettingsService } from './settings-service.js'; import type { SettingsService } from './settings-service.js';
/**
* Get the list of remote names that have a branch matching the given branch name.
*
* Uses `git for-each-ref` to check cached remote refs, returning the names of
* any remotes that already have a branch with the same name as `currentBranch`.
* Returns an empty array when `hasAnyRemotes` is false or when no matching
* remote refs are found.
*
* This helps the UI distinguish between "branch exists on the tracking remote"
* vs "branch was pushed to a different remote".
*
* @param worktreePath - Path to the git worktree
* @param currentBranch - Branch name to search for on remotes
* @param hasAnyRemotes - Whether the repository has any remotes configured
* @returns Array of remote names (e.g. ["origin", "upstream"]) that contain the branch
*/
export async function getRemotesWithBranch(
worktreePath: string,
currentBranch: string,
hasAnyRemotes: boolean
): Promise<string[]> {
if (!hasAnyRemotes) {
return [];
}
try {
const remoteRefsOutput = await execGitCommand(
['for-each-ref', '--format=%(refname:short)', `refs/remotes/*/${currentBranch}`],
worktreePath
);
if (!remoteRefsOutput.trim()) {
return [];
}
return remoteRefsOutput
.trim()
.split('\n')
.map((ref) => {
// Extract remote name from "remote/branch" format
const slashIdx = ref.indexOf('/');
return slashIdx !== -1 ? ref.slice(0, slashIdx) : ref;
})
.filter((name) => name.length > 0);
} catch {
// Ignore errors - return empty array
return [];
}
}
/** /**
* Error thrown when one or more file copy operations fail during * Error thrown when one or more file copy operations fail during
* `copyConfiguredFiles`. The caller can inspect `failures` for details. * `copyConfiguredFiles`. The caller can inspect `failures` for details.

View File

@@ -23,6 +23,7 @@ export type {
PhaseModelConfig, PhaseModelConfig,
PhaseModelKey, PhaseModelKey,
PhaseModelEntry, PhaseModelEntry,
FeatureTemplate,
// Claude-compatible provider types // Claude-compatible provider types
ApiKeySource, ApiKeySource,
ClaudeCompatibleProviderType, ClaudeCompatibleProviderType,
@@ -41,6 +42,7 @@ export {
DEFAULT_CREDENTIALS, DEFAULT_CREDENTIALS,
DEFAULT_PROJECT_SETTINGS, DEFAULT_PROJECT_SETTINGS,
DEFAULT_PHASE_MODELS, DEFAULT_PHASE_MODELS,
DEFAULT_FEATURE_TEMPLATES,
SETTINGS_VERSION, SETTINGS_VERSION,
CREDENTIALS_VERSION, CREDENTIALS_VERSION,
PROJECT_SETTINGS_VERSION, PROJECT_SETTINGS_VERSION,

View File

@@ -168,7 +168,7 @@ describe('enhancement-prompts.ts', () => {
const prompt = buildUserPrompt('improve', testText); const prompt = buildUserPrompt('improve', testText);
expect(prompt).toContain('Example 1:'); expect(prompt).toContain('Example 1:');
expect(prompt).toContain(testText); expect(prompt).toContain(testText);
expect(prompt).toContain('Now, please enhance the following task description:'); expect(prompt).toContain('Please enhance the following task description:');
}); });
it('should build prompt without examples when includeExamples is false', () => { it('should build prompt without examples when includeExamples is false', () => {

View File

@@ -0,0 +1,333 @@
import { describe, it, expect } from 'vitest';
import {
computeIsDirty,
updateTabWithContent as updateTabContent,
markTabAsSaved as markTabSaved,
} from '../../../../ui/src/components/views/file-editor-view/file-editor-dirty-utils.ts';
/**
* Unit tests for the file editor store logic, focusing on the unsaved indicator fix.
*
* The bug was: File unsaved indicators weren't working reliably - editing a file
* and saving it would sometimes leave the dirty indicator (dot) visible.
*
* Root causes:
* 1. Stale closure in handleSave - captured activeTab could have old content
* 2. Editor buffer not synced - CodeMirror might have buffered changes not yet in store
*
* Fix:
* - handleSave now gets fresh state from store using getState()
* - handleSave gets current content from editor via getValue()
* - Content is synced to store before saving if it differs
*
* Since we can't easily test the React/zustand store in node environment,
* we test the pure logic that the store uses for dirty state tracking.
*/
describe('File editor dirty state logic', () => {
describe('updateTabContent', () => {
it('should set isDirty to true when content differs from originalContent', () => {
const tab = {
content: 'original content',
originalContent: 'original content',
isDirty: false,
};
const updated = updateTabContent(tab, 'modified content');
expect(updated.isDirty).toBe(true);
expect(updated.content).toBe('modified content');
expect(updated.originalContent).toBe('original content');
});
it('should set isDirty to false when content matches originalContent', () => {
const tab = {
content: 'original content',
originalContent: 'original content',
isDirty: false,
};
// First modify it
let updated = updateTabContent(tab, 'modified content');
expect(updated.isDirty).toBe(true);
// Now update back to original
updated = updateTabContent(updated, 'original content');
expect(updated.isDirty).toBe(false);
});
it('should handle empty content correctly', () => {
const tab = {
content: '',
originalContent: '',
isDirty: false,
};
const updated = updateTabContent(tab, 'new content');
expect(updated.isDirty).toBe(true);
});
});
describe('markTabSaved', () => {
it('should set isDirty to false and update both content and originalContent', () => {
const tab = {
content: 'original content',
originalContent: 'original content',
isDirty: false,
};
// First modify
let updated = updateTabContent(tab, 'modified content');
expect(updated.isDirty).toBe(true);
// Then save
updated = markTabSaved(updated, 'modified content');
expect(updated.isDirty).toBe(false);
expect(updated.content).toBe('modified content');
expect(updated.originalContent).toBe('modified content');
});
it('should correctly clear dirty state when save is triggered after edit', () => {
// This test simulates the bug scenario:
// 1. User edits file -> isDirty = true
// 2. User saves -> markTabSaved should set isDirty = false
let tab = {
content: 'initial',
originalContent: 'initial',
isDirty: false,
};
// Simulate user editing
tab = updateTabContent(tab, 'initial\nnew line');
// Should be dirty
expect(tab.isDirty).toBe(true);
// Simulate save (with the content that was saved)
tab = markTabSaved(tab, 'initial\nnew line');
// Should NOT be dirty anymore
expect(tab.isDirty).toBe(false);
});
});
describe('race condition handling', () => {
it('should correctly handle updateTabContent after markTabSaved with same content', () => {
// This tests the scenario where:
// 1. CodeMirror has a pending onChange with content "B"
// 2. User presses save when editor shows "B"
// 3. markTabSaved is called with "B"
// 4. CodeMirror's pending onChange fires with "B" (same content)
// Result: isDirty should remain false
let tab = {
content: 'A',
originalContent: 'A',
isDirty: false,
};
// User edits to "B"
tab = updateTabContent(tab, 'B');
// Save with "B"
tab = markTabSaved(tab, 'B');
// Late onChange with same content "B"
tab = updateTabContent(tab, 'B');
expect(tab.isDirty).toBe(false);
expect(tab.content).toBe('B');
});
it('should correctly handle updateTabContent after markTabSaved with different content', () => {
// This tests the scenario where:
// 1. CodeMirror has a pending onChange with content "C"
// 2. User presses save when store has "B"
// 3. markTabSaved is called with "B"
// 4. CodeMirror's pending onChange fires with "C" (different content)
// Result: isDirty should be true (file changed after save)
let tab = {
content: 'A',
originalContent: 'A',
isDirty: false,
};
// User edits to "B"
tab = updateTabContent(tab, 'B');
// Save with "B"
tab = markTabSaved(tab, 'B');
// Late onChange with different content "C"
tab = updateTabContent(tab, 'C');
// File changed after save, so it should be dirty
expect(tab.isDirty).toBe(true);
expect(tab.content).toBe('C');
expect(tab.originalContent).toBe('B');
});
it('should handle rapid edit-save-edit cycle correctly', () => {
// Simulate rapid user actions
let tab = {
content: 'v1',
originalContent: 'v1',
isDirty: false,
};
// Edit 1
tab = updateTabContent(tab, 'v2');
expect(tab.isDirty).toBe(true);
// Save 1
tab = markTabSaved(tab, 'v2');
expect(tab.isDirty).toBe(false);
// Edit 2
tab = updateTabContent(tab, 'v3');
expect(tab.isDirty).toBe(true);
// Save 2
tab = markTabSaved(tab, 'v3');
expect(tab.isDirty).toBe(false);
// Edit 3 (back to v2)
tab = updateTabContent(tab, 'v2');
expect(tab.isDirty).toBe(true);
// Save 3
tab = markTabSaved(tab, 'v2');
expect(tab.isDirty).toBe(false);
});
});
describe('handleSave stale closure fix simulation', () => {
it('demonstrates the fix: using fresh content instead of closure content', () => {
// This test demonstrates why the fix was necessary.
// The old handleSave captured activeTab in closure, which could be stale.
// The fix gets fresh state from getState() and uses editor.getValue().
// Simulate store state
let storeState = {
tabs: [
{
id: 'tab-1',
content: 'A',
originalContent: 'A',
isDirty: false,
},
],
activeTabId: 'tab-1',
};
// Simulate a "stale closure" capturing the tab state
const staleClosureTab = storeState.tabs[0];
// User edits - store state updates
storeState = {
...storeState,
tabs: [
{
id: 'tab-1',
content: 'B',
originalContent: 'A',
isDirty: true,
},
],
};
// OLD BUG: Using stale closure tab would save "A" (old content)
const oldBugSavedContent = staleClosureTab!.content;
expect(oldBugSavedContent).toBe('A'); // Wrong! Should be "B"
// FIX: Using fresh state from getState() gets correct content
const freshTab = storeState.tabs[0];
const fixedSavedContent = freshTab!.content;
expect(fixedSavedContent).toBe('B'); // Correct!
});
it('demonstrates syncing editor content before save', () => {
// This test demonstrates why we need to get content from editor directly.
// The store might have stale content if onChange hasn't fired yet.
// Simulate store state (has old content because onChange hasn't fired)
let storeContent = 'A';
// Editor has newer content (not yet synced to store)
const editorContent = 'B';
// FIX: Use editor content if available, fall back to store content
const contentToSave = editorContent ?? storeContent;
expect(contentToSave).toBe('B'); // Correctly saves editor content
// Simulate syncing to store before save
if (editorContent !== null && editorContent !== storeContent) {
storeContent = editorContent;
}
// Now store is synced
expect(storeContent).toBe('B');
// After save, markTabSaved would set originalContent = savedContent
// and isDirty = false (if no more changes come in)
});
});
describe('edge cases', () => {
it('should handle whitespace-only changes as dirty', () => {
let tab = {
content: 'hello',
originalContent: 'hello',
isDirty: false,
};
tab = updateTabContent(tab, 'hello ');
expect(tab.isDirty).toBe(true);
});
it('should treat CRLF and LF line endings as equivalent (not dirty)', () => {
let tab = {
content: 'line1\nline2',
originalContent: 'line1\nline2',
isDirty: false,
};
// CodeMirror normalizes \r\n to \n internally, so content that only
// differs by line endings should NOT be considered dirty.
tab = updateTabContent(tab, 'line1\r\nline2');
expect(tab.isDirty).toBe(false);
});
it('should handle unicode content correctly', () => {
let tab = {
content: '你好世界',
originalContent: '你好世界',
isDirty: false,
};
tab = updateTabContent(tab, '你好宇宙');
expect(tab.isDirty).toBe(true);
tab = markTabSaved(tab, '你好宇宙');
expect(tab.isDirty).toBe(false);
});
it('should handle very large content efficiently', () => {
// Generate a large string (1MB)
const largeOriginal = 'x'.repeat(1024 * 1024);
const largeModified = largeOriginal + 'y';
let tab = {
content: largeOriginal,
originalContent: largeOriginal,
isDirty: false,
};
tab = updateTabContent(tab, largeModified);
expect(tab.isDirty).toBe(true);
});
});
});

View File

@@ -1,5 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getMCPServersFromSettings } from '@/lib/settings-helpers.js'; import {
getMCPServersFromSettings,
getProviderById,
getProviderByModelId,
resolveProviderContext,
getAllProviderModels,
} from '@/lib/settings-helpers.js';
import type { SettingsService } from '@/services/settings-service.js'; import type { SettingsService } from '@/services/settings-service.js';
// Mock the logger // Mock the logger
@@ -286,4 +292,691 @@ describe('settings-helpers.ts', () => {
}); });
}); });
}); });
describe('getProviderById', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return provider when found by ID', async () => {
const mockProvider = { id: 'zai-1', name: 'Zai', enabled: true };
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await getProviderById('zai-1', mockSettingsService);
expect(result.provider).toEqual(mockProvider);
});
it('should return undefined when provider not found', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await getProviderById('unknown', mockSettingsService);
expect(result.provider).toBeUndefined();
});
it('should return provider even if disabled (caller handles enabled state)', async () => {
const mockProvider = { id: 'disabled-1', name: 'Disabled', enabled: false };
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await getProviderById('disabled-1', mockSettingsService);
expect(result.provider).toEqual(mockProvider);
});
});
describe('getProviderByModelId', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return provider and modelConfig when found by model ID', async () => {
const mockModel = { id: 'custom-model-1', name: 'Custom Model' };
const mockProvider = {
id: 'provider-1',
name: 'Provider 1',
enabled: true,
models: [mockModel],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await getProviderByModelId('custom-model-1', mockSettingsService);
expect(result.provider).toEqual(mockProvider);
expect(result.modelConfig).toEqual(mockModel);
});
it('should resolve mapped Claude model when mapsToClaudeModel is present', async () => {
const mockModel = {
id: 'custom-model-1',
name: 'Custom Model',
mapsToClaudeModel: 'sonnet-3-5',
};
const mockProvider = {
id: 'provider-1',
name: 'Provider 1',
enabled: true,
models: [mockModel],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await getProviderByModelId('custom-model-1', mockSettingsService);
expect(result.resolvedModel).toBeDefined();
// resolveModelString('sonnet-3-5') usually returns 'claude-3-5-sonnet-20240620' or similar
});
it('should ignore disabled providers', async () => {
const mockModel = { id: 'custom-model-1', name: 'Custom Model' };
const mockProvider = {
id: 'disabled-1',
name: 'Disabled Provider',
enabled: false,
models: [mockModel],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await getProviderByModelId('custom-model-1', mockSettingsService);
expect(result.provider).toBeUndefined();
});
});
describe('resolveProviderContext', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should resolve provider by explicit providerId', async () => {
const mockProvider = {
id: 'provider-1',
name: 'Provider 1',
enabled: true,
models: [{ id: 'custom-model-1', name: 'Custom Model' }],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }),
} as unknown as SettingsService;
const result = await resolveProviderContext(
mockSettingsService,
'custom-model-1',
'provider-1'
);
expect(result.provider).toEqual(mockProvider);
expect(result.credentials).toEqual({ anthropicApiKey: 'test-key' });
});
it('should return undefined provider when explicit providerId not found', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await resolveProviderContext(
mockSettingsService,
'some-model',
'unknown-provider'
);
expect(result.provider).toBeUndefined();
});
it('should fallback to model-based lookup when providerId not provided', async () => {
const mockProvider = {
id: 'provider-1',
name: 'Provider 1',
enabled: true,
models: [{ id: 'custom-model-1', name: 'Custom Model' }],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await resolveProviderContext(mockSettingsService, 'custom-model-1');
expect(result.provider).toEqual(mockProvider);
expect(result.modelConfig?.id).toBe('custom-model-1');
});
it('should resolve mapsToClaudeModel to actual Claude model', async () => {
const mockProvider = {
id: 'provider-1',
name: 'Provider 1',
enabled: true,
models: [
{
id: 'custom-model-1',
name: 'Custom Model',
mapsToClaudeModel: 'sonnet',
},
],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await resolveProviderContext(mockSettingsService, 'custom-model-1');
// resolveModelString('sonnet') should return a valid Claude model ID
expect(result.resolvedModel).toBeDefined();
expect(result.resolvedModel).toContain('claude');
});
it('should handle empty providers list', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await resolveProviderContext(mockSettingsService, 'some-model');
expect(result.provider).toBeUndefined();
expect(result.resolvedModel).toBeUndefined();
expect(result.modelConfig).toBeUndefined();
});
it('should handle missing claudeCompatibleProviders field', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await resolveProviderContext(mockSettingsService, 'some-model');
expect(result.provider).toBeUndefined();
});
it('should skip disabled providers during fallback lookup', async () => {
const disabledProvider = {
id: 'disabled-1',
name: 'Disabled Provider',
enabled: false,
models: [{ id: 'model-in-disabled', name: 'Model' }],
};
const enabledProvider = {
id: 'enabled-1',
name: 'Enabled Provider',
enabled: true,
models: [{ id: 'model-in-enabled', name: 'Model' }],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [disabledProvider, enabledProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
// Should skip the disabled provider and find the model in the enabled one
const result = await resolveProviderContext(mockSettingsService, 'model-in-enabled');
expect(result.provider?.id).toBe('enabled-1');
// Should not find model that only exists in disabled provider
const result2 = await resolveProviderContext(mockSettingsService, 'model-in-disabled');
expect(result2.provider).toBeUndefined();
});
it('should perform case-insensitive model ID matching', async () => {
const mockProvider = {
id: 'provider-1',
name: 'Provider 1',
enabled: true,
models: [{ id: 'Custom-Model-1', name: 'Custom Model' }],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await resolveProviderContext(mockSettingsService, 'custom-model-1');
expect(result.provider).toEqual(mockProvider);
expect(result.modelConfig?.id).toBe('Custom-Model-1');
});
it('should return error result on exception', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await resolveProviderContext(mockSettingsService, 'some-model');
expect(result.provider).toBeUndefined();
expect(result.credentials).toBeUndefined();
expect(result.resolvedModel).toBeUndefined();
expect(result.modelConfig).toBeUndefined();
});
it('should persist and load provider config from server settings', async () => {
// This test verifies the main bug fix: providers are loaded from server settings
const savedProvider = {
id: 'saved-provider-1',
name: 'Saved Provider',
enabled: true,
apiKeySource: 'credentials' as const,
models: [
{
id: 'saved-model-1',
name: 'Saved Model',
mapsToClaudeModel: 'sonnet',
},
],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [savedProvider],
}),
getCredentials: vi.fn().mockResolvedValue({
anthropicApiKey: 'saved-api-key',
}),
} as unknown as SettingsService;
// Simulate loading saved provider config
const result = await resolveProviderContext(
mockSettingsService,
'saved-model-1',
'saved-provider-1'
);
// Verify the provider is loaded from server settings
expect(result.provider).toEqual(savedProvider);
expect(result.provider?.id).toBe('saved-provider-1');
expect(result.provider?.models).toHaveLength(1);
expect(result.credentials?.anthropicApiKey).toBe('saved-api-key');
// Verify model mapping is resolved
expect(result.resolvedModel).toContain('claude');
});
it('should accept custom logPrefix parameter', async () => {
// Verify that the logPrefix parameter is accepted (used by facade.ts)
const mockProvider = {
id: 'provider-1',
name: 'Provider 1',
enabled: true,
models: [{ id: 'model-1', name: 'Model' }],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
// Call with custom logPrefix (as facade.ts does)
const result = await resolveProviderContext(
mockSettingsService,
'model-1',
undefined,
'[CustomPrefix]'
);
// Function should work the same with custom prefix
expect(result.provider).toEqual(mockProvider);
});
// Session restore scenarios - provider.enabled: undefined should be treated as enabled
describe('session restore scenarios (enabled: undefined)', () => {
it('should treat provider with enabled: undefined as enabled', async () => {
// This is the main bug fix: when providers are loaded from settings on session restore,
// enabled might be undefined (not explicitly set) and should be treated as enabled
const mockProvider = {
id: 'provider-1',
name: 'Provider 1',
enabled: undefined, // Not explicitly set - should be treated as enabled
models: [{ id: 'model-1', name: 'Model' }],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await resolveProviderContext(mockSettingsService, 'model-1');
// Provider should be found and used even though enabled is undefined
expect(result.provider).toEqual(mockProvider);
expect(result.modelConfig?.id).toBe('model-1');
});
it('should use provider by ID when enabled is undefined', async () => {
// This tests the explicit providerId lookup with undefined enabled
const mockProvider = {
id: 'provider-1',
name: 'Provider 1',
enabled: undefined, // Not explicitly set - should be treated as enabled
models: [{ id: 'custom-model', name: 'Custom Model', mapsToClaudeModel: 'sonnet' }],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }),
} as unknown as SettingsService;
const result = await resolveProviderContext(
mockSettingsService,
'custom-model',
'provider-1'
);
// Provider should be found and used even though enabled is undefined
expect(result.provider).toEqual(mockProvider);
expect(result.credentials?.anthropicApiKey).toBe('test-key');
expect(result.resolvedModel).toContain('claude');
});
it('should find model via fallback in provider with enabled: undefined', async () => {
// Test fallback model lookup when provider has undefined enabled
const providerWithUndefinedEnabled = {
id: 'provider-1',
name: 'Provider 1',
// enabled is not set (undefined)
models: [{ id: 'model-1', name: 'Model' }],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [providerWithUndefinedEnabled],
}),
getCredentials: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await resolveProviderContext(mockSettingsService, 'model-1');
expect(result.provider).toEqual(providerWithUndefinedEnabled);
expect(result.modelConfig?.id).toBe('model-1');
});
it('should still use provider for connection when model not found in its models array', async () => {
// This tests the fix: when providerId is explicitly set and provider is found,
// but the model isn't in that provider's models array, we still use that provider
// for connection settings (baseUrl, credentials)
const mockProvider = {
id: 'provider-1',
name: 'Provider 1',
enabled: true,
baseUrl: 'https://custom-api.example.com',
models: [{ id: 'other-model', name: 'Other Model' }],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [mockProvider],
}),
getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }),
} as unknown as SettingsService;
const result = await resolveProviderContext(
mockSettingsService,
'unknown-model', // Model not in provider's models array
'provider-1'
);
// Provider should still be returned for connection settings
expect(result.provider).toEqual(mockProvider);
// modelConfig should be undefined since the model wasn't found
expect(result.modelConfig).toBeUndefined();
// resolvedModel should be undefined since no mapping was found
expect(result.resolvedModel).toBeUndefined();
});
it('should fallback to find modelConfig in other providers when not in explicit providerId provider', async () => {
// When providerId is set and provider is found, but model isn't there,
// we should still search for modelConfig in other providers
const provider1 = {
id: 'provider-1',
name: 'Provider 1',
enabled: true,
baseUrl: 'https://provider1.example.com',
models: [{ id: 'provider1-model', name: 'Provider 1 Model' }],
};
const provider2 = {
id: 'provider-2',
name: 'Provider 2',
enabled: true,
baseUrl: 'https://provider2.example.com',
models: [
{
id: 'shared-model',
name: 'Shared Model',
mapsToClaudeModel: 'sonnet',
},
],
};
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [provider1, provider2],
}),
getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }),
} as unknown as SettingsService;
const result = await resolveProviderContext(
mockSettingsService,
'shared-model', // This model is in provider-2, not provider-1
'provider-1' // But we explicitly want to use provider-1
);
// Provider should still be provider-1 (for connection settings)
expect(result.provider).toEqual(provider1);
// But modelConfig should be found from provider-2
expect(result.modelConfig?.id).toBe('shared-model');
// And the model mapping should be resolved
expect(result.resolvedModel).toContain('claude');
});
it('should handle multiple providers with mixed enabled states', async () => {
// Test the full session restore scenario with multiple providers
const providers = [
{
id: 'provider-1',
name: 'First Provider',
enabled: undefined, // Undefined after restore
models: [{ id: 'model-a', name: 'Model A' }],
},
{
id: 'provider-2',
name: 'Second Provider',
// enabled field missing entirely
models: [{ id: 'model-b', name: 'Model B', mapsToClaudeModel: 'opus' }],
},
{
id: 'provider-3',
name: 'Disabled Provider',
enabled: false, // Explicitly disabled
models: [{ id: 'model-c', name: 'Model C' }],
},
];
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: providers,
}),
getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }),
} as unknown as SettingsService;
// Provider 1 should work (enabled: undefined)
const result1 = await resolveProviderContext(mockSettingsService, 'model-a', 'provider-1');
expect(result1.provider?.id).toBe('provider-1');
expect(result1.modelConfig?.id).toBe('model-a');
// Provider 2 should work (enabled field missing)
const result2 = await resolveProviderContext(mockSettingsService, 'model-b', 'provider-2');
expect(result2.provider?.id).toBe('provider-2');
expect(result2.modelConfig?.id).toBe('model-b');
expect(result2.resolvedModel).toContain('claude');
// Provider 3 with explicit providerId IS returned even if disabled
// (caller handles enabled state check)
const result3 = await resolveProviderContext(mockSettingsService, 'model-c', 'provider-3');
// Provider is found but modelConfig won't be found since disabled providers
// skip model lookup in their models array
expect(result3.provider).toEqual(providers[2]);
expect(result3.modelConfig).toBeUndefined();
});
});
});
describe('getAllProviderModels', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return all models from enabled providers', async () => {
const mockProviders = [
{
id: 'provider-1',
name: 'Provider 1',
enabled: true,
models: [
{ id: 'model-1', name: 'Model 1' },
{ id: 'model-2', name: 'Model 2' },
],
},
{
id: 'provider-2',
name: 'Provider 2',
enabled: true,
models: [{ id: 'model-3', name: 'Model 3' }],
},
];
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: mockProviders,
}),
} as unknown as SettingsService;
const result = await getAllProviderModels(mockSettingsService);
expect(result).toHaveLength(3);
expect(result[0].providerId).toBe('provider-1');
expect(result[0].model.id).toBe('model-1');
expect(result[2].providerId).toBe('provider-2');
});
it('should filter out disabled providers', async () => {
const mockProviders = [
{
id: 'enabled-1',
name: 'Enabled Provider',
enabled: true,
models: [{ id: 'model-1', name: 'Model 1' }],
},
{
id: 'disabled-1',
name: 'Disabled Provider',
enabled: false,
models: [{ id: 'model-2', name: 'Model 2' }],
},
];
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: mockProviders,
}),
} as unknown as SettingsService;
const result = await getAllProviderModels(mockSettingsService);
expect(result).toHaveLength(1);
expect(result[0].providerId).toBe('enabled-1');
});
it('should return empty array when no providers configured', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: [],
}),
} as unknown as SettingsService;
const result = await getAllProviderModels(mockSettingsService);
expect(result).toEqual([]);
});
it('should handle missing claudeCompatibleProviders field', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await getAllProviderModels(mockSettingsService);
expect(result).toEqual([]);
});
it('should handle provider with no models', async () => {
const mockProviders = [
{
id: 'provider-1',
name: 'Provider 1',
enabled: true,
models: [],
},
{
id: 'provider-2',
name: 'Provider 2',
enabled: true,
// no models field
},
];
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
claudeCompatibleProviders: mockProviders,
}),
} as unknown as SettingsService;
const result = await getAllProviderModels(mockSettingsService);
expect(result).toEqual([]);
});
it('should return empty array on exception', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')),
} as unknown as SettingsService;
const result = await getAllProviderModels(mockSettingsService);
expect(result).toEqual([]);
});
});
}); });

View File

@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { normalizeThinkingLevelForModel } from '@automaker/types';
describe('normalizeThinkingLevelForModel', () => {
it('preserves explicitly selected none for Opus models', () => {
expect(normalizeThinkingLevelForModel('claude-opus', 'none')).toBe('none');
});
it('falls back to none when Opus receives an unsupported manual thinking level', () => {
expect(normalizeThinkingLevelForModel('claude-opus', 'medium')).toBe('none');
});
it('keeps adaptive for Opus when adaptive is selected', () => {
expect(normalizeThinkingLevelForModel('claude-opus', 'adaptive')).toBe('adaptive');
});
it('preserves supported manual levels for non-Opus models', () => {
expect(normalizeThinkingLevelForModel('claude-sonnet', 'high')).toBe('high');
});
});

View File

@@ -198,7 +198,7 @@ describe('claude-provider.ts', () => {
expect(typeof callArgs.prompt).not.toBe('string'); expect(typeof callArgs.prompt).not.toBe('string');
}); });
it('should use maxTurns default of 100', async () => { it('should use maxTurns default of 1000', async () => {
vi.mocked(sdk.query).mockReturnValue( vi.mocked(sdk.query).mockReturnValue(
(async function* () { (async function* () {
yield { type: 'text', text: 'test' }; yield { type: 'text', text: 'test' };
@@ -216,7 +216,7 @@ describe('claude-provider.ts', () => {
expect(sdk.query).toHaveBeenCalledWith({ expect(sdk.query).toHaveBeenCalledWith({
prompt: 'Test', prompt: 'Test',
options: expect.objectContaining({ options: expect.objectContaining({
maxTurns: 100, maxTurns: 1000,
}), }),
}); });
}); });

View File

@@ -15,6 +15,7 @@ import {
calculateReasoningTimeout, calculateReasoningTimeout,
REASONING_TIMEOUT_MULTIPLIERS, REASONING_TIMEOUT_MULTIPLIERS,
DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS,
validateBareModelId,
} from '@automaker/types'; } from '@automaker/types';
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY'; const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
@@ -170,6 +171,30 @@ describe('codex-provider.ts', () => {
expect(call.args).toContain('--json'); expect(call.args).toContain('--json');
}); });
it('uses exec resume when sdkSessionId is provided', async () => {
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
await collectAsyncGenerator(
provider.executeQuery({
prompt: 'Continue',
model: 'gpt-5.2',
cwd: '/tmp',
sdkSessionId: 'codex-session-123',
outputFormat: { type: 'json_schema', schema: { type: 'object', properties: {} } },
codexSettings: { additionalDirs: ['/extra/dir'] },
})
);
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
expect(call.args[0]).toBe('exec');
expect(call.args[1]).toBe('resume');
expect(call.args).toContain('codex-session-123');
expect(call.args).toContain('--json');
// Resume queries must not include --output-schema or --add-dir
expect(call.args).not.toContain('--output-schema');
expect(call.args).not.toContain('--add-dir');
});
it('overrides approval policy when MCP auto-approval is enabled', async () => { it('overrides approval policy when MCP auto-approval is enabled', async () => {
// Note: With full-permissions always on (--dangerously-bypass-approvals-and-sandbox), // Note: With full-permissions always on (--dangerously-bypass-approvals-and-sandbox),
// approval policy is bypassed, not configured via --config // approval policy is bypassed, not configured via --config
@@ -320,8 +345,10 @@ describe('codex-provider.ts', () => {
); );
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
// High reasoning effort should have 3x the default timeout (90000ms) // High reasoning effort should have 3x the CLI base timeout (120000ms)
expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.high); // CODEX_CLI_TIMEOUT_MS = 120000, multiplier for 'high' = 3.0 → 360000ms
const CODEX_CLI_TIMEOUT_MS = 120000;
expect(call.timeout).toBe(CODEX_CLI_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.high);
}); });
it('passes extended timeout for xhigh reasoning effort', async () => { it('passes extended timeout for xhigh reasoning effort', async () => {
@@ -357,8 +384,10 @@ describe('codex-provider.ts', () => {
); );
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
// No reasoning effort should use the default timeout // No reasoning effort should use the CLI base timeout (2 minutes)
expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS); // CODEX_CLI_TIMEOUT_MS = 120000ms, no multiplier applied
const CODEX_CLI_TIMEOUT_MS = 120000;
expect(call.timeout).toBe(CODEX_CLI_TIMEOUT_MS);
}); });
}); });
@@ -427,4 +456,19 @@ describe('codex-provider.ts', () => {
expect(calculateReasoningTimeout('xhigh')).toBe(120000); expect(calculateReasoningTimeout('xhigh')).toBe(120000);
}); });
}); });
describe('validateBareModelId integration', () => {
it('should allow codex- prefixed models for Codex provider with expectedProvider="codex"', () => {
expect(() => validateBareModelId('codex-gpt-4', 'CodexProvider', 'codex')).not.toThrow();
expect(() =>
validateBareModelId('codex-gpt-5.1-codex-max', 'CodexProvider', 'codex')
).not.toThrow();
});
it('should reject other provider prefixes for Codex provider', () => {
expect(() => validateBareModelId('cursor-gpt-4', 'CodexProvider', 'codex')).toThrow();
expect(() => validateBareModelId('gemini-2.5-flash', 'CodexProvider', 'codex')).toThrow();
expect(() => validateBareModelId('copilot-gpt-4', 'CodexProvider', 'codex')).toThrow();
});
});
}); });

View File

@@ -1,17 +1,35 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { CopilotProvider, CopilotErrorCode } from '@/providers/copilot-provider.js'; import { CopilotProvider, CopilotErrorCode } from '@/providers/copilot-provider.js';
import { collectAsyncGenerator } from '../../utils/helpers.js';
import { CopilotClient } from '@github/copilot-sdk';
const createSessionMock = vi.fn();
const resumeSessionMock = vi.fn();
function createMockSession(sessionId = 'test-session') {
let eventHandler: ((event: any) => void) | null = null;
return {
sessionId,
send: vi.fn().mockImplementation(async () => {
if (eventHandler) {
eventHandler({ type: 'assistant.message', data: { content: 'hello' } });
eventHandler({ type: 'session.idle' });
}
}),
destroy: vi.fn().mockResolvedValue(undefined),
on: vi.fn().mockImplementation((handler: (event: any) => void) => {
eventHandler = handler;
}),
};
}
// Mock the Copilot SDK // Mock the Copilot SDK
vi.mock('@github/copilot-sdk', () => ({ vi.mock('@github/copilot-sdk', () => ({
CopilotClient: vi.fn().mockImplementation(() => ({ CopilotClient: vi.fn().mockImplementation(() => ({
start: vi.fn().mockResolvedValue(undefined), start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined), stop: vi.fn().mockResolvedValue(undefined),
createSession: vi.fn().mockResolvedValue({ createSession: createSessionMock,
sessionId: 'test-session', resumeSession: resumeSessionMock,
send: vi.fn().mockResolvedValue(undefined),
destroy: vi.fn().mockResolvedValue(undefined),
on: vi.fn(),
}),
})), })),
})); }));
@@ -49,6 +67,16 @@ describe('copilot-provider.ts', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(CopilotClient).mockImplementation(function () {
return {
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
createSession: createSessionMock,
resumeSession: resumeSessionMock,
} as any;
});
createSessionMock.mockResolvedValue(createMockSession());
resumeSessionMock.mockResolvedValue(createMockSession('resumed-session'));
// Mock fs.existsSync for CLI path validation // Mock fs.existsSync for CLI path validation
vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.existsSync).mockReturnValue(true);
@@ -303,13 +331,15 @@ describe('copilot-provider.ts', () => {
}); });
}); });
it('should normalize tool.execution_end event', () => { it('should normalize tool.execution_complete event', () => {
const event = { const event = {
type: 'tool.execution_end', type: 'tool.execution_complete',
data: { data: {
toolName: 'read_file',
toolCallId: 'call-123', toolCallId: 'call-123',
result: 'file content', success: true,
result: {
content: 'file content',
},
}, },
}; };
@@ -329,23 +359,85 @@ describe('copilot-provider.ts', () => {
}); });
}); });
it('should handle tool.execution_end with error', () => { it('should handle tool.execution_complete with error', () => {
const event = { const event = {
type: 'tool.execution_end', type: 'tool.execution_complete',
data: { data: {
toolName: 'bash',
toolCallId: 'call-456', toolCallId: 'call-456',
error: 'Command failed', success: false,
error: {
message: 'Command failed',
},
}, },
}; };
const result = provider.normalizeEvent(event); const result = provider.normalizeEvent(event);
expect(result?.message?.content?.[0]).toMatchObject({ expect(result?.message?.content?.[0]).toMatchObject({
type: 'tool_result', type: 'tool_result',
tool_use_id: 'call-456',
content: '[ERROR] Command failed', content: '[ERROR] Command failed',
}); });
}); });
it('should handle tool.execution_complete with empty result', () => {
const event = {
type: 'tool.execution_complete',
data: {
toolCallId: 'call-789',
success: true,
result: {
content: '',
},
},
};
const result = provider.normalizeEvent(event);
expect(result?.message?.content?.[0]).toMatchObject({
type: 'tool_result',
tool_use_id: 'call-789',
content: '',
});
});
it('should handle tool.execution_complete with missing result', () => {
const event = {
type: 'tool.execution_complete',
data: {
toolCallId: 'call-999',
success: true,
// No result field
},
};
const result = provider.normalizeEvent(event);
expect(result?.message?.content?.[0]).toMatchObject({
type: 'tool_result',
tool_use_id: 'call-999',
content: '',
});
});
it('should handle tool.execution_complete with error code', () => {
const event = {
type: 'tool.execution_complete',
data: {
toolCallId: 'call-567',
success: false,
error: {
message: 'Permission denied',
code: 'EACCES',
},
},
};
const result = provider.normalizeEvent(event);
expect(result?.message?.content?.[0]).toMatchObject({
type: 'tool_result',
tool_use_id: 'call-567',
content: '[ERROR] Permission denied (EACCES)',
});
});
it('should normalize session.idle to success result', () => { it('should normalize session.idle to success result', () => {
const event = { type: 'session.idle' }; const event = { type: 'session.idle' };
@@ -369,6 +461,45 @@ describe('copilot-provider.ts', () => {
}); });
}); });
it('should use error code in fallback when session.error message is empty', () => {
const event = {
type: 'session.error',
data: { message: '', code: 'RATE_LIMIT_EXCEEDED' },
};
const result = provider.normalizeEvent(event);
expect(result).not.toBeNull();
expect(result!.type).toBe('error');
expect(result!.error).toContain('RATE_LIMIT_EXCEEDED');
expect(result!.error).not.toBe('Unknown error');
});
it('should return generic "Copilot agent error" fallback when both message and code are empty', () => {
const event = {
type: 'session.error',
data: { message: '', code: '' },
};
const result = provider.normalizeEvent(event);
expect(result).not.toBeNull();
expect(result!.type).toBe('error');
expect(result!.error).toBe('Copilot agent error');
// Must NOT be the old opaque 'Unknown error'
expect(result!.error).not.toBe('Unknown error');
});
it('should return generic "Copilot agent error" fallback when data has no code field', () => {
const event = {
type: 'session.error',
data: { message: '' },
};
const result = provider.normalizeEvent(event);
expect(result).not.toBeNull();
expect(result!.type).toBe('error');
expect(result!.error).toBe('Copilot agent error');
});
it('should return null for unknown event types', () => { it('should return null for unknown event types', () => {
const event = { type: 'unknown.event' }; const event = { type: 'unknown.event' };
@@ -514,4 +645,45 @@ describe('copilot-provider.ts', () => {
expect(todoInput.todos[0].status).toBe('completed'); expect(todoInput.todos[0].status).toBe('completed');
}); });
}); });
describe('executeQuery resume behavior', () => {
it('uses resumeSession when sdkSessionId is provided', async () => {
const results = await collectAsyncGenerator(
provider.executeQuery({
prompt: 'Hello',
model: 'claude-sonnet-4.6',
cwd: '/tmp/project',
sdkSessionId: 'session-123',
})
);
expect(resumeSessionMock).toHaveBeenCalledWith(
'session-123',
expect.objectContaining({ model: 'claude-sonnet-4.6', streaming: true })
);
expect(createSessionMock).not.toHaveBeenCalled();
expect(results.some((msg) => msg.session_id === 'resumed-session')).toBe(true);
});
it('falls back to createSession when resumeSession fails', async () => {
resumeSessionMock.mockRejectedValueOnce(new Error('session not found'));
createSessionMock.mockResolvedValueOnce(createMockSession('fresh-session'));
const results = await collectAsyncGenerator(
provider.executeQuery({
prompt: 'Hello',
model: 'claude-sonnet-4.6',
cwd: '/tmp/project',
sdkSessionId: 'stale-session',
})
);
expect(resumeSessionMock).toHaveBeenCalledWith(
'stale-session',
expect.objectContaining({ model: 'claude-sonnet-4.6', streaming: true })
);
expect(createSessionMock).toHaveBeenCalledTimes(1);
expect(results.some((msg) => msg.session_id === 'fresh-session')).toBe(true);
});
});
}); });

View File

@@ -0,0 +1,235 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { CursorProvider } from '@/providers/cursor-provider.js';
import { validateBareModelId } from '@automaker/types';
describe('cursor-provider.ts', () => {
describe('buildCliArgs', () => {
it('adds --resume when sdkSessionId is provided', () => {
const provider = Object.create(CursorProvider.prototype) as CursorProvider & {
cliPath?: string;
};
provider.cliPath = '/usr/local/bin/cursor-agent';
const args = provider.buildCliArgs({
prompt: 'Continue the task',
model: 'gpt-5',
cwd: '/tmp/project',
sdkSessionId: 'cursor-session-123',
});
const resumeIndex = args.indexOf('--resume');
expect(resumeIndex).toBeGreaterThan(-1);
expect(args[resumeIndex + 1]).toBe('cursor-session-123');
});
it('does not add --resume when sdkSessionId is omitted', () => {
const provider = Object.create(CursorProvider.prototype) as CursorProvider & {
cliPath?: string;
};
provider.cliPath = '/usr/local/bin/cursor-agent';
const args = provider.buildCliArgs({
prompt: 'Start a new task',
model: 'gpt-5',
cwd: '/tmp/project',
});
expect(args).not.toContain('--resume');
});
});
describe('normalizeEvent - result error handling', () => {
let provider: CursorProvider;
beforeEach(() => {
provider = Object.create(CursorProvider.prototype) as CursorProvider;
});
it('returns error message from resultEvent.error when is_error=true', () => {
const event = {
type: 'result',
is_error: true,
error: 'Rate limit exceeded',
result: '',
subtype: 'error',
duration_ms: 3000,
session_id: 'sess-123',
};
const msg = provider.normalizeEvent(event);
expect(msg).not.toBeNull();
expect(msg!.type).toBe('error');
expect(msg!.error).toBe('Rate limit exceeded');
});
it('falls back to resultEvent.result when error field is empty and is_error=true', () => {
const event = {
type: 'result',
is_error: true,
error: '',
result: 'Process terminated unexpectedly',
subtype: 'error',
duration_ms: 5000,
session_id: 'sess-456',
};
const msg = provider.normalizeEvent(event);
expect(msg).not.toBeNull();
expect(msg!.type).toBe('error');
expect(msg!.error).toBe('Process terminated unexpectedly');
});
it('builds diagnostic fallback when both error and result are empty and is_error=true', () => {
const event = {
type: 'result',
is_error: true,
error: '',
result: '',
subtype: 'error',
duration_ms: 5000,
session_id: 'sess-789',
};
const msg = provider.normalizeEvent(event);
expect(msg).not.toBeNull();
expect(msg!.type).toBe('error');
// Should contain diagnostic info rather than 'Unknown error'
expect(msg!.error).toContain('5000ms');
expect(msg!.error).toContain('sess-789');
expect(msg!.error).not.toBe('Unknown error');
});
it('preserves session_id in error message', () => {
const event = {
type: 'result',
is_error: true,
error: 'Timeout occurred',
result: '',
subtype: 'error',
duration_ms: 30000,
session_id: 'my-session-id',
};
const msg = provider.normalizeEvent(event);
expect(msg!.session_id).toBe('my-session-id');
});
it('uses "none" when session_id is missing from diagnostic fallback', () => {
const event = {
type: 'result',
is_error: true,
error: '',
result: '',
subtype: 'error',
duration_ms: 5000,
// session_id intentionally omitted
};
const msg = provider.normalizeEvent(event);
expect(msg).not.toBeNull();
expect(msg!.type).toBe('error');
expect(msg!.error).toContain('none');
expect(msg!.error).not.toContain('undefined');
});
it('returns success result when is_error=false', () => {
const event = {
type: 'result',
is_error: false,
error: '',
result: 'Completed successfully',
subtype: 'success',
duration_ms: 2000,
session_id: 'sess-ok',
};
const msg = provider.normalizeEvent(event);
expect(msg).not.toBeNull();
expect(msg!.type).toBe('result');
expect(msg!.subtype).toBe('success');
});
});
describe('Cursor Gemini models support', () => {
let provider: CursorProvider;
beforeEach(() => {
provider = Object.create(CursorProvider.prototype) as CursorProvider & {
cliPath?: string;
};
provider.cliPath = '/usr/local/bin/cursor-agent';
});
describe('buildCliArgs with Cursor Gemini models', () => {
it('should handle cursor-gemini-3-pro model', () => {
const args = provider.buildCliArgs({
prompt: 'Write a function',
model: 'gemini-3-pro', // Bare model ID after stripping cursor- prefix
cwd: '/tmp/project',
});
const modelIndex = args.indexOf('--model');
expect(modelIndex).toBeGreaterThan(-1);
expect(args[modelIndex + 1]).toBe('gemini-3-pro');
});
it('should handle cursor-gemini-3-flash model', () => {
const args = provider.buildCliArgs({
prompt: 'Quick task',
model: 'gemini-3-flash', // Bare model ID after stripping cursor- prefix
cwd: '/tmp/project',
});
const modelIndex = args.indexOf('--model');
expect(modelIndex).toBeGreaterThan(-1);
expect(args[modelIndex + 1]).toBe('gemini-3-flash');
});
it('should include --resume with Cursor Gemini models when sdkSessionId is provided', () => {
const args = provider.buildCliArgs({
prompt: 'Continue task',
model: 'gemini-3-pro',
cwd: '/tmp/project',
sdkSessionId: 'cursor-gemini-session-123',
});
const resumeIndex = args.indexOf('--resume');
expect(resumeIndex).toBeGreaterThan(-1);
expect(args[resumeIndex + 1]).toBe('cursor-gemini-session-123');
});
});
describe('validateBareModelId with Cursor Gemini models', () => {
it('should allow gemini- prefixed models for Cursor provider with expectedProvider="cursor"', () => {
// This is the key fix - Cursor Gemini models have bare IDs like "gemini-3-pro"
expect(() => validateBareModelId('gemini-3-pro', 'CursorProvider', 'cursor')).not.toThrow();
expect(() =>
validateBareModelId('gemini-3-flash', 'CursorProvider', 'cursor')
).not.toThrow();
});
it('should still reject other provider prefixes for Cursor provider', () => {
expect(() => validateBareModelId('codex-gpt-4', 'CursorProvider', 'cursor')).toThrow();
expect(() => validateBareModelId('copilot-gpt-4', 'CursorProvider', 'cursor')).toThrow();
expect(() => validateBareModelId('opencode-gpt-4', 'CursorProvider', 'cursor')).toThrow();
});
it('should accept cursor- prefixed models when expectedProvider is "cursor" (for double-prefix validation)', () => {
// Note: When expectedProvider="cursor", we skip the cursor- prefix check
// This is intentional because the validation happens AFTER prefix stripping
// So if cursor-gemini-3-pro reaches validateBareModelId with expectedProvider="cursor",
// it means the prefix was NOT properly stripped, but we skip it anyway
// since we're checking if the Cursor provider itself can receive cursor- prefixed models
expect(() =>
validateBareModelId('cursor-gemini-3-pro', 'CursorProvider', 'cursor')
).not.toThrow();
});
});
});
});

View File

@@ -0,0 +1,272 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { GeminiProvider } from '@/providers/gemini-provider.js';
import type { ProviderMessage } from '@automaker/types';
import { validateBareModelId } from '@automaker/types';
describe('gemini-provider.ts', () => {
let provider: GeminiProvider;
beforeEach(() => {
provider = new GeminiProvider();
});
describe('buildCliArgs', () => {
it('should include --prompt with empty string to force headless mode', () => {
const args = provider.buildCliArgs({
prompt: 'Hello from Gemini',
model: '2.5-flash',
cwd: '/tmp/project',
});
const promptIndex = args.indexOf('--prompt');
expect(promptIndex).toBeGreaterThan(-1);
expect(args[promptIndex + 1]).toBe('');
});
it('should include --resume when sdkSessionId is provided', () => {
const args = provider.buildCliArgs({
prompt: 'Hello',
model: '2.5-flash',
cwd: '/tmp/project',
sdkSessionId: 'gemini-session-123',
});
const resumeIndex = args.indexOf('--resume');
expect(resumeIndex).toBeGreaterThan(-1);
expect(args[resumeIndex + 1]).toBe('gemini-session-123');
});
it('should not include --resume when sdkSessionId is missing', () => {
const args = provider.buildCliArgs({
prompt: 'Hello',
model: '2.5-flash',
cwd: '/tmp/project',
});
expect(args).not.toContain('--resume');
});
it('should include --sandbox false for faster execution', () => {
const args = provider.buildCliArgs({
prompt: 'Hello',
model: '2.5-flash',
cwd: '/tmp/project',
});
const sandboxIndex = args.indexOf('--sandbox');
expect(sandboxIndex).toBeGreaterThan(-1);
expect(args[sandboxIndex + 1]).toBe('false');
});
it('should include --approval-mode yolo for non-interactive use', () => {
const args = provider.buildCliArgs({
prompt: 'Hello',
model: '2.5-flash',
cwd: '/tmp/project',
});
const approvalIndex = args.indexOf('--approval-mode');
expect(approvalIndex).toBeGreaterThan(-1);
expect(args[approvalIndex + 1]).toBe('yolo');
});
it('should include --output-format stream-json', () => {
const args = provider.buildCliArgs({
prompt: 'Hello',
model: '2.5-flash',
cwd: '/tmp/project',
});
const formatIndex = args.indexOf('--output-format');
expect(formatIndex).toBeGreaterThan(-1);
expect(args[formatIndex + 1]).toBe('stream-json');
});
it('should include --include-directories with cwd', () => {
const args = provider.buildCliArgs({
prompt: 'Hello',
model: '2.5-flash',
cwd: '/tmp/my-project',
});
const dirIndex = args.indexOf('--include-directories');
expect(dirIndex).toBeGreaterThan(-1);
expect(args[dirIndex + 1]).toBe('/tmp/my-project');
});
it('should add gemini- prefix to bare model names', () => {
const args = provider.buildCliArgs({
prompt: 'Hello',
model: '2.5-flash',
cwd: '/tmp/project',
});
const modelIndex = args.indexOf('--model');
expect(modelIndex).toBeGreaterThan(-1);
expect(args[modelIndex + 1]).toBe('gemini-2.5-flash');
});
it('should not double-prefix model names that already have gemini-', () => {
const args = provider.buildCliArgs({
prompt: 'Hello',
model: 'gemini-2.5-pro',
cwd: '/tmp/project',
});
const modelIndex = args.indexOf('--model');
expect(modelIndex).toBeGreaterThan(-1);
expect(args[modelIndex + 1]).toBe('gemini-2.5-pro');
});
});
describe('normalizeEvent - error handling', () => {
it('returns error from result event when status=error and error field is set', () => {
const event = {
type: 'result',
status: 'error',
error: 'Model overloaded',
session_id: 'sess-gemini-1',
stats: { duration_ms: 4000, total_tokens: 0 },
};
const msg = provider.normalizeEvent(event) as ProviderMessage;
expect(msg).not.toBeNull();
expect(msg.type).toBe('error');
expect(msg.error).toBe('Model overloaded');
expect(msg.session_id).toBe('sess-gemini-1');
});
it('builds diagnostic fallback when result event has status=error but empty error field', () => {
const event = {
type: 'result',
status: 'error',
error: '',
session_id: 'sess-gemini-2',
stats: { duration_ms: 7500, total_tokens: 0 },
};
const msg = provider.normalizeEvent(event) as ProviderMessage;
expect(msg).not.toBeNull();
expect(msg.type).toBe('error');
// Diagnostic info should be present instead of 'Unknown error'
expect(msg.error).toContain('7500ms');
expect(msg.error).toContain('sess-gemini-2');
expect(msg.error).not.toBe('Unknown error');
});
it('builds fallback with "unknown" duration when stats are missing', () => {
const event = {
type: 'result',
status: 'error',
error: '',
session_id: 'sess-gemini-nostats',
// no stats field
};
const msg = provider.normalizeEvent(event) as ProviderMessage;
expect(msg).not.toBeNull();
expect(msg.type).toBe('error');
expect(msg.error).toContain('unknown');
});
it('returns error from standalone error event with error field set', () => {
const event = {
type: 'error',
error: 'API key invalid',
session_id: 'sess-gemini-3',
};
const msg = provider.normalizeEvent(event) as ProviderMessage;
expect(msg).not.toBeNull();
expect(msg.type).toBe('error');
expect(msg.error).toBe('API key invalid');
});
it('builds diagnostic fallback when standalone error event has empty error field', () => {
const event = {
type: 'error',
error: '',
session_id: 'sess-gemini-empty',
};
const msg = provider.normalizeEvent(event) as ProviderMessage;
expect(msg).not.toBeNull();
expect(msg.type).toBe('error');
// Should include session_id, not just 'Unknown error'
expect(msg.error).toContain('sess-gemini-empty');
expect(msg.error).not.toBe('Unknown error');
});
it('builds fallback mentioning "none" when session_id is missing from error event', () => {
const event = {
type: 'error',
error: '',
// no session_id
};
const msg = provider.normalizeEvent(event) as ProviderMessage;
expect(msg).not.toBeNull();
expect(msg.type).toBe('error');
expect(msg.error).toContain('none');
});
it('uses consistent "Gemini agent failed" label for both result and error event fallbacks', () => {
const resultEvent = {
type: 'result',
status: 'error',
error: '',
session_id: 'sess-r',
stats: { duration_ms: 1000 },
};
const errorEvent = {
type: 'error',
error: '',
session_id: 'sess-e',
};
const resultMsg = provider.normalizeEvent(resultEvent) as ProviderMessage;
const errorMsg = provider.normalizeEvent(errorEvent) as ProviderMessage;
// Both fallback messages should use the same "Gemini agent failed" prefix
expect(resultMsg.error).toContain('Gemini agent failed');
expect(errorMsg.error).toContain('Gemini agent failed');
});
it('returns success result when result event has status=success', () => {
const event = {
type: 'result',
status: 'success',
error: '',
session_id: 'sess-gemini-ok',
stats: { duration_ms: 1200, total_tokens: 500 },
};
const msg = provider.normalizeEvent(event) as ProviderMessage;
expect(msg).not.toBeNull();
expect(msg.type).toBe('result');
expect(msg.subtype).toBe('success');
});
});
describe('validateBareModelId integration', () => {
it('should allow gemini- prefixed models for Gemini provider with expectedProvider="gemini"', () => {
expect(() =>
validateBareModelId('gemini-2.5-flash', 'GeminiProvider', 'gemini')
).not.toThrow();
expect(() => validateBareModelId('gemini-2.5-pro', 'GeminiProvider', 'gemini')).not.toThrow();
});
it('should reject other provider prefixes for Gemini provider', () => {
expect(() => validateBareModelId('cursor-gpt-4', 'GeminiProvider', 'gemini')).toThrow();
expect(() => validateBareModelId('codex-gpt-4', 'GeminiProvider', 'gemini')).toThrow();
expect(() => validateBareModelId('copilot-gpt-4', 'GeminiProvider', 'gemini')).toThrow();
});
});
});

View File

@@ -0,0 +1,270 @@
/**
* Tests for default fields applied to features created by parseAndCreateFeatures
*
* Verifies that auto-created features include planningMode: 'skip',
* requirePlanApproval: false, and dependencies: [].
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import path from 'path';
// Use vi.hoisted to create mock functions that can be referenced in vi.mock factories
const { mockMkdir, mockAtomicWriteJson, mockExtractJsonWithArray, mockCreateNotification } =
vi.hoisted(() => ({
mockMkdir: vi.fn().mockResolvedValue(undefined),
mockAtomicWriteJson: vi.fn().mockResolvedValue(undefined),
mockExtractJsonWithArray: vi.fn(),
mockCreateNotification: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('@/lib/secure-fs.js', () => ({
mkdir: mockMkdir,
}));
vi.mock('@automaker/utils', () => ({
createLogger: vi.fn().mockReturnValue({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
atomicWriteJson: mockAtomicWriteJson,
DEFAULT_BACKUP_COUNT: 3,
}));
vi.mock('@automaker/platform', () => ({
getFeaturesDir: vi.fn((projectPath: string) => path.join(projectPath, '.automaker', 'features')),
}));
vi.mock('@/lib/json-extractor.js', () => ({
extractJsonWithArray: mockExtractJsonWithArray,
}));
vi.mock('@/services/notification-service.js', () => ({
getNotificationService: vi.fn(() => ({
createNotification: mockCreateNotification,
})),
}));
// Import after mocks are set up
import { parseAndCreateFeatures } from '../../../../src/routes/app-spec/parse-and-create-features.js';
describe('parseAndCreateFeatures - default fields', () => {
const mockEvents = {
emit: vi.fn(),
} as any;
const projectPath = '/test/project';
beforeEach(() => {
vi.clearAllMocks();
});
it('should set planningMode to "skip" on created features', async () => {
mockExtractJsonWithArray.mockReturnValue({
features: [
{
id: 'feature-1',
title: 'Test Feature',
description: 'A test feature',
priority: 1,
complexity: 'simple',
},
],
});
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
expect(mockAtomicWriteJson).toHaveBeenCalledTimes(1);
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
expect(writtenData.planningMode).toBe('skip');
});
it('should set requirePlanApproval to false on created features', async () => {
mockExtractJsonWithArray.mockReturnValue({
features: [
{
id: 'feature-1',
title: 'Test Feature',
description: 'A test feature',
},
],
});
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
expect(writtenData.requirePlanApproval).toBe(false);
});
it('should set dependencies to empty array when not provided', async () => {
mockExtractJsonWithArray.mockReturnValue({
features: [
{
id: 'feature-1',
title: 'Test Feature',
description: 'A test feature',
},
],
});
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
expect(writtenData.dependencies).toEqual([]);
});
it('should preserve dependencies when provided by the parser', async () => {
mockExtractJsonWithArray.mockReturnValue({
features: [
{
id: 'feature-1',
title: 'Test Feature',
description: 'A test feature',
dependencies: ['feature-0'],
},
],
});
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
expect(writtenData.dependencies).toEqual(['feature-0']);
});
it('should apply all default fields consistently across multiple features', async () => {
mockExtractJsonWithArray.mockReturnValue({
features: [
{
id: 'feature-1',
title: 'Feature 1',
description: 'First feature',
},
{
id: 'feature-2',
title: 'Feature 2',
description: 'Second feature',
dependencies: ['feature-1'],
},
{
id: 'feature-3',
title: 'Feature 3',
description: 'Third feature',
},
],
});
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
expect(mockAtomicWriteJson).toHaveBeenCalledTimes(3);
for (let i = 0; i < 3; i++) {
const writtenData = mockAtomicWriteJson.mock.calls[i][1];
expect(writtenData.planningMode, `feature ${i + 1} planningMode`).toBe('skip');
expect(writtenData.requirePlanApproval, `feature ${i + 1} requirePlanApproval`).toBe(false);
expect(Array.isArray(writtenData.dependencies), `feature ${i + 1} dependencies`).toBe(true);
}
// Feature 2 should have its explicit dependency preserved
expect(mockAtomicWriteJson.mock.calls[1][1].dependencies).toEqual(['feature-1']);
// Features 1 and 3 should have empty arrays
expect(mockAtomicWriteJson.mock.calls[0][1].dependencies).toEqual([]);
expect(mockAtomicWriteJson.mock.calls[2][1].dependencies).toEqual([]);
});
it('should set status to "backlog" on all created features', async () => {
mockExtractJsonWithArray.mockReturnValue({
features: [
{
id: 'feature-1',
title: 'Test Feature',
description: 'A test feature',
},
],
});
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
expect(writtenData.status).toBe('backlog');
});
it('should include createdAt and updatedAt timestamps', async () => {
mockExtractJsonWithArray.mockReturnValue({
features: [
{
id: 'feature-1',
title: 'Test Feature',
description: 'A test feature',
},
],
});
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
expect(writtenData.createdAt).toBeDefined();
expect(writtenData.updatedAt).toBeDefined();
// Should be valid ISO date strings
expect(new Date(writtenData.createdAt).toISOString()).toBe(writtenData.createdAt);
expect(new Date(writtenData.updatedAt).toISOString()).toBe(writtenData.updatedAt);
});
it('should use default values for optional fields not provided', async () => {
mockExtractJsonWithArray.mockReturnValue({
features: [
{
id: 'feature-minimal',
title: 'Minimal Feature',
description: 'Only required fields',
},
],
});
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
expect(writtenData.category).toBe('Uncategorized');
expect(writtenData.priority).toBe(2);
expect(writtenData.complexity).toBe('moderate');
expect(writtenData.dependencies).toEqual([]);
expect(writtenData.planningMode).toBe('skip');
expect(writtenData.requirePlanApproval).toBe(false);
});
it('should emit success event after creating features', async () => {
mockExtractJsonWithArray.mockReturnValue({
features: [
{
id: 'feature-1',
title: 'Feature 1',
description: 'First',
},
],
});
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
expect(mockEvents.emit).toHaveBeenCalledWith(
'spec-regeneration:event',
expect.objectContaining({
type: 'spec_regeneration_complete',
projectPath,
})
);
});
it('should emit error event when no valid JSON is found', async () => {
mockExtractJsonWithArray.mockReturnValue(null);
await parseAndCreateFeatures(projectPath, 'invalid content', mockEvents);
expect(mockEvents.emit).toHaveBeenCalledWith(
'spec-regeneration:event',
expect.objectContaining({
type: 'spec_regeneration_error',
projectPath,
})
);
});
});

View File

@@ -0,0 +1,149 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { mockGetAll, mockCreate, mockUpdate, mockDelete, mockClearBacklogPlan } = vi.hoisted(() => ({
mockGetAll: vi.fn(),
mockCreate: vi.fn(),
mockUpdate: vi.fn(),
mockDelete: vi.fn(),
mockClearBacklogPlan: vi.fn(),
}));
vi.mock('@/services/feature-loader.js', () => ({
FeatureLoader: class {
getAll = mockGetAll;
create = mockCreate;
update = mockUpdate;
delete = mockDelete;
},
}));
vi.mock('@/routes/backlog-plan/common.js', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
clearBacklogPlan: mockClearBacklogPlan,
getErrorMessage: (error: unknown) => (error instanceof Error ? error.message : String(error)),
logError: vi.fn(),
}));
import { createApplyHandler } from '@/routes/backlog-plan/routes/apply.js';
function createMockRes() {
const res: {
status: ReturnType<typeof vi.fn>;
json: ReturnType<typeof vi.fn>;
} = {
status: vi.fn(),
json: vi.fn(),
};
res.status.mockReturnValue(res);
return res;
}
describe('createApplyHandler', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetAll.mockResolvedValue([]);
mockCreate.mockResolvedValue({ id: 'feature-created' });
mockUpdate.mockResolvedValue({});
mockDelete.mockResolvedValue(true);
mockClearBacklogPlan.mockResolvedValue(undefined);
});
it('applies default feature model and planning settings when backlog plan additions omit them', async () => {
const settingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
defaultFeatureModel: { model: 'codex-gpt-5.2-codex', reasoningEffort: 'high' },
defaultPlanningMode: 'spec',
defaultRequirePlanApproval: true,
}),
getProjectSettings: vi.fn().mockResolvedValue({}),
} as any;
const req = {
body: {
projectPath: '/tmp/project',
plan: {
changes: [
{
type: 'add',
feature: {
id: 'feature-from-plan',
title: 'Created from plan',
description: 'desc',
},
},
],
},
},
} as any;
const res = createMockRes();
await createApplyHandler(settingsService)(req, res as any);
expect(mockCreate).toHaveBeenCalledWith(
'/tmp/project',
expect.objectContaining({
model: 'codex-gpt-5.2-codex',
reasoningEffort: 'high',
planningMode: 'spec',
requirePlanApproval: true,
})
);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
success: true,
})
);
});
it('uses project default feature model override and enforces no approval for skip mode', async () => {
const settingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
defaultFeatureModel: { model: 'claude-opus' },
defaultPlanningMode: 'skip',
defaultRequirePlanApproval: true,
}),
getProjectSettings: vi.fn().mockResolvedValue({
defaultFeatureModel: {
model: 'GLM-4.7',
providerId: 'provider-glm',
thinkingLevel: 'adaptive',
},
}),
} as any;
const req = {
body: {
projectPath: '/tmp/project',
plan: {
changes: [
{
type: 'add',
feature: {
id: 'feature-from-plan',
title: 'Created from plan',
},
},
],
},
},
} as any;
const res = createMockRes();
await createApplyHandler(settingsService)(req, res as any);
expect(mockCreate).toHaveBeenCalledWith(
'/tmp/project',
expect.objectContaining({
model: 'GLM-4.7',
providerId: 'provider-glm',
thinkingLevel: 'adaptive',
planningMode: 'skip',
requirePlanApproval: false,
})
);
});
});

View File

@@ -0,0 +1,218 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { BacklogPlanResult, ProviderMessage } from '@automaker/types';
const {
mockGetAll,
mockExecuteQuery,
mockSaveBacklogPlan,
mockSetRunningState,
mockSetRunningDetails,
mockGetPromptCustomization,
mockGetAutoLoadClaudeMdSetting,
mockGetUseClaudeCodeSystemPromptSetting,
} = vi.hoisted(() => ({
mockGetAll: vi.fn(),
mockExecuteQuery: vi.fn(),
mockSaveBacklogPlan: vi.fn(),
mockSetRunningState: vi.fn(),
mockSetRunningDetails: vi.fn(),
mockGetPromptCustomization: vi.fn(),
mockGetAutoLoadClaudeMdSetting: vi.fn(),
mockGetUseClaudeCodeSystemPromptSetting: vi.fn(),
}));
vi.mock('@/services/feature-loader.js', () => ({
FeatureLoader: class {
getAll = mockGetAll;
},
}));
vi.mock('@/providers/provider-factory.js', () => ({
ProviderFactory: {
getProviderForModel: vi.fn(() => ({
executeQuery: mockExecuteQuery,
})),
},
}));
vi.mock('@/routes/backlog-plan/common.js', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
setRunningState: mockSetRunningState,
setRunningDetails: mockSetRunningDetails,
getErrorMessage: (error: unknown) => (error instanceof Error ? error.message : String(error)),
saveBacklogPlan: mockSaveBacklogPlan,
}));
vi.mock('@/lib/settings-helpers.js', () => ({
getPromptCustomization: mockGetPromptCustomization,
getAutoLoadClaudeMdSetting: mockGetAutoLoadClaudeMdSetting,
getUseClaudeCodeSystemPromptSetting: mockGetUseClaudeCodeSystemPromptSetting,
getPhaseModelWithOverrides: vi.fn(),
}));
import { generateBacklogPlan } from '@/routes/backlog-plan/generate-plan.js';
function createMockEvents() {
return {
emit: vi.fn(),
};
}
describe('generateBacklogPlan', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetAll.mockResolvedValue([]);
mockGetPromptCustomization.mockResolvedValue({
backlogPlan: {
systemPrompt: 'System instructions',
userPromptTemplate:
'Current features:\n{{currentFeatures}}\n\nUser request:\n{{userRequest}}',
},
});
mockGetAutoLoadClaudeMdSetting.mockResolvedValue(false);
mockGetUseClaudeCodeSystemPromptSetting.mockResolvedValue(true);
});
it('salvages valid streamed JSON when Claude process exits with code 1', async () => {
const partialResult: BacklogPlanResult = {
changes: [
{
type: 'add',
feature: {
title: 'Add signup form',
description: 'Create signup UI and validation',
category: 'frontend',
},
reason: 'Required for user onboarding',
},
],
summary: 'Adds signup feature to the backlog',
dependencyUpdates: [],
};
const responseJson = JSON.stringify(partialResult);
async function* streamWithExitError(): AsyncGenerator<ProviderMessage> {
yield {
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'text', text: responseJson }],
},
};
throw new Error('Claude Code process exited with code 1');
}
mockExecuteQuery.mockReturnValueOnce(streamWithExitError());
const events = createMockEvents();
const abortController = new AbortController();
const result = await generateBacklogPlan(
'/tmp/project',
'Please add a signup feature',
events as any,
abortController,
undefined,
'claude-opus'
);
expect(mockExecuteQuery).toHaveBeenCalledTimes(1);
expect(result).toEqual(partialResult);
expect(mockSaveBacklogPlan).toHaveBeenCalledWith(
'/tmp/project',
expect.objectContaining({
prompt: 'Please add a signup feature',
model: 'claude-opus-4-6',
result: partialResult,
})
);
expect(events.emit).toHaveBeenCalledWith('backlog-plan:event', {
type: 'backlog_plan_complete',
result: partialResult,
});
expect(mockSetRunningState).toHaveBeenCalledWith(false, null);
expect(mockSetRunningDetails).toHaveBeenCalledWith(null);
});
it('prefers parseable provider result over longer non-JSON accumulated text on exit', async () => {
const recoveredResult: BacklogPlanResult = {
changes: [
{
type: 'add',
feature: {
title: 'Add reset password flow',
description: 'Implement reset password request and token validation UI',
category: 'frontend',
},
reason: 'Supports account recovery',
},
],
summary: 'Adds password reset capability',
dependencyUpdates: [],
};
const validProviderResult = JSON.stringify(recoveredResult);
const invalidAccumulatedText = `${validProviderResult}\n\nAdditional commentary that breaks raw JSON parsing.`;
async function* streamWithResultThenExit(): AsyncGenerator<ProviderMessage> {
yield {
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'text', text: invalidAccumulatedText }],
},
};
yield {
type: 'result',
subtype: 'success',
duration_ms: 10,
duration_api_ms: 10,
is_error: false,
num_turns: 1,
result: validProviderResult,
session_id: 'session-1',
total_cost_usd: 0,
usage: {
input_tokens: 10,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
output_tokens: 10,
server_tool_use: {
web_search_requests: 0,
},
service_tier: 'standard',
},
};
throw new Error('Claude Code process exited with code 1');
}
mockExecuteQuery.mockReturnValueOnce(streamWithResultThenExit());
const events = createMockEvents();
const abortController = new AbortController();
const result = await generateBacklogPlan(
'/tmp/project',
'Add password reset support',
events as any,
abortController,
undefined,
'claude-opus'
);
expect(result).toEqual(recoveredResult);
expect(mockSaveBacklogPlan).toHaveBeenCalledWith(
'/tmp/project',
expect.objectContaining({
result: recoveredResult,
})
);
});
});

View File

@@ -47,6 +47,8 @@ describe('running-agents routes', () => {
projectPath: '/home/user/project', projectPath: '/home/user/project',
projectName: 'project', projectName: 'project',
isAutoMode: true, isAutoMode: true,
model: 'claude-sonnet-4-20250514',
provider: 'claude',
title: 'Implement login feature', title: 'Implement login feature',
description: 'Add user authentication with OAuth', description: 'Add user authentication with OAuth',
}, },
@@ -55,6 +57,8 @@ describe('running-agents routes', () => {
projectPath: '/home/user/other-project', projectPath: '/home/user/other-project',
projectName: 'other-project', projectName: 'other-project',
isAutoMode: false, isAutoMode: false,
model: 'codex-gpt-5.1',
provider: 'codex',
title: 'Fix navigation bug', title: 'Fix navigation bug',
description: undefined, description: undefined,
}, },
@@ -82,6 +86,8 @@ describe('running-agents routes', () => {
projectPath: '/project', projectPath: '/project',
projectName: 'project', projectName: 'project',
isAutoMode: true, isAutoMode: true,
model: undefined,
provider: undefined,
title: undefined, title: undefined,
description: undefined, description: undefined,
}, },
@@ -141,6 +147,8 @@ describe('running-agents routes', () => {
projectPath: `/project-${i}`, projectPath: `/project-${i}`,
projectName: `project-${i}`, projectName: `project-${i}`,
isAutoMode: i % 2 === 0, isAutoMode: i % 2 === 0,
model: i % 3 === 0 ? 'claude-sonnet-4-20250514' : 'claude-haiku-4-5',
provider: 'claude',
title: `Feature ${i}`, title: `Feature ${i}`,
description: `Description ${i}`, description: `Description ${i}`,
})); }));
@@ -167,6 +175,8 @@ describe('running-agents routes', () => {
projectPath: '/workspace/project-alpha', projectPath: '/workspace/project-alpha',
projectName: 'project-alpha', projectName: 'project-alpha',
isAutoMode: true, isAutoMode: true,
model: 'claude-sonnet-4-20250514',
provider: 'claude',
title: 'Feature A', title: 'Feature A',
description: 'In project alpha', description: 'In project alpha',
}, },
@@ -175,6 +185,8 @@ describe('running-agents routes', () => {
projectPath: '/workspace/project-beta', projectPath: '/workspace/project-beta',
projectName: 'project-beta', projectName: 'project-beta',
isAutoMode: false, isAutoMode: false,
model: 'codex-gpt-5.1',
provider: 'codex',
title: 'Feature B', title: 'Feature B',
description: 'In project beta', description: 'In project beta',
}, },
@@ -191,5 +203,56 @@ describe('running-agents routes', () => {
expect(response.runningAgents[0].projectPath).toBe('/workspace/project-alpha'); expect(response.runningAgents[0].projectPath).toBe('/workspace/project-alpha');
expect(response.runningAgents[1].projectPath).toBe('/workspace/project-beta'); expect(response.runningAgents[1].projectPath).toBe('/workspace/project-beta');
}); });
it('should include model and provider information for running agents', async () => {
// Arrange
const runningAgents = [
{
featureId: 'feature-claude',
projectPath: '/project',
projectName: 'project',
isAutoMode: true,
model: 'claude-sonnet-4-20250514',
provider: 'claude',
title: 'Claude Feature',
description: 'Using Claude model',
},
{
featureId: 'feature-codex',
projectPath: '/project',
projectName: 'project',
isAutoMode: false,
model: 'codex-gpt-5.1',
provider: 'codex',
title: 'Codex Feature',
description: 'Using Codex model',
},
{
featureId: 'feature-cursor',
projectPath: '/project',
projectName: 'project',
isAutoMode: false,
model: 'cursor-auto',
provider: 'cursor',
title: 'Cursor Feature',
description: 'Using Cursor model',
},
];
vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue(runningAgents);
// Act
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
await handler(req, res);
// Assert
const response = vi.mocked(res.json).mock.calls[0][0];
expect(response.runningAgents[0].model).toBe('claude-sonnet-4-20250514');
expect(response.runningAgents[0].provider).toBe('claude');
expect(response.runningAgents[1].model).toBe('codex-gpt-5.1');
expect(response.runningAgents[1].provider).toBe('codex');
expect(response.runningAgents[2].model).toBe('cursor-auto');
expect(response.runningAgents[2].provider).toBe('cursor');
});
}); });
}); });

View File

@@ -0,0 +1,930 @@
/**
* Tests for worktree list endpoint handling of detached HEAD state.
*
* When a worktree is in detached HEAD state (e.g., during a rebase),
* `git worktree list --porcelain` outputs "detached" instead of
* "branch refs/heads/...". Previously, these worktrees were silently
* dropped from the response because the parser required both path AND branch.
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { createMockExpressContext } from '../../../utils/mocks.js';
// Mock all external dependencies before importing the module under test
vi.mock('child_process', () => ({
exec: vi.fn(),
}));
vi.mock('@/lib/git.js', () => ({
execGitCommand: vi.fn(),
}));
vi.mock('@automaker/git-utils', () => ({
isGitRepo: vi.fn(async () => true),
}));
vi.mock('@automaker/utils', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}));
vi.mock('@automaker/types', () => ({
validatePRState: vi.fn((state: string) => state),
}));
vi.mock('@/lib/secure-fs.js', () => ({
access: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn(),
readdir: vi.fn().mockResolvedValue([]),
stat: vi.fn(),
}));
vi.mock('@/lib/worktree-metadata.js', () => ({
readAllWorktreeMetadata: vi.fn(async () => new Map()),
updateWorktreePRInfo: vi.fn(async () => undefined),
}));
vi.mock('@/routes/worktree/common.js', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
getErrorMessage: vi.fn((e: Error) => e?.message || 'Unknown error'),
logError: vi.fn(),
normalizePath: vi.fn((p: string) => p),
execEnv: {},
isGhCliAvailable: vi.fn().mockResolvedValue(false),
};
});
vi.mock('@/routes/github/routes/check-github-remote.js', () => ({
checkGitHubRemote: vi.fn().mockResolvedValue({ hasGitHubRemote: false }),
}));
import { createListHandler } from '@/routes/worktree/routes/list.js';
import * as secureFs from '@/lib/secure-fs.js';
import { execGitCommand } from '@/lib/git.js';
import { readAllWorktreeMetadata, updateWorktreePRInfo } from '@/lib/worktree-metadata.js';
import { isGitRepo } from '@automaker/git-utils';
import { isGhCliAvailable, normalizePath, getErrorMessage } from '@/routes/worktree/common.js';
import { checkGitHubRemote } from '@/routes/github/routes/check-github-remote.js';
/**
* Set up execGitCommand mock (list handler uses this via lib/git.js, not child_process.exec).
*/
function setupExecGitCommandMock(options: {
porcelainOutput: string;
projectBranch?: string;
gitDirs?: Record<string, string>;
worktreeBranches?: Record<string, string>;
}) {
const { porcelainOutput, projectBranch = 'main', gitDirs = {}, worktreeBranches = {} } = options;
vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => {
if (args[0] === 'worktree' && args[1] === 'list' && args[2] === '--porcelain') {
return porcelainOutput;
}
if (args[0] === 'branch' && args[1] === '--show-current') {
if (worktreeBranches[cwd] !== undefined) {
return worktreeBranches[cwd] + '\n';
}
return projectBranch + '\n';
}
if (args[0] === 'rev-parse' && args[1] === '--git-dir') {
if (cwd && gitDirs[cwd]) {
return gitDirs[cwd] + '\n';
}
throw new Error('not a git directory');
}
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref' && args[2] === 'HEAD') {
return 'HEAD\n';
}
if (args[0] === 'worktree' && args[1] === 'prune') {
return '';
}
if (args[0] === 'status' && args[1] === '--porcelain') {
return '';
}
if (args[0] === 'diff' && args[1] === '--name-only' && args[2] === '--diff-filter=U') {
return '';
}
return '';
});
}
describe('worktree list - detached HEAD handling', () => {
let req: Request;
let res: Response;
beforeEach(() => {
vi.clearAllMocks();
const context = createMockExpressContext();
req = context.req;
res = context.res;
// Re-establish mock implementations cleared by mockReset/clearAllMocks
vi.mocked(isGitRepo).mockResolvedValue(true);
vi.mocked(readAllWorktreeMetadata).mockResolvedValue(new Map());
vi.mocked(isGhCliAvailable).mockResolvedValue(false);
vi.mocked(checkGitHubRemote).mockResolvedValue({ hasGitHubRemote: false });
vi.mocked(normalizePath).mockImplementation((p: string) => p);
vi.mocked(getErrorMessage).mockImplementation(
(e: unknown) => (e as Error)?.message || 'Unknown error'
);
// Default: all paths exist
vi.mocked(secureFs.access).mockResolvedValue(undefined);
// Default: .worktrees directory doesn't exist (no scan via readdir)
vi.mocked(secureFs.readdir).mockRejectedValue(new Error('ENOENT'));
// Default: readFile fails
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
// Default execGitCommand so list handler gets valid porcelain/branch output (vitest clearMocks resets implementations)
setupExecGitCommandMock({
porcelainOutput: 'worktree /project\nbranch refs/heads/main\n\n',
projectBranch: 'main',
});
});
/**
* Helper: set up execGitCommand mock for the list handler.
* Worktree-specific behavior can be customized via the options parameter.
*/
function setupStandardExec(options: {
porcelainOutput: string;
projectBranch?: string;
/** Map of worktree path -> git-dir path */
gitDirs?: Record<string, string>;
/** Map of worktree cwd -> branch for `git branch --show-current` */
worktreeBranches?: Record<string, string>;
}) {
setupExecGitCommandMock(options);
}
/** Suppress .worktrees dir scan by making access throw for the .worktrees dir. */
function disableWorktreesScan() {
vi.mocked(secureFs.access).mockImplementation(async (p) => {
const pathStr = String(p);
// Block only the .worktrees dir access check in scanWorktreesDirectory
if (pathStr.endsWith('.worktrees') || pathStr.endsWith('.worktrees/')) {
throw new Error('ENOENT');
}
// All other paths exist
return undefined;
});
}
describe('porcelain parser', () => {
it('should include normal worktrees with branch lines', async () => {
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/feature-a',
'branch refs/heads/feature-a',
'',
].join('\n'),
});
disableWorktreesScan();
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
success: boolean;
worktrees: Array<{ branch: string; path: string; isMain: boolean; hasWorktree: boolean }>;
};
expect(response.success).toBe(true);
expect(response.worktrees).toHaveLength(2);
expect(response.worktrees[0]).toEqual(
expect.objectContaining({
path: '/project',
branch: 'main',
isMain: true,
hasWorktree: true,
})
);
expect(response.worktrees[1]).toEqual(
expect.objectContaining({
path: '/project/.worktrees/feature-a',
branch: 'feature-a',
isMain: false,
hasWorktree: true,
})
);
});
it('should include worktrees with detached HEAD and recover branch from rebase-merge state', async () => {
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/rebasing-wt',
'detached',
'',
].join('\n'),
gitDirs: {
'/project/.worktrees/rebasing-wt': '/project/.worktrees/rebasing-wt/.git',
},
});
disableWorktreesScan();
// rebase-merge/head-name returns the branch being rebased
vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('rebase-merge/head-name')) {
return 'refs/heads/feature/my-rebasing-branch\n' as any;
}
throw new Error('ENOENT');
});
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; path: string; isCurrent: boolean }>;
};
expect(response.worktrees).toHaveLength(2);
expect(response.worktrees[1]).toEqual(
expect.objectContaining({
path: '/project/.worktrees/rebasing-wt',
branch: 'feature/my-rebasing-branch',
isMain: false,
isCurrent: false,
hasWorktree: true,
})
);
});
it('should include worktrees with detached HEAD and recover branch from rebase-apply state', async () => {
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/apply-wt',
'detached',
'',
].join('\n'),
gitDirs: {
'/project/.worktrees/apply-wt': '/project/.worktrees/apply-wt/.git',
},
});
disableWorktreesScan();
// rebase-merge doesn't exist, but rebase-apply does
vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('rebase-apply/head-name')) {
return 'refs/heads/feature/apply-branch\n' as any;
}
throw new Error('ENOENT');
});
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; path: string }>;
};
const detachedWt = response.worktrees.find((w) => w.path === '/project/.worktrees/apply-wt');
expect(detachedWt).toBeDefined();
expect(detachedWt!.branch).toBe('feature/apply-branch');
});
it('should show merge conflict worktrees normally since merge does not detach HEAD', async () => {
// During a merge conflict, HEAD stays on the branch, so `git worktree list --porcelain`
// still outputs `branch refs/heads/...`. This test verifies merge conflicts don't
// trigger the detached HEAD recovery path.
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/merge-wt',
'branch refs/heads/feature/merge-branch',
'',
].join('\n'),
});
disableWorktreesScan();
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; path: string }>;
};
const mergeWt = response.worktrees.find((w) => w.path === '/project/.worktrees/merge-wt');
expect(mergeWt).toBeDefined();
expect(mergeWt!.branch).toBe('feature/merge-branch');
});
it('should fall back to (detached) when all branch recovery methods fail', async () => {
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/unknown-wt',
'detached',
'',
].join('\n'),
worktreeBranches: {
'/project/.worktrees/unknown-wt': '', // empty = no branch
},
});
disableWorktreesScan();
// All readFile calls fail (no gitDirs so rev-parse --git-dir will throw)
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; path: string }>;
};
const detachedWt = response.worktrees.find(
(w) => w.path === '/project/.worktrees/unknown-wt'
);
expect(detachedWt).toBeDefined();
expect(detachedWt!.branch).toBe('(detached)');
});
it('should not include detached worktree when directory does not exist on disk', async () => {
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/deleted-wt',
'detached',
'',
].join('\n'),
});
// The deleted worktree doesn't exist on disk
vi.mocked(secureFs.access).mockImplementation(async (p) => {
const pathStr = String(p);
if (pathStr.includes('deleted-wt')) {
throw new Error('ENOENT');
}
if (pathStr.endsWith('.worktrees') || pathStr.endsWith('.worktrees/')) {
throw new Error('ENOENT');
}
return undefined;
});
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; path: string }>;
};
// Only the main worktree should be present
expect(response.worktrees).toHaveLength(1);
expect(response.worktrees[0].path).toBe('/project');
});
it('should set isCurrent to false for detached worktrees even if recovered branch matches current branch', async () => {
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/rebasing-wt',
'detached',
'',
].join('\n'),
// currentBranch for project is 'feature/my-branch'
projectBranch: 'feature/my-branch',
gitDirs: {
'/project/.worktrees/rebasing-wt': '/project/.worktrees/rebasing-wt/.git',
},
});
disableWorktreesScan();
// Recovery returns the same branch as currentBranch
vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('rebase-merge/head-name')) {
return 'refs/heads/feature/my-branch\n' as any;
}
throw new Error('ENOENT');
});
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; isCurrent: boolean; path: string }>;
};
const detachedWt = response.worktrees.find(
(w) => w.path === '/project/.worktrees/rebasing-wt'
);
expect(detachedWt).toBeDefined();
// Detached worktrees should always have isCurrent=false
expect(detachedWt!.isCurrent).toBe(false);
});
it('should handle mixed normal and detached worktrees', async () => {
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/normal-wt',
'branch refs/heads/feature-normal',
'',
'worktree /project/.worktrees/rebasing-wt',
'detached',
'',
'worktree /project/.worktrees/another-normal',
'branch refs/heads/feature-other',
'',
].join('\n'),
gitDirs: {
'/project/.worktrees/rebasing-wt': '/project/.worktrees/rebasing-wt/.git',
},
});
disableWorktreesScan();
vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('rebase-merge/head-name')) {
return 'refs/heads/feature/rebasing\n' as any;
}
throw new Error('ENOENT');
});
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; path: string; isMain: boolean }>;
};
expect(response.worktrees).toHaveLength(4);
expect(response.worktrees[0]).toEqual(
expect.objectContaining({ path: '/project', branch: 'main', isMain: true })
);
expect(response.worktrees[1]).toEqual(
expect.objectContaining({
path: '/project/.worktrees/normal-wt',
branch: 'feature-normal',
isMain: false,
})
);
expect(response.worktrees[2]).toEqual(
expect.objectContaining({
path: '/project/.worktrees/rebasing-wt',
branch: 'feature/rebasing',
isMain: false,
})
);
expect(response.worktrees[3]).toEqual(
expect.objectContaining({
path: '/project/.worktrees/another-normal',
branch: 'feature-other',
isMain: false,
})
);
});
it('should correctly advance isFirst flag past detached worktrees', async () => {
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/detached-wt',
'detached',
'',
'worktree /project/.worktrees/normal-wt',
'branch refs/heads/feature-x',
'',
].join('\n'),
});
disableWorktreesScan();
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; isMain: boolean }>;
};
expect(response.worktrees).toHaveLength(3);
expect(response.worktrees[0].isMain).toBe(true); // main
expect(response.worktrees[1].isMain).toBe(false); // detached
expect(response.worktrees[2].isMain).toBe(false); // normal
});
it('should not add removed detached worktrees to removedWorktrees list', async () => {
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/gone-wt',
'detached',
'',
].join('\n'),
});
// The detached worktree doesn't exist on disk
vi.mocked(secureFs.access).mockImplementation(async (p) => {
const pathStr = String(p);
if (pathStr.includes('gone-wt')) {
throw new Error('ENOENT');
}
if (pathStr.endsWith('.worktrees') || pathStr.endsWith('.worktrees/')) {
throw new Error('ENOENT');
}
return undefined;
});
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string }>;
removedWorktrees?: Array<{ path: string; branch: string }>;
};
// Should not be in removed list since we don't know the branch
expect(response.removedWorktrees).toBeUndefined();
});
it('should strip refs/heads/ prefix from recovered branch name', async () => {
req.body = { projectPath: '/project' };
setupStandardExec({
porcelainOutput: [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/wt1',
'detached',
'',
].join('\n'),
gitDirs: {
'/project/.worktrees/wt1': '/project/.worktrees/wt1/.git',
},
});
disableWorktreesScan();
vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('rebase-merge/head-name')) {
return 'refs/heads/my-branch\n' as any;
}
throw new Error('ENOENT');
});
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; path: string }>;
};
const wt = response.worktrees.find((w) => w.path === '/project/.worktrees/wt1');
expect(wt).toBeDefined();
// Should be 'my-branch', not 'refs/heads/my-branch'
expect(wt!.branch).toBe('my-branch');
});
});
describe('scanWorktreesDirectory with detached HEAD recovery', () => {
it('should recover branch for discovered worktrees with detached HEAD', async () => {
req.body = { projectPath: '/project' };
vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => {
if (args[0] === 'worktree' && args[1] === 'list') {
return 'worktree /project\nbranch refs/heads/main\n\n';
}
if (args[0] === 'branch' && args[1] === '--show-current') {
return cwd === '/project' ? 'main\n' : '\n';
}
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') {
return 'HEAD\n';
}
if (args[0] === 'rev-parse' && args[1] === '--git-dir') {
return '/project/.worktrees/orphan-wt/.git\n';
}
return '';
});
// .worktrees directory exists and has an orphan worktree
vi.mocked(secureFs.access).mockResolvedValue(undefined);
vi.mocked(secureFs.readdir).mockResolvedValue([
{ name: 'orphan-wt', isDirectory: () => true, isFile: () => false } as any,
]);
vi.mocked(secureFs.stat).mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
} as any);
// readFile returns branch from rebase-merge/head-name
vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('rebase-merge/head-name')) {
return 'refs/heads/feature/orphan-branch\n' as any;
}
throw new Error('ENOENT');
});
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; path: string }>;
};
const orphanWt = response.worktrees.find((w) => w.path === '/project/.worktrees/orphan-wt');
expect(orphanWt).toBeDefined();
expect(orphanWt!.branch).toBe('feature/orphan-branch');
});
it('should skip discovered worktrees when all branch detection fails', async () => {
req.body = { projectPath: '/project' };
vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => {
if (args[0] === 'worktree' && args[1] === 'list') {
return 'worktree /project\nbranch refs/heads/main\n\n';
}
if (args[0] === 'branch' && args[1] === '--show-current') {
return cwd === '/project' ? 'main\n' : '\n';
}
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') {
return 'HEAD\n';
}
if (args[0] === 'rev-parse' && args[1] === '--git-dir') {
throw new Error('not a git dir');
}
return '';
});
vi.mocked(secureFs.access).mockResolvedValue(undefined);
vi.mocked(secureFs.readdir).mockResolvedValue([
{ name: 'broken-wt', isDirectory: () => true, isFile: () => false } as any,
]);
vi.mocked(secureFs.stat).mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
} as any);
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; path: string }>;
};
// Only main worktree should be present
expect(response.worktrees).toHaveLength(1);
expect(response.worktrees[0].branch).toBe('main');
});
});
describe('PR tracking precedence', () => {
it('should keep manually tracked PR from metadata when branch PR differs', async () => {
req.body = { projectPath: '/project', includeDetails: true };
vi.mocked(readAllWorktreeMetadata).mockResolvedValue(
new Map([
[
'feature-a',
{
branch: 'feature-a',
createdAt: '2026-01-01T00:00:00.000Z',
pr: {
number: 99,
url: 'https://github.com/org/repo/pull/99',
title: 'Manual override PR',
state: 'OPEN',
createdAt: '2026-01-01T00:00:00.000Z',
},
},
],
])
);
vi.mocked(isGhCliAvailable).mockResolvedValue(true);
vi.mocked(checkGitHubRemote).mockResolvedValue({
hasGitHubRemote: true,
owner: 'org',
repo: 'repo',
});
vi.mocked(secureFs.access).mockImplementation(async (p) => {
const pathStr = String(p);
if (
pathStr.includes('MERGE_HEAD') ||
pathStr.includes('rebase-merge') ||
pathStr.includes('rebase-apply') ||
pathStr.includes('CHERRY_PICK_HEAD')
) {
throw new Error('ENOENT');
}
return undefined;
});
vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => {
if (args[0] === 'rev-parse' && args[1] === '--git-dir') {
throw new Error('no git dir');
}
if (args[0] === 'worktree' && args[1] === 'list') {
return [
'worktree /project',
'branch refs/heads/main',
'',
'worktree /project/.worktrees/feature-a',
'branch refs/heads/feature-a',
'',
].join('\n');
}
if (args[0] === 'branch' && args[1] === '--show-current') {
return cwd === '/project' ? 'main\n' : 'feature-a\n';
}
if (args[0] === 'status' && args[1] === '--porcelain') {
return '';
}
return '';
});
(exec as unknown as Mock).mockImplementation(
(
cmd: string,
_opts: unknown,
callback?: (err: Error | null, out: { stdout: string; stderr: string }) => void
) => {
const cb = typeof _opts === 'function' ? _opts : callback!;
if (cmd.includes('gh pr list')) {
cb(null, {
stdout: JSON.stringify([
{
number: 42,
title: 'Branch PR',
url: 'https://github.com/org/repo/pull/42',
state: 'OPEN',
headRefName: 'feature-a',
createdAt: '2026-01-02T00:00:00.000Z',
},
]),
stderr: '',
});
} else {
cb(null, { stdout: '', stderr: '' });
}
}
);
disableWorktreesScan();
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; pr?: { number: number; title: string } }>;
};
const featureWorktree = response.worktrees.find((w) => w.branch === 'feature-a');
expect(featureWorktree?.pr?.number).toBe(99);
expect(featureWorktree?.pr?.title).toBe('Manual override PR');
});
it('should prefer GitHub PR when it matches metadata number and sync updated fields', async () => {
req.body = { projectPath: '/project-2', includeDetails: true };
vi.mocked(readAllWorktreeMetadata).mockResolvedValue(
new Map([
[
'feature-a',
{
branch: 'feature-a',
createdAt: '2026-01-01T00:00:00.000Z',
pr: {
number: 42,
url: 'https://github.com/org/repo/pull/42',
title: 'Old title',
state: 'OPEN',
createdAt: '2026-01-01T00:00:00.000Z',
},
},
],
])
);
vi.mocked(isGhCliAvailable).mockResolvedValue(true);
vi.mocked(checkGitHubRemote).mockResolvedValue({
hasGitHubRemote: true,
owner: 'org',
repo: 'repo',
});
vi.mocked(secureFs.access).mockImplementation(async (p) => {
const pathStr = String(p);
if (
pathStr.includes('MERGE_HEAD') ||
pathStr.includes('rebase-merge') ||
pathStr.includes('rebase-apply') ||
pathStr.includes('CHERRY_PICK_HEAD')
) {
throw new Error('ENOENT');
}
return undefined;
});
vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => {
if (args[0] === 'rev-parse' && args[1] === '--git-dir') {
throw new Error('no git dir');
}
if (args[0] === 'worktree' && args[1] === 'list') {
return [
'worktree /project-2',
'branch refs/heads/main',
'',
'worktree /project-2/.worktrees/feature-a',
'branch refs/heads/feature-a',
'',
].join('\n');
}
if (args[0] === 'branch' && args[1] === '--show-current') {
return cwd === '/project-2' ? 'main\n' : 'feature-a\n';
}
if (args[0] === 'status' && args[1] === '--porcelain') {
return '';
}
return '';
});
(exec as unknown as Mock).mockImplementation(
(
cmd: string,
_opts: unknown,
callback?: (err: Error | null, out: { stdout: string; stderr: string }) => void
) => {
const cb = typeof _opts === 'function' ? _opts : callback!;
if (cmd.includes('gh pr list')) {
cb(null, {
stdout: JSON.stringify([
{
number: 42,
title: 'New title from GitHub',
url: 'https://github.com/org/repo/pull/42',
state: 'MERGED',
headRefName: 'feature-a',
createdAt: '2026-01-02T00:00:00.000Z',
},
]),
stderr: '',
});
} else {
cb(null, { stdout: '', stderr: '' });
}
}
);
disableWorktreesScan();
const handler = createListHandler();
await handler(req, res);
const response = vi.mocked(res.json).mock.calls[0][0] as {
worktrees: Array<{ branch: string; pr?: { number: number; title: string; state: string } }>;
};
const featureWorktree = response.worktrees.find((w) => w.branch === 'feature-a');
expect(featureWorktree?.pr?.number).toBe(42);
expect(featureWorktree?.pr?.title).toBe('New title from GitHub');
expect(featureWorktree?.pr?.state).toBe('MERGED');
expect(vi.mocked(updateWorktreePRInfo)).toHaveBeenCalledWith(
'/project-2',
'feature-a',
expect.objectContaining({
number: 42,
title: 'New title from GitHub',
state: 'MERGED',
})
);
});
});
});

View File

@@ -0,0 +1,446 @@
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { AgentExecutor } from '../../../src/services/agent-executor.js';
import type { TypedEventBus } from '../../../src/services/typed-event-bus.js';
import type { FeatureStateManager } from '../../../src/services/feature-state-manager.js';
import type { PlanApprovalService } from '../../../src/services/plan-approval-service.js';
import type { BaseProvider } from '../../../src/providers/base-provider.js';
import * as secureFs from '../../../src/lib/secure-fs.js';
import { getFeatureDir } from '@automaker/platform';
import { buildPromptWithImages } from '@automaker/utils';
vi.mock('../../../src/lib/secure-fs.js', () => ({
mkdir: vi.fn().mockResolvedValue(undefined),
writeFile: vi.fn().mockResolvedValue(undefined),
appendFile: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn().mockResolvedValue(''),
}));
vi.mock('@automaker/platform', () => ({
getFeatureDir: vi.fn(),
}));
vi.mock('@automaker/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@automaker/utils')>();
return {
...actual,
buildPromptWithImages: vi.fn(),
createLogger: vi.fn().mockReturnValue({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
};
});
describe('AgentExecutor Summary Extraction', () => {
let mockEventBus: TypedEventBus;
let mockFeatureStateManager: FeatureStateManager;
let mockPlanApprovalService: PlanApprovalService;
beforeEach(() => {
vi.clearAllMocks();
mockEventBus = {
emitAutoModeEvent: vi.fn(),
} as unknown as TypedEventBus;
mockFeatureStateManager = {
updateTaskStatus: vi.fn().mockResolvedValue(undefined),
updateFeaturePlanSpec: vi.fn().mockResolvedValue(undefined),
saveFeatureSummary: vi.fn().mockResolvedValue(undefined),
} as unknown as FeatureStateManager;
mockPlanApprovalService = {
waitForApproval: vi.fn(),
} as unknown as PlanApprovalService;
(getFeatureDir as Mock).mockReturnValue('/mock/feature/dir');
(buildPromptWithImages as Mock).mockResolvedValue({ content: 'mocked prompt' });
});
it('should extract summary from new session content only', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
null
);
const previousContent = `Some previous work.
<summary>Old summary</summary>`;
const newWork = `New implementation work.
<summary>New summary</summary>`;
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: newWork }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const options = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet',
planningMode: 'skip' as const,
previousContent,
};
const callbacks = {
waitForApproval: vi.fn(),
saveFeatureSummary: vi.fn(),
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn(),
};
await executor.execute(options, callbacks);
// Verify it called saveFeatureSummary with the NEW summary
expect(callbacks.saveFeatureSummary).toHaveBeenCalledWith(
'/project',
'test-feature',
'New summary'
);
// Ensure it didn't call it with Old summary
expect(callbacks.saveFeatureSummary).not.toHaveBeenCalledWith(
'/project',
'test-feature',
'Old summary'
);
});
it('should not save summary if no summary in NEW session content', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
null
);
const previousContent = `Some previous work.
<summary>Old summary</summary>`;
const newWork = `New implementation work without a summary tag.`;
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: newWork }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const options = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet',
planningMode: 'skip' as const,
previousContent,
};
const callbacks = {
waitForApproval: vi.fn(),
saveFeatureSummary: vi.fn(),
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn(),
};
await executor.execute(options, callbacks);
// Verify it NEVER called saveFeatureSummary because there was no NEW summary
expect(callbacks.saveFeatureSummary).not.toHaveBeenCalled();
});
it('should extract task summary and update task status during streaming', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
null
);
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: 'Working... ' }],
},
};
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: '[TASK_COMPLETE] T001: Task finished successfully' }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
// We trigger executeTasksLoop by providing persistedTasks
const options = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet',
planningMode: 'skip' as const,
existingApprovedPlanContent: 'Some plan',
persistedTasks: [{ id: 'T001', description: 'Task 1', status: 'pending' as const }],
};
const callbacks = {
waitForApproval: vi.fn(),
saveFeatureSummary: vi.fn(),
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
};
await executor.execute(options, callbacks);
// Verify it updated task status with summary
expect(mockFeatureStateManager.updateTaskStatus).toHaveBeenCalledWith(
'/project',
'test-feature',
'T001',
'completed',
'Task finished successfully'
);
});
describe('Pipeline step summary fallback', () => {
it('should save fallback summary when extraction fails for pipeline step', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
null
);
// Content without a summary tag (extraction will fail)
const newWork = 'Implementation completed without summary tag.';
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: newWork }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const options = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet',
planningMode: 'skip' as const,
status: 'pipeline_step1' as const, // Pipeline status triggers fallback
};
const callbacks = {
waitForApproval: vi.fn(),
saveFeatureSummary: vi.fn(),
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn(),
};
await executor.execute(options, callbacks);
// Verify fallback summary was saved with trimmed content
expect(callbacks.saveFeatureSummary).toHaveBeenCalledWith(
'/project',
'test-feature',
'Implementation completed without summary tag.'
);
});
it('should not save fallback for non-pipeline status when extraction fails', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
null
);
// Content without a summary tag
const newWork = 'Implementation completed without summary tag.';
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: newWork }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const options = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet',
planningMode: 'skip' as const,
status: 'in_progress' as const, // Non-pipeline status
};
const callbacks = {
waitForApproval: vi.fn(),
saveFeatureSummary: vi.fn(),
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn(),
};
await executor.execute(options, callbacks);
// Verify no fallback was saved for non-pipeline status
expect(callbacks.saveFeatureSummary).not.toHaveBeenCalled();
});
it('should not save empty fallback for pipeline step', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
null
);
// Empty/whitespace-only content
const newWork = ' \n\t ';
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: newWork }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const options = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet',
planningMode: 'skip' as const,
status: 'pipeline_step1' as const,
};
const callbacks = {
waitForApproval: vi.fn(),
saveFeatureSummary: vi.fn(),
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn(),
};
await executor.execute(options, callbacks);
// Verify no fallback was saved since content was empty/whitespace
expect(callbacks.saveFeatureSummary).not.toHaveBeenCalled();
});
it('should prefer extracted summary over fallback for pipeline step', async () => {
const executor = new AgentExecutor(
mockEventBus,
mockFeatureStateManager,
mockPlanApprovalService,
null
);
// Content WITH a summary tag
const newWork = `Implementation details here.
<summary>Proper summary from extraction</summary>`;
const mockProvider = {
getName: () => 'mock',
executeQuery: vi.fn().mockImplementation(function* () {
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: newWork }],
},
};
yield { type: 'result', subtype: 'success' };
}),
} as unknown as BaseProvider;
const options = {
workDir: '/test',
featureId: 'test-feature',
prompt: 'Test prompt',
projectPath: '/project',
abortController: new AbortController(),
provider: mockProvider,
effectiveBareModel: 'claude-sonnet',
planningMode: 'skip' as const,
status: 'pipeline_step1' as const,
};
const callbacks = {
waitForApproval: vi.fn(),
saveFeatureSummary: vi.fn(),
updateFeatureSummary: vi.fn(),
buildTaskPrompt: vi.fn(),
};
await executor.execute(options, callbacks);
// Verify extracted summary was saved, not the full content
expect(callbacks.saveFeatureSummary).toHaveBeenCalledWith(
'/project',
'test-feature',
'Proper summary from extraction'
);
// Ensure it didn't save the full content as fallback
expect(callbacks.saveFeatureSummary).not.toHaveBeenCalledWith(
'/project',
'test-feature',
expect.stringContaining('Implementation details here')
);
});
});
});

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