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,383 @@
|
|
|
1
|
+
"""Stuck detection for autonomous session management.
|
|
2
|
+
|
|
3
|
+
Provides multi-layer stuck detection for autonomous workflows:
|
|
4
|
+
1. Task selection loop detection - same tasks being selected repeatedly
|
|
5
|
+
2. Progress stagnation - no meaningful progress being made
|
|
6
|
+
3. Tool call patterns - repeated identical tool calls
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import threading
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from gobby.autonomous.progress_tracker import ProgressTracker
|
|
19
|
+
from gobby.storage.database import LocalDatabase
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class TaskSelectionEvent:
|
|
26
|
+
"""A task selection event for loop detection."""
|
|
27
|
+
|
|
28
|
+
session_id: str
|
|
29
|
+
task_id: str
|
|
30
|
+
selected_at: datetime
|
|
31
|
+
context: dict[str, Any] | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class StuckDetectionResult:
|
|
36
|
+
"""Result of stuck detection analysis."""
|
|
37
|
+
|
|
38
|
+
is_stuck: bool
|
|
39
|
+
reason: str | None = None
|
|
40
|
+
layer: str | None = None # task_loop, progress_stagnation, tool_loop
|
|
41
|
+
details: dict[str, Any] | None = None
|
|
42
|
+
suggested_action: str | None = None # stop, change_approach, escalate
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class StuckDetector:
|
|
46
|
+
"""Multi-layer stuck detection for autonomous sessions.
|
|
47
|
+
|
|
48
|
+
The stuck detector analyzes session behavior at three levels:
|
|
49
|
+
|
|
50
|
+
Layer 1 - Task Selection Loops:
|
|
51
|
+
Detects when the same task(s) are being selected repeatedly
|
|
52
|
+
without successful completion. This indicates the agent is
|
|
53
|
+
unable to make progress on available work.
|
|
54
|
+
|
|
55
|
+
Layer 2 - Progress Stagnation:
|
|
56
|
+
Uses ProgressTracker to detect when no meaningful progress
|
|
57
|
+
(file modifications, commits, task completions) is occurring
|
|
58
|
+
despite continued activity.
|
|
59
|
+
|
|
60
|
+
Layer 3 - Tool Call Patterns:
|
|
61
|
+
Detects repeated identical tool calls that indicate the agent
|
|
62
|
+
is stuck in a loop (e.g., repeatedly reading the same file).
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
# Thresholds for loop detection
|
|
66
|
+
DEFAULT_TASK_LOOP_THRESHOLD = 3 # Same task selected N times = loop
|
|
67
|
+
DEFAULT_TASK_WINDOW_SIZE = 10 # Look at last N selections
|
|
68
|
+
DEFAULT_TOOL_LOOP_THRESHOLD = 5 # Same tool call N times = loop
|
|
69
|
+
DEFAULT_TOOL_WINDOW_SIZE = 20 # Look at last N tool calls
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
db: "LocalDatabase",
|
|
74
|
+
progress_tracker: "ProgressTracker | None" = None,
|
|
75
|
+
task_loop_threshold: int | None = None,
|
|
76
|
+
task_window_size: int | None = None,
|
|
77
|
+
tool_loop_threshold: int | None = None,
|
|
78
|
+
tool_window_size: int | None = None,
|
|
79
|
+
):
|
|
80
|
+
"""Initialize the stuck detector.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
db: Database connection for persistent storage
|
|
84
|
+
progress_tracker: Optional ProgressTracker for stagnation detection
|
|
85
|
+
task_loop_threshold: Times a task can be selected before considered stuck
|
|
86
|
+
task_window_size: Number of recent selections to analyze
|
|
87
|
+
tool_loop_threshold: Times same tool call before considered stuck
|
|
88
|
+
tool_window_size: Number of recent tool calls to analyze
|
|
89
|
+
"""
|
|
90
|
+
self.db = db
|
|
91
|
+
self.progress_tracker = progress_tracker
|
|
92
|
+
self._lock = threading.Lock()
|
|
93
|
+
|
|
94
|
+
self.task_loop_threshold = task_loop_threshold or self.DEFAULT_TASK_LOOP_THRESHOLD
|
|
95
|
+
self.task_window_size = task_window_size or self.DEFAULT_TASK_WINDOW_SIZE
|
|
96
|
+
self.tool_loop_threshold = tool_loop_threshold or self.DEFAULT_TOOL_LOOP_THRESHOLD
|
|
97
|
+
self.tool_window_size = tool_window_size or self.DEFAULT_TOOL_WINDOW_SIZE
|
|
98
|
+
|
|
99
|
+
def record_task_selection(
|
|
100
|
+
self,
|
|
101
|
+
session_id: str,
|
|
102
|
+
task_id: str,
|
|
103
|
+
context: dict[str, Any] | None = None,
|
|
104
|
+
) -> TaskSelectionEvent:
|
|
105
|
+
"""Record a task selection event.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
session_id: The session selecting the task
|
|
109
|
+
task_id: The task being selected
|
|
110
|
+
context: Optional context about the selection
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
The created TaskSelectionEvent
|
|
114
|
+
"""
|
|
115
|
+
now = datetime.now(UTC)
|
|
116
|
+
event = TaskSelectionEvent(
|
|
117
|
+
session_id=session_id,
|
|
118
|
+
task_id=task_id,
|
|
119
|
+
selected_at=now,
|
|
120
|
+
context=context,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
with self._lock:
|
|
124
|
+
self.db.execute(
|
|
125
|
+
"""
|
|
126
|
+
INSERT INTO task_selection_history (
|
|
127
|
+
session_id, task_id, selected_at, context
|
|
128
|
+
) VALUES (?, ?, ?, ?)
|
|
129
|
+
""",
|
|
130
|
+
(
|
|
131
|
+
session_id,
|
|
132
|
+
task_id,
|
|
133
|
+
now.isoformat(),
|
|
134
|
+
str(context) if context else None,
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
logger.debug(f"Recorded task selection for session {session_id}: task={task_id}")
|
|
139
|
+
|
|
140
|
+
return event
|
|
141
|
+
|
|
142
|
+
def detect_task_loop(self, session_id: str) -> StuckDetectionResult:
|
|
143
|
+
"""Detect task selection loops.
|
|
144
|
+
|
|
145
|
+
Checks the last N task selections (task_window_size) within the past hour
|
|
146
|
+
to detect if any task has been selected more times than the threshold.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
session_id: The session to check
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
StuckDetectionResult indicating if stuck in task loop
|
|
153
|
+
"""
|
|
154
|
+
from datetime import timedelta
|
|
155
|
+
|
|
156
|
+
# Compute cutoff as ISO8601 string for like-for-like comparison
|
|
157
|
+
cutoff = (datetime.now(UTC) - timedelta(hours=1)).isoformat()
|
|
158
|
+
|
|
159
|
+
# Get the last N task selections within the time window, then aggregate
|
|
160
|
+
rows = self.db.fetchall(
|
|
161
|
+
"""
|
|
162
|
+
SELECT task_id, COUNT(*) as count
|
|
163
|
+
FROM (
|
|
164
|
+
SELECT task_id
|
|
165
|
+
FROM task_selection_history
|
|
166
|
+
WHERE session_id = ?
|
|
167
|
+
AND selected_at > ?
|
|
168
|
+
ORDER BY selected_at DESC
|
|
169
|
+
LIMIT ?
|
|
170
|
+
)
|
|
171
|
+
GROUP BY task_id
|
|
172
|
+
ORDER BY count DESC
|
|
173
|
+
""",
|
|
174
|
+
(session_id, cutoff, self.task_window_size),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if not rows:
|
|
178
|
+
return StuckDetectionResult(is_stuck=False)
|
|
179
|
+
|
|
180
|
+
# Check if any task has been selected too many times
|
|
181
|
+
for row in rows:
|
|
182
|
+
if row["count"] >= self.task_loop_threshold:
|
|
183
|
+
logger.info(
|
|
184
|
+
f"Session {session_id} stuck in task loop: "
|
|
185
|
+
f"task {row['task_id']} selected {row['count']} times"
|
|
186
|
+
)
|
|
187
|
+
return StuckDetectionResult(
|
|
188
|
+
is_stuck=True,
|
|
189
|
+
reason=f"Task '{row['task_id']}' selected {row['count']} times without completion",
|
|
190
|
+
layer="task_loop",
|
|
191
|
+
details={
|
|
192
|
+
"task_id": row["task_id"],
|
|
193
|
+
"selection_count": row["count"],
|
|
194
|
+
"threshold": self.task_loop_threshold,
|
|
195
|
+
},
|
|
196
|
+
suggested_action="change_approach",
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
return StuckDetectionResult(is_stuck=False)
|
|
200
|
+
|
|
201
|
+
def detect_progress_stagnation(self, session_id: str) -> StuckDetectionResult:
|
|
202
|
+
"""Detect progress stagnation using ProgressTracker.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
session_id: The session to check
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
StuckDetectionResult indicating if progress is stagnant
|
|
209
|
+
"""
|
|
210
|
+
if not self.progress_tracker:
|
|
211
|
+
return StuckDetectionResult(is_stuck=False)
|
|
212
|
+
|
|
213
|
+
summary = self.progress_tracker.get_summary(session_id)
|
|
214
|
+
|
|
215
|
+
if summary.is_stagnant:
|
|
216
|
+
logger.info(
|
|
217
|
+
f"Session {session_id} progress stagnant: "
|
|
218
|
+
f"{summary.stagnation_duration_seconds:.0f}s since high-value event"
|
|
219
|
+
)
|
|
220
|
+
return StuckDetectionResult(
|
|
221
|
+
is_stuck=True,
|
|
222
|
+
reason=f"No meaningful progress for {summary.stagnation_duration_seconds:.0f} seconds",
|
|
223
|
+
layer="progress_stagnation",
|
|
224
|
+
details={
|
|
225
|
+
"total_events": summary.total_events,
|
|
226
|
+
"high_value_events": summary.high_value_events,
|
|
227
|
+
"stagnation_duration": summary.stagnation_duration_seconds,
|
|
228
|
+
"last_high_value_at": (
|
|
229
|
+
summary.last_high_value_at.isoformat()
|
|
230
|
+
if summary.last_high_value_at
|
|
231
|
+
else None
|
|
232
|
+
),
|
|
233
|
+
},
|
|
234
|
+
suggested_action="stop",
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
return StuckDetectionResult(is_stuck=False)
|
|
238
|
+
|
|
239
|
+
def detect_tool_loop(self, session_id: str) -> StuckDetectionResult:
|
|
240
|
+
"""Detect repeated identical tool calls.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
session_id: The session to check
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
StuckDetectionResult indicating if stuck in tool loop
|
|
247
|
+
"""
|
|
248
|
+
# Get recent tool calls from progress tracker
|
|
249
|
+
if not self.progress_tracker:
|
|
250
|
+
return StuckDetectionResult(is_stuck=False)
|
|
251
|
+
|
|
252
|
+
recent_events = self.progress_tracker.get_recent_events(session_id, self.tool_window_size)
|
|
253
|
+
|
|
254
|
+
if not recent_events:
|
|
255
|
+
return StuckDetectionResult(is_stuck=False)
|
|
256
|
+
|
|
257
|
+
# Count tool call patterns
|
|
258
|
+
tool_counts: dict[str, int] = {}
|
|
259
|
+
for event in recent_events:
|
|
260
|
+
if event.tool_name:
|
|
261
|
+
# Create a key from tool name and key args
|
|
262
|
+
key = f"{event.tool_name}:{event.details.get('tool_args_keys', [])}"
|
|
263
|
+
tool_counts[key] = tool_counts.get(key, 0) + 1
|
|
264
|
+
|
|
265
|
+
# Check for repeated patterns
|
|
266
|
+
for key, count in tool_counts.items():
|
|
267
|
+
if count >= self.tool_loop_threshold:
|
|
268
|
+
tool_name = key.split(":")[0]
|
|
269
|
+
logger.info(
|
|
270
|
+
f"Session {session_id} stuck in tool loop: {tool_name} called {count} times"
|
|
271
|
+
)
|
|
272
|
+
return StuckDetectionResult(
|
|
273
|
+
is_stuck=True,
|
|
274
|
+
reason=f"Tool '{tool_name}' called {count} times with same pattern",
|
|
275
|
+
layer="tool_loop",
|
|
276
|
+
details={
|
|
277
|
+
"tool_pattern": key,
|
|
278
|
+
"call_count": count,
|
|
279
|
+
"threshold": self.tool_loop_threshold,
|
|
280
|
+
},
|
|
281
|
+
suggested_action="change_approach",
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
return StuckDetectionResult(is_stuck=False)
|
|
285
|
+
|
|
286
|
+
def is_stuck(self, session_id: str) -> StuckDetectionResult:
|
|
287
|
+
"""Run all stuck detection checks.
|
|
288
|
+
|
|
289
|
+
Checks all three layers in order of severity:
|
|
290
|
+
1. Task selection loops
|
|
291
|
+
2. Progress stagnation
|
|
292
|
+
3. Tool call loops
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
session_id: The session to check
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
StuckDetectionResult from first layer that detects stuck state,
|
|
299
|
+
or not-stuck result if all layers pass
|
|
300
|
+
"""
|
|
301
|
+
# Layer 1: Task loops
|
|
302
|
+
result = self.detect_task_loop(session_id)
|
|
303
|
+
if result.is_stuck:
|
|
304
|
+
return result
|
|
305
|
+
|
|
306
|
+
# Layer 2: Progress stagnation
|
|
307
|
+
result = self.detect_progress_stagnation(session_id)
|
|
308
|
+
if result.is_stuck:
|
|
309
|
+
return result
|
|
310
|
+
|
|
311
|
+
# Layer 3: Tool loops
|
|
312
|
+
result = self.detect_tool_loop(session_id)
|
|
313
|
+
if result.is_stuck:
|
|
314
|
+
return result
|
|
315
|
+
|
|
316
|
+
return StuckDetectionResult(is_stuck=False)
|
|
317
|
+
|
|
318
|
+
def clear_session(self, session_id: str) -> int:
|
|
319
|
+
"""Clear all stuck detection data for a session.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
session_id: The session to clear
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
Number of records cleared
|
|
326
|
+
"""
|
|
327
|
+
with self._lock:
|
|
328
|
+
result = self.db.execute(
|
|
329
|
+
"DELETE FROM task_selection_history WHERE session_id = ?",
|
|
330
|
+
(session_id,),
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
if result.rowcount > 0:
|
|
334
|
+
logger.debug(
|
|
335
|
+
f"Cleared {result.rowcount} task selection record(s) for session {session_id}"
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
return result.rowcount
|
|
339
|
+
|
|
340
|
+
def get_selection_history(self, session_id: str, limit: int = 20) -> list[TaskSelectionEvent]:
|
|
341
|
+
"""Get recent task selection history.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
session_id: The session to get history for
|
|
345
|
+
limit: Maximum number of events to return
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
List of recent TaskSelectionEvents
|
|
349
|
+
"""
|
|
350
|
+
rows = self.db.fetchall(
|
|
351
|
+
"""
|
|
352
|
+
SELECT session_id, task_id, selected_at, context
|
|
353
|
+
FROM task_selection_history
|
|
354
|
+
WHERE session_id = ?
|
|
355
|
+
ORDER BY selected_at DESC
|
|
356
|
+
LIMIT ?
|
|
357
|
+
""",
|
|
358
|
+
(session_id, limit),
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
events = []
|
|
362
|
+
for row in rows:
|
|
363
|
+
context = None
|
|
364
|
+
if row["context"]:
|
|
365
|
+
try:
|
|
366
|
+
context = ast.literal_eval(row["context"])
|
|
367
|
+
except (ValueError, SyntaxError):
|
|
368
|
+
try:
|
|
369
|
+
context = json.loads(row["context"])
|
|
370
|
+
except json.JSONDecodeError:
|
|
371
|
+
logger.warning(
|
|
372
|
+
f"Failed to parse context for task selection: {row['context'][:100]}"
|
|
373
|
+
)
|
|
374
|
+
context = None
|
|
375
|
+
events.append(
|
|
376
|
+
TaskSelectionEvent(
|
|
377
|
+
session_id=row["session_id"],
|
|
378
|
+
task_id=row["task_id"],
|
|
379
|
+
selected_at=datetime.fromisoformat(row["selected_at"]),
|
|
380
|
+
context=context,
|
|
381
|
+
)
|
|
382
|
+
)
|
|
383
|
+
return events
|
gobby/cli/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Gobby CLI entry point.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from gobby.config.app import load_config
|
|
8
|
+
|
|
9
|
+
from .agents import agents
|
|
10
|
+
from .artifacts import artifacts
|
|
11
|
+
from .daemon import restart, start, status, stop
|
|
12
|
+
from .extensions import hooks, plugins, webhooks
|
|
13
|
+
from .github import github
|
|
14
|
+
from .init import init
|
|
15
|
+
from .install import install, uninstall
|
|
16
|
+
from .linear import linear
|
|
17
|
+
from .mcp import mcp_server
|
|
18
|
+
from .mcp_proxy import mcp_proxy
|
|
19
|
+
from .memory import memory
|
|
20
|
+
from .merge import merge
|
|
21
|
+
from .projects import projects
|
|
22
|
+
from .sessions import sessions
|
|
23
|
+
from .tasks import tasks
|
|
24
|
+
from .tui import ui
|
|
25
|
+
from .workflows import workflows
|
|
26
|
+
from .worktrees import worktrees
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@click.group()
|
|
30
|
+
@click.option(
|
|
31
|
+
"--config",
|
|
32
|
+
type=click.Path(exists=True),
|
|
33
|
+
help="Path to custom configuration file",
|
|
34
|
+
)
|
|
35
|
+
@click.pass_context
|
|
36
|
+
def cli(ctx: click.Context, config: str | None) -> None:
|
|
37
|
+
"""Gobby - Local-first daemon for AI coding assistants."""
|
|
38
|
+
# Store config in context for subcommands
|
|
39
|
+
ctx.ensure_object(dict)
|
|
40
|
+
ctx.obj["config"] = load_config(config)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Register commands
|
|
44
|
+
cli.add_command(start)
|
|
45
|
+
cli.add_command(stop)
|
|
46
|
+
cli.add_command(restart)
|
|
47
|
+
cli.add_command(status)
|
|
48
|
+
cli.add_command(mcp_server)
|
|
49
|
+
cli.add_command(init)
|
|
50
|
+
cli.add_command(install)
|
|
51
|
+
cli.add_command(uninstall)
|
|
52
|
+
cli.add_command(tasks)
|
|
53
|
+
cli.add_command(memory)
|
|
54
|
+
cli.add_command(sessions)
|
|
55
|
+
cli.add_command(agents)
|
|
56
|
+
cli.add_command(worktrees)
|
|
57
|
+
cli.add_command(mcp_proxy)
|
|
58
|
+
cli.add_command(projects)
|
|
59
|
+
cli.add_command(workflows)
|
|
60
|
+
cli.add_command(merge)
|
|
61
|
+
cli.add_command(artifacts)
|
|
62
|
+
cli.add_command(github)
|
|
63
|
+
cli.add_command(linear)
|
|
64
|
+
cli.add_command(hooks)
|
|
65
|
+
cli.add_command(plugins)
|
|
66
|
+
cli.add_command(webhooks)
|
|
67
|
+
cli.add_command(ui)
|