gobby 0.2.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gobby/__init__.py +3 -0
- gobby/adapters/__init__.py +30 -0
- gobby/adapters/base.py +93 -0
- gobby/adapters/claude_code.py +276 -0
- gobby/adapters/codex.py +1292 -0
- gobby/adapters/gemini.py +343 -0
- gobby/agents/__init__.py +37 -0
- gobby/agents/codex_session.py +120 -0
- gobby/agents/constants.py +112 -0
- gobby/agents/context.py +362 -0
- gobby/agents/definitions.py +133 -0
- gobby/agents/gemini_session.py +111 -0
- gobby/agents/registry.py +618 -0
- gobby/agents/runner.py +968 -0
- gobby/agents/session.py +259 -0
- gobby/agents/spawn.py +916 -0
- gobby/agents/spawners/__init__.py +77 -0
- gobby/agents/spawners/base.py +142 -0
- gobby/agents/spawners/cross_platform.py +266 -0
- gobby/agents/spawners/embedded.py +225 -0
- gobby/agents/spawners/headless.py +226 -0
- gobby/agents/spawners/linux.py +125 -0
- gobby/agents/spawners/macos.py +277 -0
- gobby/agents/spawners/windows.py +308 -0
- gobby/agents/tty_config.py +319 -0
- gobby/autonomous/__init__.py +32 -0
- gobby/autonomous/progress_tracker.py +447 -0
- gobby/autonomous/stop_registry.py +269 -0
- gobby/autonomous/stuck_detector.py +383 -0
- gobby/cli/__init__.py +67 -0
- gobby/cli/__main__.py +8 -0
- gobby/cli/agents.py +529 -0
- gobby/cli/artifacts.py +266 -0
- gobby/cli/daemon.py +329 -0
- gobby/cli/extensions.py +526 -0
- gobby/cli/github.py +263 -0
- gobby/cli/init.py +53 -0
- gobby/cli/install.py +614 -0
- gobby/cli/installers/__init__.py +37 -0
- gobby/cli/installers/antigravity.py +65 -0
- gobby/cli/installers/claude.py +363 -0
- gobby/cli/installers/codex.py +192 -0
- gobby/cli/installers/gemini.py +294 -0
- gobby/cli/installers/git_hooks.py +377 -0
- gobby/cli/installers/shared.py +737 -0
- gobby/cli/linear.py +250 -0
- gobby/cli/mcp.py +30 -0
- gobby/cli/mcp_proxy.py +698 -0
- gobby/cli/memory.py +304 -0
- gobby/cli/merge.py +384 -0
- gobby/cli/projects.py +79 -0
- gobby/cli/sessions.py +622 -0
- gobby/cli/tasks/__init__.py +30 -0
- gobby/cli/tasks/_utils.py +658 -0
- gobby/cli/tasks/ai.py +1025 -0
- gobby/cli/tasks/commits.py +169 -0
- gobby/cli/tasks/crud.py +685 -0
- gobby/cli/tasks/deps.py +135 -0
- gobby/cli/tasks/labels.py +63 -0
- gobby/cli/tasks/main.py +273 -0
- gobby/cli/tasks/search.py +178 -0
- gobby/cli/tui.py +34 -0
- gobby/cli/utils.py +513 -0
- gobby/cli/workflows.py +927 -0
- gobby/cli/worktrees.py +481 -0
- gobby/config/__init__.py +129 -0
- gobby/config/app.py +551 -0
- gobby/config/extensions.py +167 -0
- gobby/config/features.py +472 -0
- gobby/config/llm_providers.py +98 -0
- gobby/config/logging.py +66 -0
- gobby/config/mcp.py +346 -0
- gobby/config/persistence.py +247 -0
- gobby/config/servers.py +141 -0
- gobby/config/sessions.py +250 -0
- gobby/config/tasks.py +784 -0
- gobby/hooks/__init__.py +104 -0
- gobby/hooks/artifact_capture.py +213 -0
- gobby/hooks/broadcaster.py +243 -0
- gobby/hooks/event_handlers.py +723 -0
- gobby/hooks/events.py +218 -0
- gobby/hooks/git.py +169 -0
- gobby/hooks/health_monitor.py +171 -0
- gobby/hooks/hook_manager.py +856 -0
- gobby/hooks/hook_types.py +575 -0
- gobby/hooks/plugins.py +813 -0
- gobby/hooks/session_coordinator.py +396 -0
- gobby/hooks/verification_runner.py +268 -0
- gobby/hooks/webhooks.py +339 -0
- gobby/install/claude/commands/gobby/bug.md +51 -0
- gobby/install/claude/commands/gobby/chore.md +51 -0
- gobby/install/claude/commands/gobby/epic.md +52 -0
- gobby/install/claude/commands/gobby/eval.md +235 -0
- gobby/install/claude/commands/gobby/feat.md +49 -0
- gobby/install/claude/commands/gobby/nit.md +52 -0
- gobby/install/claude/commands/gobby/ref.md +52 -0
- gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
- gobby/install/claude/hooks/hook_dispatcher.py +364 -0
- gobby/install/claude/hooks/validate_settings.py +102 -0
- gobby/install/claude/hooks-template.json +118 -0
- gobby/install/codex/hooks/hook_dispatcher.py +153 -0
- gobby/install/codex/prompts/forget.md +7 -0
- gobby/install/codex/prompts/memories.md +7 -0
- gobby/install/codex/prompts/recall.md +7 -0
- gobby/install/codex/prompts/remember.md +13 -0
- gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
- gobby/install/gemini/hooks-template.json +138 -0
- gobby/install/shared/plugins/code_guardian.py +456 -0
- gobby/install/shared/plugins/example_notify.py +331 -0
- gobby/integrations/__init__.py +10 -0
- gobby/integrations/github.py +145 -0
- gobby/integrations/linear.py +145 -0
- gobby/llm/__init__.py +40 -0
- gobby/llm/base.py +120 -0
- gobby/llm/claude.py +578 -0
- gobby/llm/claude_executor.py +503 -0
- gobby/llm/codex.py +322 -0
- gobby/llm/codex_executor.py +513 -0
- gobby/llm/executor.py +316 -0
- gobby/llm/factory.py +34 -0
- gobby/llm/gemini.py +258 -0
- gobby/llm/gemini_executor.py +339 -0
- gobby/llm/litellm.py +287 -0
- gobby/llm/litellm_executor.py +303 -0
- gobby/llm/resolver.py +499 -0
- gobby/llm/service.py +236 -0
- gobby/mcp_proxy/__init__.py +29 -0
- gobby/mcp_proxy/actions.py +175 -0
- gobby/mcp_proxy/daemon_control.py +198 -0
- gobby/mcp_proxy/importer.py +436 -0
- gobby/mcp_proxy/lazy.py +325 -0
- gobby/mcp_proxy/manager.py +798 -0
- gobby/mcp_proxy/metrics.py +609 -0
- gobby/mcp_proxy/models.py +139 -0
- gobby/mcp_proxy/registries.py +215 -0
- gobby/mcp_proxy/schema_hash.py +381 -0
- gobby/mcp_proxy/semantic_search.py +706 -0
- gobby/mcp_proxy/server.py +549 -0
- gobby/mcp_proxy/services/__init__.py +0 -0
- gobby/mcp_proxy/services/fallback.py +306 -0
- gobby/mcp_proxy/services/recommendation.py +224 -0
- gobby/mcp_proxy/services/server_mgmt.py +214 -0
- gobby/mcp_proxy/services/system.py +72 -0
- gobby/mcp_proxy/services/tool_filter.py +231 -0
- gobby/mcp_proxy/services/tool_proxy.py +309 -0
- gobby/mcp_proxy/stdio.py +565 -0
- gobby/mcp_proxy/tools/__init__.py +27 -0
- gobby/mcp_proxy/tools/agents.py +1103 -0
- gobby/mcp_proxy/tools/artifacts.py +207 -0
- gobby/mcp_proxy/tools/hub.py +335 -0
- gobby/mcp_proxy/tools/internal.py +337 -0
- gobby/mcp_proxy/tools/memory.py +543 -0
- gobby/mcp_proxy/tools/merge.py +422 -0
- gobby/mcp_proxy/tools/metrics.py +283 -0
- gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
- gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
- gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
- gobby/mcp_proxy/tools/orchestration/review.py +736 -0
- gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
- gobby/mcp_proxy/tools/session_messages.py +1056 -0
- gobby/mcp_proxy/tools/task_dependencies.py +219 -0
- gobby/mcp_proxy/tools/task_expansion.py +591 -0
- gobby/mcp_proxy/tools/task_github.py +393 -0
- gobby/mcp_proxy/tools/task_linear.py +379 -0
- gobby/mcp_proxy/tools/task_orchestration.py +77 -0
- gobby/mcp_proxy/tools/task_readiness.py +522 -0
- gobby/mcp_proxy/tools/task_sync.py +351 -0
- gobby/mcp_proxy/tools/task_validation.py +843 -0
- gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
- gobby/mcp_proxy/tools/tasks/_context.py +112 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
- gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
- gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
- gobby/mcp_proxy/tools/tasks/_search.py +215 -0
- gobby/mcp_proxy/tools/tasks/_session.py +125 -0
- gobby/mcp_proxy/tools/workflows.py +973 -0
- gobby/mcp_proxy/tools/worktrees.py +1264 -0
- gobby/mcp_proxy/transports/__init__.py +0 -0
- gobby/mcp_proxy/transports/base.py +95 -0
- gobby/mcp_proxy/transports/factory.py +44 -0
- gobby/mcp_proxy/transports/http.py +139 -0
- gobby/mcp_proxy/transports/stdio.py +213 -0
- gobby/mcp_proxy/transports/websocket.py +136 -0
- gobby/memory/backends/__init__.py +116 -0
- gobby/memory/backends/mem0.py +408 -0
- gobby/memory/backends/memu.py +485 -0
- gobby/memory/backends/null.py +111 -0
- gobby/memory/backends/openmemory.py +537 -0
- gobby/memory/backends/sqlite.py +304 -0
- gobby/memory/context.py +87 -0
- gobby/memory/manager.py +1001 -0
- gobby/memory/protocol.py +451 -0
- gobby/memory/search/__init__.py +66 -0
- gobby/memory/search/text.py +127 -0
- gobby/memory/viz.py +258 -0
- gobby/prompts/__init__.py +13 -0
- gobby/prompts/defaults/expansion/system.md +119 -0
- gobby/prompts/defaults/expansion/user.md +48 -0
- gobby/prompts/defaults/external_validation/agent.md +72 -0
- gobby/prompts/defaults/external_validation/external.md +63 -0
- gobby/prompts/defaults/external_validation/spawn.md +83 -0
- gobby/prompts/defaults/external_validation/system.md +6 -0
- gobby/prompts/defaults/features/import_mcp.md +22 -0
- gobby/prompts/defaults/features/import_mcp_github.md +17 -0
- gobby/prompts/defaults/features/import_mcp_search.md +16 -0
- gobby/prompts/defaults/features/recommend_tools.md +32 -0
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
- gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
- gobby/prompts/defaults/features/server_description.md +20 -0
- gobby/prompts/defaults/features/server_description_system.md +6 -0
- gobby/prompts/defaults/features/task_description.md +31 -0
- gobby/prompts/defaults/features/task_description_system.md +6 -0
- gobby/prompts/defaults/features/tool_summary.md +17 -0
- gobby/prompts/defaults/features/tool_summary_system.md +6 -0
- gobby/prompts/defaults/research/step.md +58 -0
- gobby/prompts/defaults/validation/criteria.md +47 -0
- gobby/prompts/defaults/validation/validate.md +38 -0
- gobby/prompts/loader.py +346 -0
- gobby/prompts/models.py +113 -0
- gobby/py.typed +0 -0
- gobby/runner.py +488 -0
- gobby/search/__init__.py +23 -0
- gobby/search/protocol.py +104 -0
- gobby/search/tfidf.py +232 -0
- gobby/servers/__init__.py +7 -0
- gobby/servers/http.py +636 -0
- gobby/servers/models.py +31 -0
- gobby/servers/routes/__init__.py +23 -0
- gobby/servers/routes/admin.py +416 -0
- gobby/servers/routes/dependencies.py +118 -0
- gobby/servers/routes/mcp/__init__.py +24 -0
- gobby/servers/routes/mcp/hooks.py +135 -0
- gobby/servers/routes/mcp/plugins.py +121 -0
- gobby/servers/routes/mcp/tools.py +1337 -0
- gobby/servers/routes/mcp/webhooks.py +159 -0
- gobby/servers/routes/sessions.py +582 -0
- gobby/servers/websocket.py +766 -0
- gobby/sessions/__init__.py +13 -0
- gobby/sessions/analyzer.py +322 -0
- gobby/sessions/lifecycle.py +240 -0
- gobby/sessions/manager.py +563 -0
- gobby/sessions/processor.py +225 -0
- gobby/sessions/summary.py +532 -0
- gobby/sessions/transcripts/__init__.py +41 -0
- gobby/sessions/transcripts/base.py +125 -0
- gobby/sessions/transcripts/claude.py +386 -0
- gobby/sessions/transcripts/codex.py +143 -0
- gobby/sessions/transcripts/gemini.py +195 -0
- gobby/storage/__init__.py +21 -0
- gobby/storage/agents.py +409 -0
- gobby/storage/artifact_classifier.py +341 -0
- gobby/storage/artifacts.py +285 -0
- gobby/storage/compaction.py +67 -0
- gobby/storage/database.py +357 -0
- gobby/storage/inter_session_messages.py +194 -0
- gobby/storage/mcp.py +680 -0
- gobby/storage/memories.py +562 -0
- gobby/storage/merge_resolutions.py +550 -0
- gobby/storage/migrations.py +860 -0
- gobby/storage/migrations_legacy.py +1359 -0
- gobby/storage/projects.py +166 -0
- gobby/storage/session_messages.py +251 -0
- gobby/storage/session_tasks.py +97 -0
- gobby/storage/sessions.py +817 -0
- gobby/storage/task_dependencies.py +223 -0
- gobby/storage/tasks/__init__.py +42 -0
- gobby/storage/tasks/_aggregates.py +180 -0
- gobby/storage/tasks/_crud.py +449 -0
- gobby/storage/tasks/_id.py +104 -0
- gobby/storage/tasks/_lifecycle.py +311 -0
- gobby/storage/tasks/_manager.py +889 -0
- gobby/storage/tasks/_models.py +300 -0
- gobby/storage/tasks/_ordering.py +119 -0
- gobby/storage/tasks/_path_cache.py +110 -0
- gobby/storage/tasks/_queries.py +343 -0
- gobby/storage/tasks/_search.py +143 -0
- gobby/storage/workflow_audit.py +393 -0
- gobby/storage/worktrees.py +547 -0
- gobby/sync/__init__.py +29 -0
- gobby/sync/github.py +333 -0
- gobby/sync/linear.py +304 -0
- gobby/sync/memories.py +284 -0
- gobby/sync/tasks.py +641 -0
- gobby/tasks/__init__.py +8 -0
- gobby/tasks/build_verification.py +193 -0
- gobby/tasks/commits.py +633 -0
- gobby/tasks/context.py +747 -0
- gobby/tasks/criteria.py +342 -0
- gobby/tasks/enhanced_validator.py +226 -0
- gobby/tasks/escalation.py +263 -0
- gobby/tasks/expansion.py +626 -0
- gobby/tasks/external_validator.py +764 -0
- gobby/tasks/issue_extraction.py +171 -0
- gobby/tasks/prompts/expand.py +327 -0
- gobby/tasks/research.py +421 -0
- gobby/tasks/tdd.py +352 -0
- gobby/tasks/tree_builder.py +263 -0
- gobby/tasks/validation.py +712 -0
- gobby/tasks/validation_history.py +357 -0
- gobby/tasks/validation_models.py +89 -0
- gobby/tools/__init__.py +0 -0
- gobby/tools/summarizer.py +170 -0
- gobby/tui/__init__.py +5 -0
- gobby/tui/api_client.py +281 -0
- gobby/tui/app.py +327 -0
- gobby/tui/screens/__init__.py +25 -0
- gobby/tui/screens/agents.py +333 -0
- gobby/tui/screens/chat.py +450 -0
- gobby/tui/screens/dashboard.py +377 -0
- gobby/tui/screens/memory.py +305 -0
- gobby/tui/screens/metrics.py +231 -0
- gobby/tui/screens/orchestrator.py +904 -0
- gobby/tui/screens/sessions.py +412 -0
- gobby/tui/screens/tasks.py +442 -0
- gobby/tui/screens/workflows.py +289 -0
- gobby/tui/screens/worktrees.py +174 -0
- gobby/tui/widgets/__init__.py +21 -0
- gobby/tui/widgets/chat.py +210 -0
- gobby/tui/widgets/conductor.py +104 -0
- gobby/tui/widgets/menu.py +132 -0
- gobby/tui/widgets/message_panel.py +160 -0
- gobby/tui/widgets/review_gate.py +224 -0
- gobby/tui/widgets/task_tree.py +99 -0
- gobby/tui/widgets/token_budget.py +166 -0
- gobby/tui/ws_client.py +258 -0
- gobby/utils/__init__.py +3 -0
- gobby/utils/daemon_client.py +235 -0
- gobby/utils/git.py +222 -0
- gobby/utils/id.py +38 -0
- gobby/utils/json_helpers.py +161 -0
- gobby/utils/logging.py +376 -0
- gobby/utils/machine_id.py +135 -0
- gobby/utils/metrics.py +589 -0
- gobby/utils/project_context.py +182 -0
- gobby/utils/project_init.py +263 -0
- gobby/utils/status.py +256 -0
- gobby/utils/validation.py +80 -0
- gobby/utils/version.py +23 -0
- gobby/workflows/__init__.py +4 -0
- gobby/workflows/actions.py +1310 -0
- gobby/workflows/approval_flow.py +138 -0
- gobby/workflows/artifact_actions.py +103 -0
- gobby/workflows/audit_helpers.py +110 -0
- gobby/workflows/autonomous_actions.py +286 -0
- gobby/workflows/context_actions.py +394 -0
- gobby/workflows/definitions.py +130 -0
- gobby/workflows/detection_helpers.py +208 -0
- gobby/workflows/engine.py +485 -0
- gobby/workflows/evaluator.py +669 -0
- gobby/workflows/git_utils.py +96 -0
- gobby/workflows/hooks.py +169 -0
- gobby/workflows/lifecycle_evaluator.py +613 -0
- gobby/workflows/llm_actions.py +70 -0
- gobby/workflows/loader.py +333 -0
- gobby/workflows/mcp_actions.py +60 -0
- gobby/workflows/memory_actions.py +272 -0
- gobby/workflows/premature_stop.py +164 -0
- gobby/workflows/session_actions.py +139 -0
- gobby/workflows/state_actions.py +123 -0
- gobby/workflows/state_manager.py +104 -0
- gobby/workflows/stop_signal_actions.py +163 -0
- gobby/workflows/summary_actions.py +344 -0
- gobby/workflows/task_actions.py +249 -0
- gobby/workflows/task_enforcement_actions.py +901 -0
- gobby/workflows/templates.py +52 -0
- gobby/workflows/todo_actions.py +84 -0
- gobby/workflows/webhook.py +223 -0
- gobby/workflows/webhook_executor.py +399 -0
- gobby/worktrees/__init__.py +5 -0
- gobby/worktrees/git.py +690 -0
- gobby/worktrees/merge/__init__.py +20 -0
- gobby/worktrees/merge/conflict_parser.py +177 -0
- gobby/worktrees/merge/resolver.py +485 -0
- gobby-0.2.5.dist-info/METADATA +351 -0
- gobby-0.2.5.dist-info/RECORD +383 -0
- gobby-0.2.5.dist-info/WHEEL +5 -0
- gobby-0.2.5.dist-info/entry_points.txt +2 -0
- gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
- gobby-0.2.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1264 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Internal MCP tools for Gobby Worktree Management.
|
|
3
|
+
|
|
4
|
+
Exposes functionality for:
|
|
5
|
+
- Creating git worktrees for isolated development
|
|
6
|
+
- Managing worktree lifecycle (claim, release, cleanup)
|
|
7
|
+
- Syncing worktrees with main branch
|
|
8
|
+
- Spawning agents in worktrees
|
|
9
|
+
|
|
10
|
+
These tools are registered with the InternalToolRegistry and accessed
|
|
11
|
+
via the downstream proxy pattern (call_tool, list_tools, get_tool_schema).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import platform
|
|
19
|
+
import tempfile
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import TYPE_CHECKING, Any, Literal, cast
|
|
22
|
+
|
|
23
|
+
from gobby.mcp_proxy.tools.internal import InternalToolRegistry
|
|
24
|
+
from gobby.utils.project_context import get_project_context
|
|
25
|
+
from gobby.workflows.definitions import WorkflowState
|
|
26
|
+
from gobby.workflows.loader import WorkflowLoader
|
|
27
|
+
from gobby.workflows.state_manager import WorkflowStateManager
|
|
28
|
+
from gobby.worktrees.git import WorktreeGitManager
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from gobby.agents.runner import AgentRunner
|
|
32
|
+
from gobby.storage.worktrees import LocalWorktreeManager
|
|
33
|
+
from gobby.worktrees.git import WorktreeGitManager
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
# Cache for WorktreeGitManager instances per repo path
|
|
38
|
+
_git_manager_cache: dict[str, WorktreeGitManager] = {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _get_worktree_base_dir() -> Path:
|
|
42
|
+
"""
|
|
43
|
+
Get the base directory for worktrees.
|
|
44
|
+
|
|
45
|
+
Uses the system temp directory:
|
|
46
|
+
- macOS/Linux: /tmp/gobby-worktrees/
|
|
47
|
+
- Windows: %TEMP%/gobby-worktrees/
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Path to worktree base directory (creates if needed)
|
|
51
|
+
"""
|
|
52
|
+
if platform.system() == "Windows":
|
|
53
|
+
# Windows: use %TEMP% (typically C:\\Users\\<user>\\AppData\\Local\\Temp)
|
|
54
|
+
base = Path(tempfile.gettempdir()) / "gobby-worktrees"
|
|
55
|
+
else:
|
|
56
|
+
# macOS/Linux: use /tmp for better isolation (tmpfs, cleared on reboot)
|
|
57
|
+
# Resolve symlink on macOS (/tmp -> /private/tmp) for consistent paths
|
|
58
|
+
# nosec B108: /tmp is intentional for worktrees - they're temporary by design
|
|
59
|
+
base = Path("/tmp").resolve() / "gobby-worktrees" # nosec B108
|
|
60
|
+
|
|
61
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
return base
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _generate_worktree_path(branch_name: str, project_name: str | None = None) -> str:
|
|
66
|
+
"""
|
|
67
|
+
Generate a worktree path in the temp directory.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
branch_name: Branch name (used as directory name)
|
|
71
|
+
project_name: Optional project name for namespacing
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Full path for the worktree
|
|
75
|
+
"""
|
|
76
|
+
base = _get_worktree_base_dir()
|
|
77
|
+
|
|
78
|
+
# Sanitize branch name for filesystem (replace / with -)
|
|
79
|
+
safe_branch = branch_name.replace("/", "-")
|
|
80
|
+
|
|
81
|
+
if project_name:
|
|
82
|
+
# Namespace by project: /tmp/gobby-worktrees/project-name/branch-name
|
|
83
|
+
return str(base / project_name / safe_branch)
|
|
84
|
+
else:
|
|
85
|
+
# No project namespace: /tmp/gobby-worktrees/branch-name
|
|
86
|
+
return str(base / safe_branch)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _resolve_project_context(
|
|
90
|
+
project_path: str | None,
|
|
91
|
+
default_git_manager: WorktreeGitManager | None,
|
|
92
|
+
default_project_id: str | None,
|
|
93
|
+
) -> tuple[WorktreeGitManager | None, str | None, str | None]:
|
|
94
|
+
"""
|
|
95
|
+
Resolve project context from project_path or fall back to defaults.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
project_path: Path to project directory (cwd from caller).
|
|
99
|
+
default_git_manager: Registry-level git manager (may be None).
|
|
100
|
+
default_project_id: Registry-level project ID (may be None).
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Tuple of (git_manager, project_id, error_message).
|
|
104
|
+
If error_message is not None, the other values should not be used.
|
|
105
|
+
"""
|
|
106
|
+
if project_path:
|
|
107
|
+
# Resolve from provided path
|
|
108
|
+
path = Path(project_path)
|
|
109
|
+
if not path.exists():
|
|
110
|
+
return None, None, f"Project path does not exist: {project_path}"
|
|
111
|
+
|
|
112
|
+
project_ctx = get_project_context(path)
|
|
113
|
+
if not project_ctx:
|
|
114
|
+
return None, None, f"No .gobby/project.json found in {project_path}"
|
|
115
|
+
|
|
116
|
+
resolved_project_id = project_ctx.get("id")
|
|
117
|
+
resolved_path = project_ctx.get("project_path", str(path))
|
|
118
|
+
|
|
119
|
+
# Get or create git manager for this path
|
|
120
|
+
if resolved_path not in _git_manager_cache:
|
|
121
|
+
try:
|
|
122
|
+
_git_manager_cache[resolved_path] = WorktreeGitManager(resolved_path)
|
|
123
|
+
except ValueError as e:
|
|
124
|
+
return None, None, f"Invalid git repository: {e}"
|
|
125
|
+
|
|
126
|
+
return _git_manager_cache[resolved_path], resolved_project_id, None
|
|
127
|
+
|
|
128
|
+
# Fall back to defaults
|
|
129
|
+
if default_git_manager is None:
|
|
130
|
+
return None, None, "No project_path provided and no default git manager configured."
|
|
131
|
+
if default_project_id is None:
|
|
132
|
+
return None, None, "No project_path provided and no default project ID configured."
|
|
133
|
+
|
|
134
|
+
return default_git_manager, default_project_id, None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _copy_project_json_to_worktree(
|
|
138
|
+
repo_path: str | Path,
|
|
139
|
+
worktree_path: str | Path,
|
|
140
|
+
) -> None:
|
|
141
|
+
"""
|
|
142
|
+
Copy .gobby/project.json from main repo to worktree, adding parent reference.
|
|
143
|
+
|
|
144
|
+
This ensures worktree sessions:
|
|
145
|
+
- Use the same project_id as the parent repo
|
|
146
|
+
- Can discover the parent project path for workflow lookup
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
repo_path: Path to main repository
|
|
150
|
+
worktree_path: Path to worktree directory
|
|
151
|
+
"""
|
|
152
|
+
main_gobby_dir = Path(repo_path) / ".gobby"
|
|
153
|
+
main_project_json = main_gobby_dir / "project.json"
|
|
154
|
+
worktree_gobby_dir = Path(worktree_path) / ".gobby"
|
|
155
|
+
|
|
156
|
+
if main_project_json.exists():
|
|
157
|
+
try:
|
|
158
|
+
worktree_gobby_dir.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
worktree_project_json = worktree_gobby_dir / "project.json"
|
|
160
|
+
if not worktree_project_json.exists():
|
|
161
|
+
# Read, add parent reference, write
|
|
162
|
+
with open(main_project_json) as f:
|
|
163
|
+
data = json.load(f)
|
|
164
|
+
|
|
165
|
+
data["parent_project_path"] = str(Path(repo_path).resolve())
|
|
166
|
+
|
|
167
|
+
with open(worktree_project_json, "w") as f:
|
|
168
|
+
json.dump(data, f, indent=2)
|
|
169
|
+
|
|
170
|
+
logger.info("Created project.json in worktree with parent reference")
|
|
171
|
+
except Exception as e:
|
|
172
|
+
logger.warning(f"Failed to create project.json in worktree: {e}")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _install_provider_hooks(
|
|
176
|
+
provider: Literal["claude", "gemini", "codex", "antigravity"] | None,
|
|
177
|
+
worktree_path: str | Path,
|
|
178
|
+
) -> bool:
|
|
179
|
+
"""
|
|
180
|
+
Install CLI hooks for the specified provider in the worktree.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
provider: Provider name ('claude', 'gemini', 'antigravity', or None)
|
|
184
|
+
worktree_path: Path to worktree directory
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
True if hooks were successfully installed, False otherwise
|
|
188
|
+
"""
|
|
189
|
+
if not provider:
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
worktree_path_obj = Path(worktree_path)
|
|
193
|
+
try:
|
|
194
|
+
if provider == "claude":
|
|
195
|
+
from gobby.cli.installers.claude import install_claude
|
|
196
|
+
|
|
197
|
+
result = install_claude(worktree_path_obj)
|
|
198
|
+
if result["success"]:
|
|
199
|
+
logger.info(f"Installed Claude hooks in worktree: {worktree_path}")
|
|
200
|
+
return True
|
|
201
|
+
else:
|
|
202
|
+
logger.warning(f"Failed to install Claude hooks: {result.get('error')}")
|
|
203
|
+
elif provider == "gemini":
|
|
204
|
+
from gobby.cli.installers.gemini import install_gemini
|
|
205
|
+
|
|
206
|
+
result = install_gemini(worktree_path_obj)
|
|
207
|
+
if result["success"]:
|
|
208
|
+
logger.info(f"Installed Gemini hooks in worktree: {worktree_path}")
|
|
209
|
+
return True
|
|
210
|
+
else:
|
|
211
|
+
logger.warning(f"Failed to install Gemini hooks: {result.get('error')}")
|
|
212
|
+
elif provider == "antigravity":
|
|
213
|
+
from gobby.cli.installers.antigravity import install_antigravity
|
|
214
|
+
|
|
215
|
+
result = install_antigravity(worktree_path_obj)
|
|
216
|
+
if result["success"]:
|
|
217
|
+
logger.info(f"Installed Antigravity hooks in worktree: {worktree_path}")
|
|
218
|
+
return True
|
|
219
|
+
else:
|
|
220
|
+
logger.warning(f"Failed to install Antigravity hooks: {result.get('error')}")
|
|
221
|
+
# Note: codex uses CODEX_NOTIFY_SCRIPT env var, not project-level hooks
|
|
222
|
+
except Exception as e:
|
|
223
|
+
logger.warning(f"Failed to install {provider} hooks in worktree: {e}")
|
|
224
|
+
return False
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _build_worktree_context_prompt(
|
|
228
|
+
original_prompt: str,
|
|
229
|
+
worktree_path: str,
|
|
230
|
+
branch_name: str,
|
|
231
|
+
task_id: str | None,
|
|
232
|
+
main_repo_path: str | None = None,
|
|
233
|
+
) -> str:
|
|
234
|
+
"""
|
|
235
|
+
Build an enhanced prompt with worktree context injected.
|
|
236
|
+
|
|
237
|
+
This helps the spawned agent understand it's working in an isolated worktree,
|
|
238
|
+
not the main repository. Critical for preventing the agent from accessing
|
|
239
|
+
wrong files or working in the wrong directory.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
original_prompt: The original task prompt
|
|
243
|
+
worktree_path: Path to the worktree
|
|
244
|
+
branch_name: Name of the worktree branch
|
|
245
|
+
task_id: Task ID being worked on (if any)
|
|
246
|
+
main_repo_path: Path to the main repo (to explicitly warn against accessing it)
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
Enhanced prompt with worktree context prepended
|
|
250
|
+
"""
|
|
251
|
+
context_lines = [
|
|
252
|
+
"## CRITICAL: Worktree Context",
|
|
253
|
+
"You are working in an ISOLATED git worktree, NOT the main repository.",
|
|
254
|
+
"",
|
|
255
|
+
f"**Your workspace:** {worktree_path}",
|
|
256
|
+
f"**Your branch:** {branch_name}",
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
if task_id:
|
|
260
|
+
context_lines.append(f"**Your task:** {task_id}")
|
|
261
|
+
|
|
262
|
+
context_lines.extend(
|
|
263
|
+
[
|
|
264
|
+
"",
|
|
265
|
+
"**IMPORTANT RULES:**",
|
|
266
|
+
f"1. ALL file operations must be within {worktree_path}",
|
|
267
|
+
]
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
if main_repo_path:
|
|
271
|
+
context_lines.append(f"2. Do NOT access {main_repo_path} (main repo)")
|
|
272
|
+
else:
|
|
273
|
+
context_lines.append("2. Do NOT access the main repository")
|
|
274
|
+
|
|
275
|
+
context_lines.extend(
|
|
276
|
+
[
|
|
277
|
+
"3. Run `pwd` to verify your location before any file operations",
|
|
278
|
+
f"4. Commit to YOUR branch ({branch_name}), not main/dev",
|
|
279
|
+
"5. When your assigned task is complete, STOP - do not claim other tasks",
|
|
280
|
+
"",
|
|
281
|
+
"---",
|
|
282
|
+
"",
|
|
283
|
+
]
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
worktree_context = "\n".join(context_lines)
|
|
287
|
+
return f"{worktree_context}{original_prompt}"
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def create_worktrees_registry(
|
|
291
|
+
worktree_storage: LocalWorktreeManager,
|
|
292
|
+
git_manager: WorktreeGitManager | None = None,
|
|
293
|
+
project_id: str | None = None,
|
|
294
|
+
agent_runner: AgentRunner | None = None,
|
|
295
|
+
) -> InternalToolRegistry:
|
|
296
|
+
"""
|
|
297
|
+
Create a worktree tool registry with all worktree-related tools.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
worktree_storage: LocalWorktreeManager for database operations.
|
|
301
|
+
git_manager: WorktreeGitManager for git operations.
|
|
302
|
+
project_id: Default project ID for operations.
|
|
303
|
+
agent_runner: AgentRunner for spawning agents in worktrees.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
InternalToolRegistry with all worktree tools registered.
|
|
307
|
+
"""
|
|
308
|
+
registry = InternalToolRegistry(
|
|
309
|
+
name="gobby-worktrees",
|
|
310
|
+
description="Git worktree management - create, manage, and cleanup isolated development directories",
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
@registry.tool(
|
|
314
|
+
name="create_worktree",
|
|
315
|
+
description="Create a new git worktree for isolated development.",
|
|
316
|
+
)
|
|
317
|
+
async def create_worktree(
|
|
318
|
+
branch_name: str,
|
|
319
|
+
base_branch: str = "main",
|
|
320
|
+
task_id: str | None = None,
|
|
321
|
+
worktree_path: str | None = None,
|
|
322
|
+
create_branch: bool = True,
|
|
323
|
+
project_path: str | None = None,
|
|
324
|
+
provider: Literal["claude", "gemini", "codex", "antigravity"] | None = None,
|
|
325
|
+
) -> dict[str, Any]:
|
|
326
|
+
"""
|
|
327
|
+
Create a new git worktree.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
branch_name: Name for the new branch.
|
|
331
|
+
base_branch: Branch to base the worktree on (default: main).
|
|
332
|
+
task_id: Optional task ID to link to this worktree.
|
|
333
|
+
worktree_path: Optional custom path (defaults to ../{branch_name}).
|
|
334
|
+
create_branch: Whether to create a new branch (default: True).
|
|
335
|
+
project_path: Path to project directory (pass cwd from CLI).
|
|
336
|
+
provider: CLI provider to install hooks for (claude, gemini, codex, antigravity).
|
|
337
|
+
If specified, installs hooks so agents can communicate with daemon.
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
Dict with worktree ID, path, and branch info.
|
|
341
|
+
"""
|
|
342
|
+
# Resolve project context
|
|
343
|
+
resolved_git_mgr, resolved_project_id, error = _resolve_project_context(
|
|
344
|
+
project_path, git_manager, project_id
|
|
345
|
+
)
|
|
346
|
+
if error:
|
|
347
|
+
return {"success": False, "error": error}
|
|
348
|
+
|
|
349
|
+
# Type narrowing: if no error, these are guaranteed non-None
|
|
350
|
+
if resolved_git_mgr is None or resolved_project_id is None:
|
|
351
|
+
raise RuntimeError("Git manager or project ID unexpectedly None")
|
|
352
|
+
|
|
353
|
+
# Check if branch already exists as a worktree
|
|
354
|
+
existing = worktree_storage.get_by_branch(resolved_project_id, branch_name)
|
|
355
|
+
if existing:
|
|
356
|
+
return {
|
|
357
|
+
"success": False,
|
|
358
|
+
"error": f"Worktree already exists for branch '{branch_name}'",
|
|
359
|
+
"existing_worktree_id": existing.id,
|
|
360
|
+
"existing_path": existing.worktree_path,
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
# Generate default worktree path if not provided
|
|
364
|
+
if worktree_path is None:
|
|
365
|
+
# Use temp directory (e.g., /tmp/gobby-worktrees/project-name/branch-name)
|
|
366
|
+
project_name = Path(resolved_git_mgr.repo_path).name
|
|
367
|
+
worktree_path = _generate_worktree_path(branch_name, project_name)
|
|
368
|
+
|
|
369
|
+
# Create git worktree
|
|
370
|
+
result = resolved_git_mgr.create_worktree(
|
|
371
|
+
worktree_path=worktree_path,
|
|
372
|
+
branch_name=branch_name,
|
|
373
|
+
base_branch=base_branch,
|
|
374
|
+
create_branch=create_branch,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
if not result.success:
|
|
378
|
+
return {
|
|
379
|
+
"success": False,
|
|
380
|
+
"error": result.error or "Failed to create git worktree",
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
# Record in database
|
|
384
|
+
worktree = worktree_storage.create(
|
|
385
|
+
project_id=resolved_project_id,
|
|
386
|
+
branch_name=branch_name,
|
|
387
|
+
worktree_path=worktree_path,
|
|
388
|
+
base_branch=base_branch,
|
|
389
|
+
task_id=task_id,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
# Copy project.json and install provider hooks
|
|
393
|
+
_copy_project_json_to_worktree(resolved_git_mgr.repo_path, worktree.worktree_path)
|
|
394
|
+
hooks_installed = _install_provider_hooks(provider, worktree.worktree_path)
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
"success": True,
|
|
398
|
+
"worktree_id": worktree.id,
|
|
399
|
+
"worktree_path": worktree.worktree_path,
|
|
400
|
+
"hooks_installed": hooks_installed,
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
@registry.tool(
|
|
404
|
+
name="get_worktree",
|
|
405
|
+
description="Get details of a specific worktree.",
|
|
406
|
+
)
|
|
407
|
+
async def get_worktree(worktree_id: str) -> dict[str, Any]:
|
|
408
|
+
"""
|
|
409
|
+
Get worktree details by ID.
|
|
410
|
+
|
|
411
|
+
Args:
|
|
412
|
+
worktree_id: The worktree ID.
|
|
413
|
+
|
|
414
|
+
Returns:
|
|
415
|
+
Dict with full worktree details.
|
|
416
|
+
"""
|
|
417
|
+
worktree = worktree_storage.get(worktree_id)
|
|
418
|
+
if not worktree:
|
|
419
|
+
return {
|
|
420
|
+
"success": False,
|
|
421
|
+
"error": f"Worktree '{worktree_id}' not found",
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
# Get git status if manager available
|
|
425
|
+
git_status = None
|
|
426
|
+
if git_manager and Path(worktree.worktree_path).exists():
|
|
427
|
+
status = git_manager.get_worktree_status(worktree.worktree_path)
|
|
428
|
+
if status:
|
|
429
|
+
git_status = {
|
|
430
|
+
"has_uncommitted_changes": status.has_uncommitted_changes,
|
|
431
|
+
"commits_ahead": status.ahead,
|
|
432
|
+
"commits_behind": status.behind,
|
|
433
|
+
"current_branch": status.branch,
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
"success": True,
|
|
438
|
+
"worktree": worktree.to_dict(),
|
|
439
|
+
"git_status": git_status,
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
@registry.tool(
|
|
443
|
+
name="list_worktrees",
|
|
444
|
+
description="List worktrees with optional filters.",
|
|
445
|
+
)
|
|
446
|
+
async def list_worktrees(
|
|
447
|
+
status: str | None = None,
|
|
448
|
+
agent_session_id: str | None = None,
|
|
449
|
+
limit: int = 50,
|
|
450
|
+
) -> dict[str, Any]:
|
|
451
|
+
"""
|
|
452
|
+
List worktrees with optional filters.
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
status: Filter by status (active, stale, merged, abandoned).
|
|
456
|
+
agent_session_id: Filter by owning session.
|
|
457
|
+
limit: Maximum results (default: 50).
|
|
458
|
+
|
|
459
|
+
Returns:
|
|
460
|
+
Dict with list of worktrees.
|
|
461
|
+
"""
|
|
462
|
+
worktrees = worktree_storage.list_worktrees(
|
|
463
|
+
project_id=project_id,
|
|
464
|
+
status=status,
|
|
465
|
+
agent_session_id=agent_session_id,
|
|
466
|
+
limit=limit,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
"success": True,
|
|
471
|
+
"worktrees": [
|
|
472
|
+
{
|
|
473
|
+
"id": wt.id,
|
|
474
|
+
"branch_name": wt.branch_name,
|
|
475
|
+
"worktree_path": wt.worktree_path,
|
|
476
|
+
"status": wt.status,
|
|
477
|
+
"task_id": wt.task_id,
|
|
478
|
+
"agent_session_id": wt.agent_session_id,
|
|
479
|
+
"created_at": wt.created_at,
|
|
480
|
+
}
|
|
481
|
+
for wt in worktrees
|
|
482
|
+
],
|
|
483
|
+
"count": len(worktrees),
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
@registry.tool(
|
|
487
|
+
name="claim_worktree",
|
|
488
|
+
description="Claim ownership of a worktree for an agent session.",
|
|
489
|
+
)
|
|
490
|
+
async def claim_worktree(
|
|
491
|
+
worktree_id: str,
|
|
492
|
+
session_id: str,
|
|
493
|
+
) -> dict[str, Any]:
|
|
494
|
+
"""
|
|
495
|
+
Claim a worktree for an agent session.
|
|
496
|
+
|
|
497
|
+
Args:
|
|
498
|
+
worktree_id: The worktree ID to claim.
|
|
499
|
+
session_id: The session ID claiming ownership.
|
|
500
|
+
|
|
501
|
+
Returns:
|
|
502
|
+
Dict with success status.
|
|
503
|
+
"""
|
|
504
|
+
worktree = worktree_storage.get(worktree_id)
|
|
505
|
+
if not worktree:
|
|
506
|
+
return {
|
|
507
|
+
"success": False,
|
|
508
|
+
"error": f"Worktree '{worktree_id}' not found",
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if worktree.agent_session_id and worktree.agent_session_id != session_id:
|
|
512
|
+
return {
|
|
513
|
+
"success": False,
|
|
514
|
+
"error": f"Worktree already claimed by session '{worktree.agent_session_id}'",
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
updated = worktree_storage.claim(worktree_id, session_id)
|
|
518
|
+
if not updated:
|
|
519
|
+
return {"error": "Failed to claim worktree"}
|
|
520
|
+
|
|
521
|
+
return {}
|
|
522
|
+
|
|
523
|
+
@registry.tool(
|
|
524
|
+
name="release_worktree",
|
|
525
|
+
description="Release ownership of a worktree.",
|
|
526
|
+
)
|
|
527
|
+
async def release_worktree(worktree_id: str) -> dict[str, Any]:
|
|
528
|
+
"""
|
|
529
|
+
Release a worktree from its current owner.
|
|
530
|
+
|
|
531
|
+
Args:
|
|
532
|
+
worktree_id: The worktree ID to release.
|
|
533
|
+
|
|
534
|
+
Returns:
|
|
535
|
+
Dict with success status.
|
|
536
|
+
"""
|
|
537
|
+
worktree = worktree_storage.get(worktree_id)
|
|
538
|
+
if not worktree:
|
|
539
|
+
return {"error": f"Worktree '{worktree_id}' not found"}
|
|
540
|
+
|
|
541
|
+
updated = worktree_storage.release(worktree_id)
|
|
542
|
+
if not updated:
|
|
543
|
+
return {"error": "Failed to release worktree"}
|
|
544
|
+
|
|
545
|
+
return {}
|
|
546
|
+
|
|
547
|
+
@registry.tool(
|
|
548
|
+
name="delete_worktree",
|
|
549
|
+
description="Delete a worktree (both git and database record).",
|
|
550
|
+
)
|
|
551
|
+
async def delete_worktree(
|
|
552
|
+
worktree_id: str,
|
|
553
|
+
force: bool = False,
|
|
554
|
+
project_path: str | None = None,
|
|
555
|
+
) -> dict[str, Any]:
|
|
556
|
+
"""
|
|
557
|
+
Delete a worktree completely (handles all cleanup).
|
|
558
|
+
|
|
559
|
+
This is the proper way to remove a worktree. It handles:
|
|
560
|
+
- Removes the worktree directory and all temporary files
|
|
561
|
+
- Cleans up git's worktree tracking (.git/worktrees/)
|
|
562
|
+
- Deletes the associated git branch
|
|
563
|
+
- Removes the Gobby database record
|
|
564
|
+
|
|
565
|
+
Do NOT manually run `git worktree remove` - use this tool instead.
|
|
566
|
+
|
|
567
|
+
Args:
|
|
568
|
+
worktree_id: The worktree ID to delete (e.g., "wt-abc123").
|
|
569
|
+
force: Force deletion even if there are uncommitted changes.
|
|
570
|
+
project_path: Optional path to project root to resolve git context.
|
|
571
|
+
|
|
572
|
+
Returns:
|
|
573
|
+
Dict with success status.
|
|
574
|
+
"""
|
|
575
|
+
worktree = worktree_storage.get(worktree_id)
|
|
576
|
+
|
|
577
|
+
if not worktree:
|
|
578
|
+
return {
|
|
579
|
+
"success": False,
|
|
580
|
+
"error": f"Worktree '{worktree_id}' not found",
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
# Resolve git manager
|
|
584
|
+
resolved_git_mgr = git_manager # Start with the module-level git_manager
|
|
585
|
+
if project_path:
|
|
586
|
+
try:
|
|
587
|
+
# _resolve_project_context is defined in this module scope
|
|
588
|
+
mgr, _, _ = _resolve_project_context(project_path, resolved_git_mgr, None)
|
|
589
|
+
if mgr:
|
|
590
|
+
resolved_git_mgr = mgr
|
|
591
|
+
except Exception:
|
|
592
|
+
# nosec B110 - if context resolution fails, continue without git manager
|
|
593
|
+
pass
|
|
594
|
+
|
|
595
|
+
# Check for uncommitted changes if not forcing
|
|
596
|
+
if resolved_git_mgr and Path(worktree.worktree_path).exists():
|
|
597
|
+
status = resolved_git_mgr.get_worktree_status(worktree.worktree_path)
|
|
598
|
+
if status and status.has_uncommitted_changes and not force:
|
|
599
|
+
return {
|
|
600
|
+
"success": False,
|
|
601
|
+
"error": "Worktree has uncommitted changes. Use force=True to delete anyway.",
|
|
602
|
+
"uncommitted_changes": True,
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
# Delete git worktree
|
|
606
|
+
if resolved_git_mgr:
|
|
607
|
+
result = resolved_git_mgr.delete_worktree(
|
|
608
|
+
worktree.worktree_path,
|
|
609
|
+
force=force,
|
|
610
|
+
delete_branch=True,
|
|
611
|
+
branch_name=worktree.branch_name,
|
|
612
|
+
)
|
|
613
|
+
if not result.success:
|
|
614
|
+
return {
|
|
615
|
+
"success": False,
|
|
616
|
+
"error": result.error or "Failed to delete git worktree",
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
# Delete database record
|
|
620
|
+
deleted = worktree_storage.delete(worktree_id)
|
|
621
|
+
if not deleted:
|
|
622
|
+
return {"error": "Failed to delete worktree record"}
|
|
623
|
+
|
|
624
|
+
return {}
|
|
625
|
+
|
|
626
|
+
@registry.tool(
|
|
627
|
+
name="sync_worktree",
|
|
628
|
+
description="Sync a worktree with the main branch.",
|
|
629
|
+
)
|
|
630
|
+
async def sync_worktree(
|
|
631
|
+
worktree_id: str,
|
|
632
|
+
strategy: str = "merge",
|
|
633
|
+
project_path: str | None = None,
|
|
634
|
+
) -> dict[str, Any]:
|
|
635
|
+
"""
|
|
636
|
+
Sync a worktree with the main branch.
|
|
637
|
+
|
|
638
|
+
Args:
|
|
639
|
+
worktree_id: The worktree ID to sync.
|
|
640
|
+
strategy: Sync strategy ('merge' or 'rebase').
|
|
641
|
+
project_path: Path to project directory (pass cwd from CLI).
|
|
642
|
+
|
|
643
|
+
Returns:
|
|
644
|
+
Dict with sync result.
|
|
645
|
+
"""
|
|
646
|
+
# Resolve git manager from project_path or fall back to default
|
|
647
|
+
resolved_git_mgr, _, error = _resolve_project_context(project_path, git_manager, project_id)
|
|
648
|
+
if error:
|
|
649
|
+
return {"success": False, "error": error}
|
|
650
|
+
|
|
651
|
+
if resolved_git_mgr is None:
|
|
652
|
+
return {
|
|
653
|
+
"success": False,
|
|
654
|
+
"error": "Git manager not configured and no project_path provided.",
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
worktree = worktree_storage.get(worktree_id)
|
|
658
|
+
if not worktree:
|
|
659
|
+
return {
|
|
660
|
+
"success": False,
|
|
661
|
+
"error": f"Worktree '{worktree_id}' not found",
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
# Validate strategy
|
|
665
|
+
if strategy not in ("rebase", "merge"):
|
|
666
|
+
return {
|
|
667
|
+
"success": False,
|
|
668
|
+
"error": f"Invalid strategy '{strategy}'. Must be 'rebase' or 'merge'.",
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
strategy_literal = cast(Literal["rebase", "merge"], strategy)
|
|
672
|
+
|
|
673
|
+
result = resolved_git_mgr.sync_from_main(
|
|
674
|
+
worktree.worktree_path,
|
|
675
|
+
base_branch=worktree.base_branch,
|
|
676
|
+
strategy=strategy_literal,
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
if not result.success:
|
|
680
|
+
return {
|
|
681
|
+
"success": False,
|
|
682
|
+
"error": result.error or "Sync failed",
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
# Update last activity
|
|
686
|
+
worktree_storage.update(worktree_id)
|
|
687
|
+
|
|
688
|
+
return {
|
|
689
|
+
"success": True,
|
|
690
|
+
"message": result.message,
|
|
691
|
+
"output": result.output,
|
|
692
|
+
"strategy": strategy,
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
@registry.tool(
|
|
696
|
+
name="mark_worktree_merged",
|
|
697
|
+
description="Mark a worktree as merged (ready for cleanup).",
|
|
698
|
+
)
|
|
699
|
+
async def mark_worktree_merged(worktree_id: str) -> dict[str, Any]:
|
|
700
|
+
"""
|
|
701
|
+
Mark a worktree as merged.
|
|
702
|
+
|
|
703
|
+
Args:
|
|
704
|
+
worktree_id: The worktree ID to mark.
|
|
705
|
+
|
|
706
|
+
Returns:
|
|
707
|
+
Dict with success status.
|
|
708
|
+
"""
|
|
709
|
+
worktree = worktree_storage.get(worktree_id)
|
|
710
|
+
if not worktree:
|
|
711
|
+
return {
|
|
712
|
+
"success": False,
|
|
713
|
+
"error": f"Worktree '{worktree_id}' not found",
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
updated = worktree_storage.mark_merged(worktree_id)
|
|
717
|
+
if not updated:
|
|
718
|
+
return {"error": "Failed to mark worktree as merged"}
|
|
719
|
+
|
|
720
|
+
return {}
|
|
721
|
+
|
|
722
|
+
@registry.tool(
|
|
723
|
+
name="detect_stale_worktrees",
|
|
724
|
+
description="Find worktrees with no activity for a period.",
|
|
725
|
+
)
|
|
726
|
+
async def detect_stale_worktrees(
|
|
727
|
+
project_path: str | None = None,
|
|
728
|
+
hours: int = 24,
|
|
729
|
+
limit: int = 50,
|
|
730
|
+
) -> dict[str, Any]:
|
|
731
|
+
"""
|
|
732
|
+
Find stale worktrees (no activity for N hours).
|
|
733
|
+
|
|
734
|
+
Args:
|
|
735
|
+
project_path: Path to project directory (pass cwd from CLI).
|
|
736
|
+
hours: Hours of inactivity threshold (default: 24).
|
|
737
|
+
limit: Maximum results (default: 50).
|
|
738
|
+
|
|
739
|
+
Returns:
|
|
740
|
+
Dict with list of stale worktrees.
|
|
741
|
+
"""
|
|
742
|
+
_, resolved_project_id, error = _resolve_project_context(
|
|
743
|
+
project_path, git_manager, project_id
|
|
744
|
+
)
|
|
745
|
+
if error:
|
|
746
|
+
return {"success": False, "error": error}
|
|
747
|
+
if resolved_project_id is None:
|
|
748
|
+
return {"success": False, "error": "Could not resolve project ID"}
|
|
749
|
+
|
|
750
|
+
stale = worktree_storage.find_stale(
|
|
751
|
+
project_id=resolved_project_id,
|
|
752
|
+
hours=hours,
|
|
753
|
+
limit=limit,
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
return {
|
|
757
|
+
"success": True,
|
|
758
|
+
"stale_worktrees": [
|
|
759
|
+
{
|
|
760
|
+
"id": wt.id,
|
|
761
|
+
"branch_name": wt.branch_name,
|
|
762
|
+
"worktree_path": wt.worktree_path,
|
|
763
|
+
"updated_at": wt.updated_at,
|
|
764
|
+
"task_id": wt.task_id,
|
|
765
|
+
}
|
|
766
|
+
for wt in stale
|
|
767
|
+
],
|
|
768
|
+
"count": len(stale),
|
|
769
|
+
"threshold_hours": hours,
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
@registry.tool(
|
|
773
|
+
name="cleanup_stale_worktrees",
|
|
774
|
+
description="Mark and optionally delete stale worktrees.",
|
|
775
|
+
)
|
|
776
|
+
async def cleanup_stale_worktrees(
|
|
777
|
+
project_path: str | None = None,
|
|
778
|
+
hours: int = 24,
|
|
779
|
+
dry_run: bool = True,
|
|
780
|
+
delete_git: bool = False,
|
|
781
|
+
) -> dict[str, Any]:
|
|
782
|
+
"""
|
|
783
|
+
Cleanup stale worktrees.
|
|
784
|
+
|
|
785
|
+
Args:
|
|
786
|
+
project_path: Path to project directory (pass cwd from CLI).
|
|
787
|
+
hours: Hours of inactivity threshold (default: 24).
|
|
788
|
+
dry_run: If True, only report what would be cleaned (default: True).
|
|
789
|
+
delete_git: If True, also delete git worktrees (default: False).
|
|
790
|
+
|
|
791
|
+
Returns:
|
|
792
|
+
Dict with cleanup results.
|
|
793
|
+
"""
|
|
794
|
+
resolved_git_manager, resolved_project_id, error = _resolve_project_context(
|
|
795
|
+
project_path, git_manager, project_id
|
|
796
|
+
)
|
|
797
|
+
if error:
|
|
798
|
+
return {"success": False, "error": error}
|
|
799
|
+
if resolved_project_id is None:
|
|
800
|
+
return {"success": False, "error": "Could not resolve project ID"}
|
|
801
|
+
|
|
802
|
+
# Find and mark stale worktrees
|
|
803
|
+
stale = worktree_storage.cleanup_stale(
|
|
804
|
+
project_id=resolved_project_id,
|
|
805
|
+
hours=hours,
|
|
806
|
+
dry_run=dry_run,
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
results = []
|
|
810
|
+
for wt in stale:
|
|
811
|
+
result = {
|
|
812
|
+
"id": wt.id,
|
|
813
|
+
"branch_name": wt.branch_name,
|
|
814
|
+
"worktree_path": wt.worktree_path,
|
|
815
|
+
"marked_abandoned": not dry_run,
|
|
816
|
+
"git_deleted": False,
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
# Optionally delete git worktrees
|
|
820
|
+
if delete_git and not dry_run and resolved_git_manager:
|
|
821
|
+
git_result = resolved_git_manager.delete_worktree(
|
|
822
|
+
wt.worktree_path,
|
|
823
|
+
force=True,
|
|
824
|
+
delete_branch=True,
|
|
825
|
+
branch_name=wt.branch_name,
|
|
826
|
+
)
|
|
827
|
+
result["git_deleted"] = git_result.success
|
|
828
|
+
if not git_result.success:
|
|
829
|
+
result["git_error"] = git_result.error or "Unknown error"
|
|
830
|
+
|
|
831
|
+
results.append(result)
|
|
832
|
+
|
|
833
|
+
return {
|
|
834
|
+
"success": True,
|
|
835
|
+
"dry_run": dry_run,
|
|
836
|
+
"cleaned": results,
|
|
837
|
+
"count": len(results),
|
|
838
|
+
"threshold_hours": hours,
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
@registry.tool(
|
|
842
|
+
name="get_worktree_stats",
|
|
843
|
+
description="Get worktree statistics for the project.",
|
|
844
|
+
)
|
|
845
|
+
async def get_worktree_stats(project_path: str | None = None) -> dict[str, Any]:
|
|
846
|
+
"""
|
|
847
|
+
Get worktree statistics.
|
|
848
|
+
|
|
849
|
+
Args:
|
|
850
|
+
project_path: Path to project directory (pass cwd from CLI).
|
|
851
|
+
|
|
852
|
+
Returns:
|
|
853
|
+
Dict with counts by status.
|
|
854
|
+
"""
|
|
855
|
+
# Resolve project context (git_manager not needed for stats)
|
|
856
|
+
_, resolved_project_id, error = _resolve_project_context(
|
|
857
|
+
project_path, git_manager, project_id
|
|
858
|
+
)
|
|
859
|
+
if error:
|
|
860
|
+
return {"success": False, "error": error}
|
|
861
|
+
|
|
862
|
+
# Type narrowing: if no error, resolved_project_id is guaranteed non-None
|
|
863
|
+
if resolved_project_id is None:
|
|
864
|
+
raise RuntimeError("Project ID unexpectedly None")
|
|
865
|
+
|
|
866
|
+
counts = worktree_storage.count_by_status(resolved_project_id)
|
|
867
|
+
|
|
868
|
+
return {
|
|
869
|
+
"success": True,
|
|
870
|
+
"project_id": resolved_project_id,
|
|
871
|
+
"counts": counts,
|
|
872
|
+
"total": sum(counts.values()),
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
@registry.tool(
|
|
876
|
+
name="get_worktree_by_task",
|
|
877
|
+
description="Get worktree linked to a specific task.",
|
|
878
|
+
)
|
|
879
|
+
async def get_worktree_by_task(task_id: str) -> dict[str, Any]:
|
|
880
|
+
"""
|
|
881
|
+
Get worktree linked to a task.
|
|
882
|
+
|
|
883
|
+
Args:
|
|
884
|
+
task_id: The task ID to look up.
|
|
885
|
+
|
|
886
|
+
Returns:
|
|
887
|
+
Dict with worktree details or not found.
|
|
888
|
+
"""
|
|
889
|
+
worktree = worktree_storage.get_by_task(task_id)
|
|
890
|
+
if not worktree:
|
|
891
|
+
return {
|
|
892
|
+
"success": False,
|
|
893
|
+
"error": f"No worktree linked to task '{task_id}'",
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
return {
|
|
897
|
+
"success": True,
|
|
898
|
+
"worktree": worktree.to_dict(),
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
@registry.tool(
|
|
902
|
+
name="link_task_to_worktree",
|
|
903
|
+
description="Link a task to an existing worktree.",
|
|
904
|
+
)
|
|
905
|
+
async def link_task_to_worktree(
|
|
906
|
+
worktree_id: str,
|
|
907
|
+
task_id: str,
|
|
908
|
+
) -> dict[str, Any]:
|
|
909
|
+
"""
|
|
910
|
+
Link a task to a worktree.
|
|
911
|
+
|
|
912
|
+
Args:
|
|
913
|
+
worktree_id: The worktree ID.
|
|
914
|
+
task_id: The task ID to link.
|
|
915
|
+
|
|
916
|
+
Returns:
|
|
917
|
+
Dict with success status.
|
|
918
|
+
"""
|
|
919
|
+
worktree = worktree_storage.get(worktree_id)
|
|
920
|
+
if not worktree:
|
|
921
|
+
return {
|
|
922
|
+
"success": False,
|
|
923
|
+
"error": f"Worktree '{worktree_id}' not found",
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
updated = worktree_storage.update(worktree_id, task_id=task_id)
|
|
927
|
+
if not updated:
|
|
928
|
+
return {"error": "Failed to link task to worktree"}
|
|
929
|
+
|
|
930
|
+
return {}
|
|
931
|
+
|
|
932
|
+
@registry.tool(
|
|
933
|
+
name="spawn_agent_in_worktree",
|
|
934
|
+
description="Create a worktree and spawn an agent in it.",
|
|
935
|
+
)
|
|
936
|
+
async def spawn_agent_in_worktree(
|
|
937
|
+
prompt: str,
|
|
938
|
+
branch_name: str,
|
|
939
|
+
base_branch: str = "main",
|
|
940
|
+
task_id: str | None = None,
|
|
941
|
+
parent_session_id: str | None = None,
|
|
942
|
+
mode: str = "terminal", # Note: in_process mode is not supported
|
|
943
|
+
terminal: str = "auto",
|
|
944
|
+
provider: Literal["claude", "gemini", "codex", "antigravity"] = "claude",
|
|
945
|
+
model: str | None = None,
|
|
946
|
+
workflow: str | None = None,
|
|
947
|
+
timeout: float = 120.0,
|
|
948
|
+
max_turns: int = 10,
|
|
949
|
+
project_path: str | None = None,
|
|
950
|
+
) -> dict[str, Any]:
|
|
951
|
+
"""
|
|
952
|
+
Create a worktree and spawn an agent to work in it.
|
|
953
|
+
|
|
954
|
+
This combines worktree creation with agent spawning for isolated development.
|
|
955
|
+
|
|
956
|
+
Args:
|
|
957
|
+
prompt: The task/prompt for the agent.
|
|
958
|
+
branch_name: Name for the new branch/worktree.
|
|
959
|
+
base_branch: Branch to base the worktree on (default: main).
|
|
960
|
+
task_id: Optional task ID to link to this worktree.
|
|
961
|
+
parent_session_id: Parent session ID for context.
|
|
962
|
+
mode: Execution mode (terminal, embedded, headless). Note: in_process is not supported.
|
|
963
|
+
terminal: Terminal for terminal/embedded modes (auto, ghostty, etc.).
|
|
964
|
+
provider: LLM provider (claude, gemini, etc.).
|
|
965
|
+
model: Optional model override.
|
|
966
|
+
workflow: Workflow name to execute.
|
|
967
|
+
timeout: Execution timeout in seconds (default: 120).
|
|
968
|
+
max_turns: Maximum turns (default: 10).
|
|
969
|
+
project_path: Path to project directory (pass cwd from CLI).
|
|
970
|
+
|
|
971
|
+
Returns:
|
|
972
|
+
Dict with worktree_id, run_id, and status.
|
|
973
|
+
"""
|
|
974
|
+
if agent_runner is None:
|
|
975
|
+
return {
|
|
976
|
+
"success": False,
|
|
977
|
+
"error": "Agent runner not configured. Cannot spawn agent.",
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
# Resolve project context
|
|
981
|
+
resolved_git_mgr, resolved_project_id, error = _resolve_project_context(
|
|
982
|
+
project_path, git_manager, project_id
|
|
983
|
+
)
|
|
984
|
+
if error:
|
|
985
|
+
return {"success": False, "error": error}
|
|
986
|
+
|
|
987
|
+
# Type narrowing: if no error, these are guaranteed non-None
|
|
988
|
+
if resolved_git_mgr is None or resolved_project_id is None:
|
|
989
|
+
raise RuntimeError("Git manager or project ID unexpectedly None")
|
|
990
|
+
|
|
991
|
+
if parent_session_id is None:
|
|
992
|
+
return {
|
|
993
|
+
"success": False,
|
|
994
|
+
"error": "parent_session_id is required for agent spawning.",
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
# Handle mode aliases and validation
|
|
998
|
+
# "interactive" is an alias for "terminal" mode
|
|
999
|
+
if mode == "interactive":
|
|
1000
|
+
mode = "terminal"
|
|
1001
|
+
|
|
1002
|
+
valid_modes = ["terminal", "embedded", "headless"]
|
|
1003
|
+
if mode not in valid_modes:
|
|
1004
|
+
return {
|
|
1005
|
+
"success": False,
|
|
1006
|
+
"error": (
|
|
1007
|
+
f"Invalid mode '{mode}'. Must be one of: {', '.join(valid_modes)} (or 'interactive' as alias for 'terminal'). "
|
|
1008
|
+
f"Note: 'in_process' mode is not supported for spawn_agent_in_worktree."
|
|
1009
|
+
),
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
# Normalize terminal parameter to lowercase for enum compatibility
|
|
1013
|
+
# (TerminalType enum values are lowercase, e.g., "terminal.app" not "Terminal.app")
|
|
1014
|
+
if isinstance(terminal, str):
|
|
1015
|
+
terminal = terminal.lower()
|
|
1016
|
+
|
|
1017
|
+
# Validate workflow (reject lifecycle workflows)
|
|
1018
|
+
if workflow:
|
|
1019
|
+
workflow_loader = WorkflowLoader()
|
|
1020
|
+
is_valid, error_msg = workflow_loader.validate_workflow_for_agent(
|
|
1021
|
+
workflow, project_path=project_path
|
|
1022
|
+
)
|
|
1023
|
+
if not is_valid:
|
|
1024
|
+
return {
|
|
1025
|
+
"success": False,
|
|
1026
|
+
"error": error_msg,
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
# Check if worktree already exists for this branch
|
|
1030
|
+
existing = worktree_storage.get_by_branch(resolved_project_id, branch_name)
|
|
1031
|
+
if existing:
|
|
1032
|
+
# Use existing worktree
|
|
1033
|
+
worktree = existing
|
|
1034
|
+
logger.info(f"Using existing worktree for branch '{branch_name}'")
|
|
1035
|
+
else:
|
|
1036
|
+
# Generate worktree path in temp directory
|
|
1037
|
+
project_name = Path(resolved_git_mgr.repo_path).name
|
|
1038
|
+
worktree_path = _generate_worktree_path(branch_name, project_name)
|
|
1039
|
+
|
|
1040
|
+
# Create git worktree
|
|
1041
|
+
result = resolved_git_mgr.create_worktree(
|
|
1042
|
+
worktree_path=worktree_path,
|
|
1043
|
+
branch_name=branch_name,
|
|
1044
|
+
base_branch=base_branch,
|
|
1045
|
+
create_branch=True,
|
|
1046
|
+
)
|
|
1047
|
+
|
|
1048
|
+
if not result.success:
|
|
1049
|
+
return {
|
|
1050
|
+
"success": False,
|
|
1051
|
+
"error": result.error or "Failed to create git worktree",
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
# Record in database
|
|
1055
|
+
worktree = worktree_storage.create(
|
|
1056
|
+
project_id=resolved_project_id,
|
|
1057
|
+
branch_name=branch_name,
|
|
1058
|
+
worktree_path=worktree_path,
|
|
1059
|
+
base_branch=base_branch,
|
|
1060
|
+
task_id=task_id,
|
|
1061
|
+
)
|
|
1062
|
+
|
|
1063
|
+
# Copy project.json and install provider hooks
|
|
1064
|
+
_copy_project_json_to_worktree(resolved_git_mgr.repo_path, worktree.worktree_path)
|
|
1065
|
+
_install_provider_hooks(provider, worktree.worktree_path)
|
|
1066
|
+
|
|
1067
|
+
# Check spawn depth limit
|
|
1068
|
+
can_spawn, reason, _depth = agent_runner.can_spawn(parent_session_id)
|
|
1069
|
+
if not can_spawn:
|
|
1070
|
+
return {
|
|
1071
|
+
"success": False,
|
|
1072
|
+
"error": reason,
|
|
1073
|
+
"worktree_id": worktree.id,
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
# Import AgentConfig and get machine_id
|
|
1077
|
+
from gobby.agents.runner import AgentConfig
|
|
1078
|
+
from gobby.utils.machine_id import get_machine_id
|
|
1079
|
+
|
|
1080
|
+
# Auto-detect machine_id if not provided
|
|
1081
|
+
machine_id = get_machine_id()
|
|
1082
|
+
|
|
1083
|
+
# Create agent config with worktree
|
|
1084
|
+
config = AgentConfig(
|
|
1085
|
+
prompt=prompt,
|
|
1086
|
+
parent_session_id=parent_session_id,
|
|
1087
|
+
project_id=resolved_project_id,
|
|
1088
|
+
machine_id=machine_id,
|
|
1089
|
+
source=provider,
|
|
1090
|
+
workflow=workflow,
|
|
1091
|
+
task=task_id,
|
|
1092
|
+
session_context="summary_markdown",
|
|
1093
|
+
mode=mode,
|
|
1094
|
+
terminal=terminal,
|
|
1095
|
+
worktree_id=worktree.id,
|
|
1096
|
+
provider=provider,
|
|
1097
|
+
model=model,
|
|
1098
|
+
max_turns=max_turns,
|
|
1099
|
+
timeout=timeout,
|
|
1100
|
+
project_path=worktree.worktree_path,
|
|
1101
|
+
)
|
|
1102
|
+
|
|
1103
|
+
# For terminal/embedded/headless modes, use prepare_run + spawner
|
|
1104
|
+
# (runner.run() is only for in_process mode)
|
|
1105
|
+
from gobby.llm.executor import AgentResult
|
|
1106
|
+
|
|
1107
|
+
prepare_result = agent_runner.prepare_run(config)
|
|
1108
|
+
if isinstance(prepare_result, AgentResult):
|
|
1109
|
+
# prepare_run returns AgentResult on error
|
|
1110
|
+
return {
|
|
1111
|
+
"success": False,
|
|
1112
|
+
"worktree_id": worktree.id,
|
|
1113
|
+
"worktree_path": worktree.worktree_path,
|
|
1114
|
+
"branch_name": worktree.branch_name,
|
|
1115
|
+
"error": prepare_result.error,
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
# Successfully prepared - we have context with session and run
|
|
1119
|
+
context = prepare_result
|
|
1120
|
+
|
|
1121
|
+
if context.session is None or context.run is None:
|
|
1122
|
+
return {
|
|
1123
|
+
"success": False,
|
|
1124
|
+
"worktree_id": worktree.id,
|
|
1125
|
+
"error": "Internal error: context missing session or run after prepare_run",
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
child_session = context.session
|
|
1129
|
+
agent_run = context.run
|
|
1130
|
+
|
|
1131
|
+
# Claim worktree for the child session
|
|
1132
|
+
worktree_storage.claim(worktree.id, child_session.id)
|
|
1133
|
+
|
|
1134
|
+
# Pre-save workflow state with session_task if task_id is provided
|
|
1135
|
+
# This ensures suggest_next_task() will scope to this task's subtasks
|
|
1136
|
+
if task_id and workflow:
|
|
1137
|
+
try:
|
|
1138
|
+
workflow_state_manager = WorkflowStateManager(worktree_storage.db)
|
|
1139
|
+
initial_state = WorkflowState(
|
|
1140
|
+
session_id=child_session.id,
|
|
1141
|
+
workflow_name=workflow,
|
|
1142
|
+
step="", # Will be set when workflow actually starts
|
|
1143
|
+
variables={"session_task": task_id},
|
|
1144
|
+
)
|
|
1145
|
+
workflow_state_manager.save_state(initial_state)
|
|
1146
|
+
logger.debug(
|
|
1147
|
+
f"Pre-saved workflow state for session {child_session.id} "
|
|
1148
|
+
f"with session_task={task_id}"
|
|
1149
|
+
)
|
|
1150
|
+
except Exception as e:
|
|
1151
|
+
logger.warning(f"Failed to pre-save workflow state: {e}")
|
|
1152
|
+
# Continue anyway - this is an optimization, not a requirement
|
|
1153
|
+
|
|
1154
|
+
# Build enhanced prompt with worktree context
|
|
1155
|
+
# This helps the agent understand it's in an isolated worktree, not the main repo
|
|
1156
|
+
enhanced_prompt = _build_worktree_context_prompt(
|
|
1157
|
+
original_prompt=prompt,
|
|
1158
|
+
worktree_path=worktree.worktree_path,
|
|
1159
|
+
branch_name=worktree.branch_name,
|
|
1160
|
+
task_id=task_id,
|
|
1161
|
+
main_repo_path=str(resolved_git_mgr.repo_path),
|
|
1162
|
+
)
|
|
1163
|
+
|
|
1164
|
+
# Spawn in terminal using TerminalSpawner
|
|
1165
|
+
if mode == "terminal":
|
|
1166
|
+
from gobby.agents.spawn import TerminalSpawner
|
|
1167
|
+
|
|
1168
|
+
terminal_spawner = TerminalSpawner()
|
|
1169
|
+
terminal_result = terminal_spawner.spawn_agent(
|
|
1170
|
+
cli=provider, # claude, gemini, codex
|
|
1171
|
+
cwd=worktree.worktree_path,
|
|
1172
|
+
session_id=child_session.id,
|
|
1173
|
+
parent_session_id=parent_session_id,
|
|
1174
|
+
agent_run_id=agent_run.id,
|
|
1175
|
+
project_id=resolved_project_id,
|
|
1176
|
+
workflow_name=workflow,
|
|
1177
|
+
agent_depth=child_session.agent_depth,
|
|
1178
|
+
max_agent_depth=agent_runner._child_session_manager.max_agent_depth,
|
|
1179
|
+
terminal=terminal,
|
|
1180
|
+
prompt=enhanced_prompt,
|
|
1181
|
+
)
|
|
1182
|
+
|
|
1183
|
+
if not terminal_result.success:
|
|
1184
|
+
return {
|
|
1185
|
+
"success": False,
|
|
1186
|
+
"worktree_id": worktree.id,
|
|
1187
|
+
"worktree_path": worktree.worktree_path,
|
|
1188
|
+
"branch_name": worktree.branch_name,
|
|
1189
|
+
"run_id": agent_run.id,
|
|
1190
|
+
"child_session_id": child_session.id,
|
|
1191
|
+
"error": terminal_result.error or terminal_result.message,
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
return {
|
|
1195
|
+
"success": True,
|
|
1196
|
+
"worktree_id": worktree.id,
|
|
1197
|
+
"worktree_path": worktree.worktree_path,
|
|
1198
|
+
"branch_name": worktree.branch_name,
|
|
1199
|
+
"run_id": agent_run.id,
|
|
1200
|
+
"child_session_id": child_session.id,
|
|
1201
|
+
"status": "pending",
|
|
1202
|
+
"message": f"Agent spawned in {terminal_result.terminal_type} (PID: {terminal_result.pid})",
|
|
1203
|
+
"terminal_type": terminal_result.terminal_type,
|
|
1204
|
+
"pid": terminal_result.pid,
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
elif mode == "embedded":
|
|
1208
|
+
from gobby.agents.spawn import EmbeddedSpawner
|
|
1209
|
+
|
|
1210
|
+
embedded_spawner = EmbeddedSpawner()
|
|
1211
|
+
embedded_result = embedded_spawner.spawn_agent(
|
|
1212
|
+
cli=provider,
|
|
1213
|
+
cwd=worktree.worktree_path,
|
|
1214
|
+
session_id=child_session.id,
|
|
1215
|
+
parent_session_id=parent_session_id,
|
|
1216
|
+
agent_run_id=agent_run.id,
|
|
1217
|
+
project_id=resolved_project_id,
|
|
1218
|
+
workflow_name=workflow,
|
|
1219
|
+
agent_depth=child_session.agent_depth,
|
|
1220
|
+
max_agent_depth=agent_runner._child_session_manager.max_agent_depth,
|
|
1221
|
+
prompt=enhanced_prompt,
|
|
1222
|
+
)
|
|
1223
|
+
|
|
1224
|
+
return {
|
|
1225
|
+
"success": embedded_result.success,
|
|
1226
|
+
"worktree_id": worktree.id,
|
|
1227
|
+
"worktree_path": worktree.worktree_path,
|
|
1228
|
+
"branch_name": worktree.branch_name,
|
|
1229
|
+
"run_id": agent_run.id,
|
|
1230
|
+
"child_session_id": child_session.id,
|
|
1231
|
+
"status": "pending" if embedded_result.success else "error",
|
|
1232
|
+
"error": embedded_result.error if not embedded_result.success else None,
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
else: # headless
|
|
1236
|
+
from gobby.agents.spawn import HeadlessSpawner
|
|
1237
|
+
|
|
1238
|
+
headless_spawner = HeadlessSpawner()
|
|
1239
|
+
headless_result = headless_spawner.spawn_agent(
|
|
1240
|
+
cli=provider,
|
|
1241
|
+
cwd=worktree.worktree_path,
|
|
1242
|
+
session_id=child_session.id,
|
|
1243
|
+
parent_session_id=parent_session_id,
|
|
1244
|
+
agent_run_id=agent_run.id,
|
|
1245
|
+
project_id=resolved_project_id,
|
|
1246
|
+
workflow_name=workflow,
|
|
1247
|
+
agent_depth=child_session.agent_depth,
|
|
1248
|
+
max_agent_depth=agent_runner._child_session_manager.max_agent_depth,
|
|
1249
|
+
prompt=enhanced_prompt,
|
|
1250
|
+
)
|
|
1251
|
+
|
|
1252
|
+
return {
|
|
1253
|
+
"success": headless_result.success,
|
|
1254
|
+
"worktree_id": worktree.id,
|
|
1255
|
+
"worktree_path": worktree.worktree_path,
|
|
1256
|
+
"branch_name": worktree.branch_name,
|
|
1257
|
+
"run_id": agent_run.id,
|
|
1258
|
+
"child_session_id": child_session.id,
|
|
1259
|
+
"status": "pending" if headless_result.success else "error",
|
|
1260
|
+
"pid": headless_result.pid if headless_result.success else None,
|
|
1261
|
+
"error": headless_result.error if not headless_result.success else None,
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
return registry
|