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,447 @@
|
|
|
1
|
+
"""Progress tracking for autonomous session management.
|
|
2
|
+
|
|
3
|
+
Provides progress tracking for autonomous workflows to detect stagnation
|
|
4
|
+
and enable informed decisions about when to stop or redirect work.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import threading
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from gobby.storage.database import LocalDatabase
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ProgressType(str, Enum):
|
|
22
|
+
"""Types of progress events."""
|
|
23
|
+
|
|
24
|
+
TOOL_CALL = "tool_call" # Any tool was called
|
|
25
|
+
FILE_MODIFIED = "file_modified" # A file was modified (Edit, Write)
|
|
26
|
+
FILE_READ = "file_read" # A file was read
|
|
27
|
+
TASK_STARTED = "task_started" # A task was set to in_progress
|
|
28
|
+
TASK_COMPLETED = "task_completed" # A task was closed
|
|
29
|
+
TEST_PASSED = "test_passed" # Tests passed
|
|
30
|
+
TEST_FAILED = "test_failed" # Tests failed
|
|
31
|
+
BUILD_SUCCEEDED = "build_succeeded" # Build succeeded
|
|
32
|
+
BUILD_FAILED = "build_failed" # Build failed
|
|
33
|
+
COMMIT_CREATED = "commit_created" # Git commit was created
|
|
34
|
+
ERROR_OCCURRED = "error_occurred" # An error occurred
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Tool names that indicate meaningful progress
|
|
38
|
+
MEANINGFUL_TOOLS = {
|
|
39
|
+
"Edit": ProgressType.FILE_MODIFIED,
|
|
40
|
+
"Write": ProgressType.FILE_MODIFIED,
|
|
41
|
+
"NotebookEdit": ProgressType.FILE_MODIFIED,
|
|
42
|
+
"Bash": ProgressType.TOOL_CALL, # Could be build/test
|
|
43
|
+
"Read": ProgressType.FILE_READ,
|
|
44
|
+
"Glob": ProgressType.FILE_READ,
|
|
45
|
+
"Grep": ProgressType.FILE_READ,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# High-value progress types that reset stagnation
|
|
49
|
+
HIGH_VALUE_PROGRESS = {
|
|
50
|
+
ProgressType.FILE_MODIFIED,
|
|
51
|
+
ProgressType.TASK_COMPLETED,
|
|
52
|
+
ProgressType.COMMIT_CREATED,
|
|
53
|
+
ProgressType.TEST_PASSED,
|
|
54
|
+
ProgressType.BUILD_SUCCEEDED,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class ProgressEvent:
|
|
60
|
+
"""A single progress event."""
|
|
61
|
+
|
|
62
|
+
session_id: str
|
|
63
|
+
progress_type: ProgressType
|
|
64
|
+
timestamp: datetime
|
|
65
|
+
tool_name: str | None = None
|
|
66
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def is_high_value(self) -> bool:
|
|
70
|
+
"""Return True if this is a high-value progress event."""
|
|
71
|
+
return self.progress_type in HIGH_VALUE_PROGRESS
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class ProgressSummary:
|
|
76
|
+
"""Summary of progress for a session."""
|
|
77
|
+
|
|
78
|
+
session_id: str
|
|
79
|
+
total_events: int
|
|
80
|
+
high_value_events: int
|
|
81
|
+
last_high_value_at: datetime | None
|
|
82
|
+
last_event_at: datetime | None
|
|
83
|
+
events_by_type: dict[ProgressType, int]
|
|
84
|
+
is_stagnant: bool = False
|
|
85
|
+
stagnation_duration_seconds: float = 0.0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ProgressTracker:
|
|
89
|
+
"""Track progress for autonomous sessions.
|
|
90
|
+
|
|
91
|
+
The ProgressTracker records tool calls and other events during
|
|
92
|
+
autonomous execution, enabling detection of stagnation (when the
|
|
93
|
+
session is no longer making meaningful progress).
|
|
94
|
+
|
|
95
|
+
Stagnation is detected when:
|
|
96
|
+
1. No high-value progress events for a configured duration
|
|
97
|
+
2. Too many low-value events without high-value events
|
|
98
|
+
3. Repeated identical tool calls (loop detection)
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
# Default stagnation threshold in seconds (10 minutes)
|
|
102
|
+
DEFAULT_STAGNATION_THRESHOLD = 600.0
|
|
103
|
+
|
|
104
|
+
# Max low-value events before considering stagnant
|
|
105
|
+
DEFAULT_MAX_LOW_VALUE_EVENTS = 50
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
db: "LocalDatabase",
|
|
110
|
+
stagnation_threshold: float | None = None,
|
|
111
|
+
max_low_value_events: int | None = None,
|
|
112
|
+
):
|
|
113
|
+
"""Initialize the progress tracker.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
db: Database connection for persistent storage
|
|
117
|
+
stagnation_threshold: Seconds without high-value progress before stagnant
|
|
118
|
+
max_low_value_events: Max low-value events before stagnant
|
|
119
|
+
"""
|
|
120
|
+
self.db = db
|
|
121
|
+
self._lock = threading.Lock()
|
|
122
|
+
self.stagnation_threshold = stagnation_threshold or self.DEFAULT_STAGNATION_THRESHOLD
|
|
123
|
+
self.max_low_value_events = max_low_value_events or self.DEFAULT_MAX_LOW_VALUE_EVENTS
|
|
124
|
+
|
|
125
|
+
def record_event(
|
|
126
|
+
self,
|
|
127
|
+
session_id: str,
|
|
128
|
+
progress_type: ProgressType,
|
|
129
|
+
tool_name: str | None = None,
|
|
130
|
+
details: dict[str, Any] | None = None,
|
|
131
|
+
) -> ProgressEvent:
|
|
132
|
+
"""Record a progress event.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
session_id: The session to record progress for
|
|
136
|
+
progress_type: Type of progress event
|
|
137
|
+
tool_name: Name of the tool that generated this event
|
|
138
|
+
details: Additional details about the event
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
The created ProgressEvent
|
|
142
|
+
"""
|
|
143
|
+
now = datetime.now(UTC)
|
|
144
|
+
event = ProgressEvent(
|
|
145
|
+
session_id=session_id,
|
|
146
|
+
progress_type=progress_type,
|
|
147
|
+
timestamp=now,
|
|
148
|
+
tool_name=tool_name,
|
|
149
|
+
details=details or {},
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
with self._lock:
|
|
153
|
+
self.db.execute(
|
|
154
|
+
"""
|
|
155
|
+
INSERT INTO loop_progress (
|
|
156
|
+
session_id, progress_type, tool_name, details, recorded_at, is_high_value
|
|
157
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
158
|
+
""",
|
|
159
|
+
(
|
|
160
|
+
session_id,
|
|
161
|
+
progress_type.value,
|
|
162
|
+
tool_name,
|
|
163
|
+
json.dumps(details) if details else None,
|
|
164
|
+
now.isoformat(),
|
|
165
|
+
event.is_high_value,
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
logger.debug(
|
|
170
|
+
f"Recorded progress for session {session_id}: "
|
|
171
|
+
f"{progress_type.value} (high_value={event.is_high_value})"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return event
|
|
175
|
+
|
|
176
|
+
def record_tool_call(
|
|
177
|
+
self,
|
|
178
|
+
session_id: str,
|
|
179
|
+
tool_name: str,
|
|
180
|
+
tool_args: dict[str, Any] | None = None,
|
|
181
|
+
tool_result: Any = None,
|
|
182
|
+
) -> ProgressEvent | None:
|
|
183
|
+
"""Record a tool call as a progress event.
|
|
184
|
+
|
|
185
|
+
Automatically determines the progress type based on the tool name
|
|
186
|
+
and result.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
session_id: The session that made the tool call
|
|
190
|
+
tool_name: Name of the tool that was called
|
|
191
|
+
tool_args: Arguments passed to the tool
|
|
192
|
+
tool_result: Result returned by the tool
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
ProgressEvent if recorded, None if tool is not tracked
|
|
196
|
+
"""
|
|
197
|
+
# Determine progress type from tool name
|
|
198
|
+
progress_type = MEANINGFUL_TOOLS.get(tool_name, ProgressType.TOOL_CALL)
|
|
199
|
+
|
|
200
|
+
# Enhance progress type based on result analysis
|
|
201
|
+
if tool_name == "Bash":
|
|
202
|
+
# Check for test/build commands
|
|
203
|
+
command = (tool_args or {}).get("command", "")
|
|
204
|
+
if any(kw in command for kw in ["pytest", "test", "npm test", "cargo test"]):
|
|
205
|
+
# Check result for pass/fail
|
|
206
|
+
result_str = str(tool_result) if tool_result else ""
|
|
207
|
+
if "FAILED" in result_str or "error" in result_str.lower():
|
|
208
|
+
progress_type = ProgressType.TEST_FAILED
|
|
209
|
+
elif "passed" in result_str or "OK" in result_str:
|
|
210
|
+
progress_type = ProgressType.TEST_PASSED
|
|
211
|
+
elif any(kw in command for kw in ["build", "compile", "npm run build", "cargo build"]):
|
|
212
|
+
result_str = str(tool_result) if tool_result else ""
|
|
213
|
+
if "error" in result_str.lower() or "failed" in result_str.lower():
|
|
214
|
+
progress_type = ProgressType.BUILD_FAILED
|
|
215
|
+
else:
|
|
216
|
+
progress_type = ProgressType.BUILD_SUCCEEDED
|
|
217
|
+
elif "git commit" in command:
|
|
218
|
+
progress_type = ProgressType.COMMIT_CREATED
|
|
219
|
+
|
|
220
|
+
# Don't track Read/Glob/Grep as high-priority events
|
|
221
|
+
# They're useful but don't represent meaningful progress alone
|
|
222
|
+
details = {
|
|
223
|
+
"tool_args_keys": list((tool_args or {}).keys()),
|
|
224
|
+
"result_type": type(tool_result).__name__ if tool_result else None,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return self.record_event(
|
|
228
|
+
session_id=session_id,
|
|
229
|
+
progress_type=progress_type,
|
|
230
|
+
tool_name=tool_name,
|
|
231
|
+
details=details,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def get_summary(self, session_id: str) -> ProgressSummary:
|
|
235
|
+
"""Get a summary of progress for a session.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
session_id: The session to get summary for
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
ProgressSummary with aggregated progress data
|
|
242
|
+
"""
|
|
243
|
+
# Get total counts by type
|
|
244
|
+
rows = self.db.fetchall(
|
|
245
|
+
"""
|
|
246
|
+
SELECT progress_type, COUNT(*) as count
|
|
247
|
+
FROM loop_progress
|
|
248
|
+
WHERE session_id = ?
|
|
249
|
+
GROUP BY progress_type
|
|
250
|
+
""",
|
|
251
|
+
(session_id,),
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
events_by_type: dict[ProgressType, int] = {}
|
|
255
|
+
total_events = 0
|
|
256
|
+
for row in rows:
|
|
257
|
+
ptype = ProgressType(row["progress_type"])
|
|
258
|
+
events_by_type[ptype] = row["count"]
|
|
259
|
+
total_events += row["count"]
|
|
260
|
+
|
|
261
|
+
# Count high-value events
|
|
262
|
+
high_value_result = self.db.fetchone(
|
|
263
|
+
"""
|
|
264
|
+
SELECT COUNT(*) as count
|
|
265
|
+
FROM loop_progress
|
|
266
|
+
WHERE session_id = ? AND is_high_value = 1
|
|
267
|
+
""",
|
|
268
|
+
(session_id,),
|
|
269
|
+
)
|
|
270
|
+
high_value_events = high_value_result["count"] if high_value_result else 0
|
|
271
|
+
|
|
272
|
+
# Get last high-value event time
|
|
273
|
+
last_hv_result = self.db.fetchone(
|
|
274
|
+
"""
|
|
275
|
+
SELECT recorded_at
|
|
276
|
+
FROM loop_progress
|
|
277
|
+
WHERE session_id = ? AND is_high_value = 1
|
|
278
|
+
ORDER BY recorded_at DESC
|
|
279
|
+
LIMIT 1
|
|
280
|
+
""",
|
|
281
|
+
(session_id,),
|
|
282
|
+
)
|
|
283
|
+
last_high_value_at = (
|
|
284
|
+
datetime.fromisoformat(last_hv_result["recorded_at"]) if last_hv_result else None
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# Get last event time
|
|
288
|
+
last_event_result = self.db.fetchone(
|
|
289
|
+
"""
|
|
290
|
+
SELECT recorded_at
|
|
291
|
+
FROM loop_progress
|
|
292
|
+
WHERE session_id = ?
|
|
293
|
+
ORDER BY recorded_at DESC
|
|
294
|
+
LIMIT 1
|
|
295
|
+
""",
|
|
296
|
+
(session_id,),
|
|
297
|
+
)
|
|
298
|
+
last_event_at = (
|
|
299
|
+
datetime.fromisoformat(last_event_result["recorded_at"]) if last_event_result else None
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# Calculate stagnation
|
|
303
|
+
is_stagnant, stagnation_duration = self._check_stagnation(
|
|
304
|
+
session_id, high_value_events, total_events, last_high_value_at
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
return ProgressSummary(
|
|
308
|
+
session_id=session_id,
|
|
309
|
+
total_events=total_events,
|
|
310
|
+
high_value_events=high_value_events,
|
|
311
|
+
last_high_value_at=last_high_value_at,
|
|
312
|
+
last_event_at=last_event_at,
|
|
313
|
+
events_by_type=events_by_type,
|
|
314
|
+
is_stagnant=is_stagnant,
|
|
315
|
+
stagnation_duration_seconds=stagnation_duration,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
def is_stagnant(self, session_id: str) -> bool:
|
|
319
|
+
"""Check if a session is in a stagnant state.
|
|
320
|
+
|
|
321
|
+
A session is stagnant if:
|
|
322
|
+
1. No high-value progress for longer than stagnation_threshold
|
|
323
|
+
2. Too many low-value events without high-value progress
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
session_id: The session to check
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
True if the session appears stagnant
|
|
330
|
+
"""
|
|
331
|
+
summary = self.get_summary(session_id)
|
|
332
|
+
return summary.is_stagnant
|
|
333
|
+
|
|
334
|
+
def _check_stagnation(
|
|
335
|
+
self,
|
|
336
|
+
session_id: str,
|
|
337
|
+
high_value_events: int,
|
|
338
|
+
total_events: int,
|
|
339
|
+
last_high_value_at: datetime | None,
|
|
340
|
+
) -> tuple[bool, float]:
|
|
341
|
+
"""Check for stagnation conditions.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
session_id: The session to check
|
|
345
|
+
high_value_events: Count of high-value events
|
|
346
|
+
total_events: Total event count
|
|
347
|
+
last_high_value_at: Timestamp of last high-value event
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
Tuple of (is_stagnant, stagnation_duration_seconds)
|
|
351
|
+
"""
|
|
352
|
+
now = datetime.now(UTC)
|
|
353
|
+
|
|
354
|
+
# No events yet - not stagnant
|
|
355
|
+
if total_events == 0:
|
|
356
|
+
return False, 0.0
|
|
357
|
+
|
|
358
|
+
# Calculate time since last high-value event
|
|
359
|
+
if last_high_value_at:
|
|
360
|
+
duration = (now - last_high_value_at).total_seconds()
|
|
361
|
+
else:
|
|
362
|
+
# No high-value events ever - use first event time
|
|
363
|
+
first_event = self.db.fetchone(
|
|
364
|
+
"""
|
|
365
|
+
SELECT recorded_at
|
|
366
|
+
FROM loop_progress
|
|
367
|
+
WHERE session_id = ?
|
|
368
|
+
ORDER BY recorded_at ASC
|
|
369
|
+
LIMIT 1
|
|
370
|
+
""",
|
|
371
|
+
(session_id,),
|
|
372
|
+
)
|
|
373
|
+
if first_event:
|
|
374
|
+
first_time = datetime.fromisoformat(first_event["recorded_at"])
|
|
375
|
+
duration = (now - first_time).total_seconds()
|
|
376
|
+
else:
|
|
377
|
+
duration = 0.0
|
|
378
|
+
|
|
379
|
+
# Check time-based stagnation
|
|
380
|
+
if duration > self.stagnation_threshold:
|
|
381
|
+
logger.info(
|
|
382
|
+
f"Session {session_id} stagnant: {duration:.0f}s since last high-value event"
|
|
383
|
+
)
|
|
384
|
+
return True, duration
|
|
385
|
+
|
|
386
|
+
# Check event count-based stagnation
|
|
387
|
+
low_value_events = total_events - high_value_events
|
|
388
|
+
if high_value_events == 0 and low_value_events >= self.max_low_value_events:
|
|
389
|
+
logger.info(
|
|
390
|
+
f"Session {session_id} stagnant: "
|
|
391
|
+
f"{low_value_events} low-value events without high-value progress"
|
|
392
|
+
)
|
|
393
|
+
return True, duration
|
|
394
|
+
|
|
395
|
+
return False, duration
|
|
396
|
+
|
|
397
|
+
def clear_session(self, session_id: str) -> int:
|
|
398
|
+
"""Clear all progress records for a session.
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
session_id: The session to clear
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
Number of records cleared
|
|
405
|
+
"""
|
|
406
|
+
with self._lock:
|
|
407
|
+
result = self.db.execute(
|
|
408
|
+
"DELETE FROM loop_progress WHERE session_id = ?",
|
|
409
|
+
(session_id,),
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
if result.rowcount > 0:
|
|
413
|
+
logger.debug(f"Cleared {result.rowcount} progress record(s) for session {session_id}")
|
|
414
|
+
|
|
415
|
+
return result.rowcount
|
|
416
|
+
|
|
417
|
+
def get_recent_events(self, session_id: str, limit: int = 20) -> list[ProgressEvent]:
|
|
418
|
+
"""Get recent progress events for a session.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
session_id: The session to get events for
|
|
422
|
+
limit: Maximum number of events to return
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
List of recent ProgressEvents
|
|
426
|
+
"""
|
|
427
|
+
rows = self.db.fetchall(
|
|
428
|
+
"""
|
|
429
|
+
SELECT session_id, progress_type, tool_name, details, recorded_at
|
|
430
|
+
FROM loop_progress
|
|
431
|
+
WHERE session_id = ?
|
|
432
|
+
ORDER BY recorded_at DESC
|
|
433
|
+
LIMIT ?
|
|
434
|
+
""",
|
|
435
|
+
(session_id, limit),
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
return [
|
|
439
|
+
ProgressEvent(
|
|
440
|
+
session_id=row["session_id"],
|
|
441
|
+
progress_type=ProgressType(row["progress_type"]),
|
|
442
|
+
timestamp=datetime.fromisoformat(row["recorded_at"]),
|
|
443
|
+
tool_name=row["tool_name"],
|
|
444
|
+
details=json.loads(row["details"]) if row["details"] else {}, # Safe: json loads
|
|
445
|
+
)
|
|
446
|
+
for row in rows
|
|
447
|
+
]
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Stop signal registry for autonomous session management.
|
|
2
|
+
|
|
3
|
+
Provides thread-safe stop signal management for autonomous workflows.
|
|
4
|
+
External systems (HTTP, WebSocket, CLI, MCP) can signal sessions to stop
|
|
5
|
+
gracefully, and workflows can check for pending stop signals at step
|
|
6
|
+
transitions.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import threading
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from gobby.storage.database import LocalDatabase
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class StopSignal:
|
|
23
|
+
"""Represents a stop signal for a session."""
|
|
24
|
+
|
|
25
|
+
session_id: str
|
|
26
|
+
source: str # http, websocket, cli, mcp, workflow
|
|
27
|
+
reason: str | None
|
|
28
|
+
requested_at: datetime
|
|
29
|
+
acknowledged_at: datetime | None = None
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def is_pending(self) -> bool:
|
|
33
|
+
"""Return True if signal has not been acknowledged."""
|
|
34
|
+
return self.acknowledged_at is None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class StopRegistry:
|
|
38
|
+
"""Thread-safe registry for session stop signals.
|
|
39
|
+
|
|
40
|
+
Stop signals can be sent from multiple sources:
|
|
41
|
+
- HTTP endpoint: POST /api/v1/sessions/{session_id}/stop
|
|
42
|
+
- WebSocket: stop_session message
|
|
43
|
+
- CLI: gobby session stop <session_id>
|
|
44
|
+
- MCP: gobby-sessions.request_stop tool
|
|
45
|
+
- Workflow: check_stop_signal action detecting stuck state
|
|
46
|
+
|
|
47
|
+
Workflows check for stop signals via the check_stop_signal action
|
|
48
|
+
or the has_stop_signal() condition function.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, db: "LocalDatabase"):
|
|
52
|
+
"""Initialize the stop registry.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
db: Database connection for persistent storage
|
|
56
|
+
"""
|
|
57
|
+
self.db = db
|
|
58
|
+
self._lock = threading.Lock()
|
|
59
|
+
|
|
60
|
+
def signal_stop(
|
|
61
|
+
self,
|
|
62
|
+
session_id: str,
|
|
63
|
+
source: str = "unknown",
|
|
64
|
+
reason: str | None = None,
|
|
65
|
+
) -> StopSignal:
|
|
66
|
+
"""Request a session to stop.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
session_id: The session to signal
|
|
70
|
+
source: Source of the stop request (http, websocket, cli, mcp, workflow)
|
|
71
|
+
reason: Optional reason for the stop request
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
The created StopSignal
|
|
75
|
+
"""
|
|
76
|
+
now = datetime.now(UTC)
|
|
77
|
+
|
|
78
|
+
with self._lock:
|
|
79
|
+
# Check if there's already a pending signal
|
|
80
|
+
existing = self.get_signal(session_id)
|
|
81
|
+
if existing and existing.is_pending:
|
|
82
|
+
logger.debug(
|
|
83
|
+
f"Stop signal already pending for session {session_id} from {existing.source}"
|
|
84
|
+
)
|
|
85
|
+
return existing
|
|
86
|
+
|
|
87
|
+
# Insert new signal
|
|
88
|
+
self.db.execute(
|
|
89
|
+
"""
|
|
90
|
+
INSERT INTO session_stop_signals (session_id, source, reason, requested_at)
|
|
91
|
+
VALUES (?, ?, ?, ?)
|
|
92
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
93
|
+
source = excluded.source,
|
|
94
|
+
reason = excluded.reason,
|
|
95
|
+
requested_at = excluded.requested_at,
|
|
96
|
+
acknowledged_at = NULL
|
|
97
|
+
""",
|
|
98
|
+
(session_id, source, reason, now.isoformat()),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
logger.info(
|
|
102
|
+
f"Stop signal sent for session {session_id} from {source}: {reason or 'no reason'}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return StopSignal(
|
|
106
|
+
session_id=session_id,
|
|
107
|
+
source=source,
|
|
108
|
+
reason=reason,
|
|
109
|
+
requested_at=now,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def get_signal(self, session_id: str) -> StopSignal | None:
|
|
113
|
+
"""Get the stop signal for a session if one exists.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
session_id: The session to check
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
StopSignal if one exists, None otherwise
|
|
120
|
+
"""
|
|
121
|
+
row = self.db.fetchone(
|
|
122
|
+
"""
|
|
123
|
+
SELECT session_id, source, reason, requested_at, acknowledged_at
|
|
124
|
+
FROM session_stop_signals
|
|
125
|
+
WHERE session_id = ?
|
|
126
|
+
""",
|
|
127
|
+
(session_id,),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if not row:
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
return StopSignal(
|
|
134
|
+
session_id=row["session_id"],
|
|
135
|
+
source=row["source"],
|
|
136
|
+
reason=row["reason"],
|
|
137
|
+
requested_at=datetime.fromisoformat(row["requested_at"]),
|
|
138
|
+
acknowledged_at=(
|
|
139
|
+
datetime.fromisoformat(row["acknowledged_at"]) if row["acknowledged_at"] else None
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def has_pending_signal(self, session_id: str) -> bool:
|
|
144
|
+
"""Check if a session has a pending stop signal.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
session_id: The session to check
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
True if there is an unacknowledged stop signal
|
|
151
|
+
"""
|
|
152
|
+
signal = self.get_signal(session_id)
|
|
153
|
+
return signal is not None and signal.is_pending
|
|
154
|
+
|
|
155
|
+
def acknowledge(self, session_id: str) -> bool:
|
|
156
|
+
"""Acknowledge a stop signal (session is stopping).
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
session_id: The session acknowledging the stop
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
True if a signal was acknowledged, False if none existed
|
|
163
|
+
"""
|
|
164
|
+
now = datetime.now(UTC)
|
|
165
|
+
|
|
166
|
+
with self._lock:
|
|
167
|
+
result = self.db.execute(
|
|
168
|
+
"""
|
|
169
|
+
UPDATE session_stop_signals
|
|
170
|
+
SET acknowledged_at = ?
|
|
171
|
+
WHERE session_id = ? AND acknowledged_at IS NULL
|
|
172
|
+
""",
|
|
173
|
+
(now.isoformat(), session_id),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
if result.rowcount > 0:
|
|
177
|
+
logger.info(f"Stop signal acknowledged for session {session_id}")
|
|
178
|
+
return True
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
def clear(self, session_id: str) -> bool:
|
|
182
|
+
"""Clear any stop signal for a session.
|
|
183
|
+
|
|
184
|
+
Use this when a session has fully stopped and we want to clean up.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
session_id: The session to clear
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
True if a signal was cleared, False if none existed
|
|
191
|
+
"""
|
|
192
|
+
with self._lock:
|
|
193
|
+
result = self.db.execute(
|
|
194
|
+
"DELETE FROM session_stop_signals WHERE session_id = ?",
|
|
195
|
+
(session_id,),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if result.rowcount > 0:
|
|
199
|
+
logger.debug(f"Stop signal cleared for session {session_id}")
|
|
200
|
+
return True
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
def list_pending(self, project_id: str | None = None) -> list[StopSignal]:
|
|
204
|
+
"""List all pending stop signals.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
project_id: Optional project filter (requires join with sessions)
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
List of pending StopSignals
|
|
211
|
+
"""
|
|
212
|
+
if project_id:
|
|
213
|
+
rows = self.db.fetchall(
|
|
214
|
+
"""
|
|
215
|
+
SELECT ss.session_id, ss.source, ss.reason, ss.requested_at, ss.acknowledged_at
|
|
216
|
+
FROM session_stop_signals ss
|
|
217
|
+
JOIN sessions s ON ss.session_id = s.id
|
|
218
|
+
WHERE ss.acknowledged_at IS NULL AND s.project_id = ?
|
|
219
|
+
ORDER BY ss.requested_at DESC
|
|
220
|
+
""",
|
|
221
|
+
(project_id,),
|
|
222
|
+
)
|
|
223
|
+
else:
|
|
224
|
+
rows = self.db.fetchall(
|
|
225
|
+
"""
|
|
226
|
+
SELECT session_id, source, reason, requested_at, acknowledged_at
|
|
227
|
+
FROM session_stop_signals
|
|
228
|
+
WHERE acknowledged_at IS NULL
|
|
229
|
+
ORDER BY requested_at DESC
|
|
230
|
+
""",
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
return [
|
|
234
|
+
StopSignal(
|
|
235
|
+
session_id=row["session_id"],
|
|
236
|
+
source=row["source"],
|
|
237
|
+
reason=row["reason"],
|
|
238
|
+
requested_at=datetime.fromisoformat(row["requested_at"]),
|
|
239
|
+
acknowledged_at=None,
|
|
240
|
+
)
|
|
241
|
+
for row in rows
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
def cleanup_stale(self, max_age_hours: int = 24) -> int:
|
|
245
|
+
"""Clean up old acknowledged signals.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
max_age_hours: Remove acknowledged signals older than this
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
Number of signals cleaned up
|
|
252
|
+
"""
|
|
253
|
+
from datetime import timedelta
|
|
254
|
+
|
|
255
|
+
threshold = datetime.now(UTC) - timedelta(hours=max_age_hours)
|
|
256
|
+
|
|
257
|
+
with self._lock:
|
|
258
|
+
result = self.db.execute(
|
|
259
|
+
"""
|
|
260
|
+
DELETE FROM session_stop_signals
|
|
261
|
+
WHERE acknowledged_at IS NOT NULL
|
|
262
|
+
AND datetime(acknowledged_at) < datetime(?)
|
|
263
|
+
""",
|
|
264
|
+
(threshold.isoformat(),),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
if result.rowcount > 0:
|
|
268
|
+
logger.info(f"Cleaned up {result.rowcount} stale stop signal(s)")
|
|
269
|
+
return result.rowcount
|