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,195 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Gemini transcript parser.
|
|
3
|
+
|
|
4
|
+
Parses JSONL transcript files generated by Gemini CLI.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from gobby.sessions.transcripts.base import ParsedMessage, TokenUsage
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GeminiTranscriptParser:
|
|
20
|
+
"""
|
|
21
|
+
Parses JSONL transcript files from Gemini.
|
|
22
|
+
|
|
23
|
+
Implements the TranscriptParser protocol for Gemini's transcript format.
|
|
24
|
+
Assumes a standard JSONL structure where each line is a message or event.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, logger_instance: logging.Logger | None = None):
|
|
28
|
+
"""
|
|
29
|
+
Initialize GeminiTranscriptParser.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
logger_instance: Optional logger instance.
|
|
33
|
+
"""
|
|
34
|
+
self.logger = logger_instance or logger
|
|
35
|
+
|
|
36
|
+
def extract_last_messages(
|
|
37
|
+
self, turns: list[dict[str, Any]], num_pairs: int = 2
|
|
38
|
+
) -> list[dict[str, Any]]:
|
|
39
|
+
"""
|
|
40
|
+
Extract last N user<>agent message pairs.
|
|
41
|
+
"""
|
|
42
|
+
messages: list[dict[str, str]] = []
|
|
43
|
+
for turn in reversed(turns):
|
|
44
|
+
# Adapt to generic turn structure
|
|
45
|
+
# Assumes generic schema: {"role": "...", "content": "..."} or nested in "message"
|
|
46
|
+
role = turn.get("role") or turn.get("message", {}).get("role")
|
|
47
|
+
content = turn.get("content") or turn.get("message", {}).get("content")
|
|
48
|
+
|
|
49
|
+
if role in ["user", "model", "assistant"]:
|
|
50
|
+
norm_role = "assistant" if role == "model" else role
|
|
51
|
+
|
|
52
|
+
# Handle complex content types if necessary
|
|
53
|
+
if isinstance(content, list):
|
|
54
|
+
content = " ".join(str(part) for part in content)
|
|
55
|
+
|
|
56
|
+
messages.insert(0, {"role": norm_role, "content": str(content)})
|
|
57
|
+
if len(messages) >= num_pairs * 2:
|
|
58
|
+
break
|
|
59
|
+
return messages
|
|
60
|
+
|
|
61
|
+
def extract_turns_since_clear(
|
|
62
|
+
self, turns: list[dict[str, Any]], max_turns: int = 50
|
|
63
|
+
) -> list[dict[str, Any]]:
|
|
64
|
+
"""
|
|
65
|
+
Extract turns since the most recent session boundary.
|
|
66
|
+
For Gemini, we might look for specific clear events or just return the tail.
|
|
67
|
+
"""
|
|
68
|
+
# Placeholder: just return last N turns for now until we know the clear signal
|
|
69
|
+
return turns[-max_turns:] if len(turns) > max_turns else turns
|
|
70
|
+
|
|
71
|
+
def is_session_boundary(self, turn: dict[str, Any]) -> bool:
|
|
72
|
+
"""
|
|
73
|
+
Check if a turn is a session boundary.
|
|
74
|
+
"""
|
|
75
|
+
# Placeholder for Gemini specific boundary
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
def parse_line(self, line: str, index: int) -> ParsedMessage | None:
|
|
79
|
+
"""
|
|
80
|
+
Parse a single line from the transcript JSONL.
|
|
81
|
+
"""
|
|
82
|
+
if not line.strip():
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
data = json.loads(line)
|
|
87
|
+
except json.JSONDecodeError:
|
|
88
|
+
self.logger.warning(f"Invalid JSON at line {index}")
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
# Extract timestamp
|
|
92
|
+
timestamp_str = data.get("timestamp") or datetime.now(UTC).isoformat()
|
|
93
|
+
try:
|
|
94
|
+
timestamp = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
|
|
95
|
+
except ValueError:
|
|
96
|
+
timestamp = datetime.now(UTC)
|
|
97
|
+
|
|
98
|
+
# Determine role and content
|
|
99
|
+
# Check top-level or nested 'message'
|
|
100
|
+
role = data.get("role")
|
|
101
|
+
content = data.get("content")
|
|
102
|
+
|
|
103
|
+
if not role and "message" in data:
|
|
104
|
+
msg = data["message"]
|
|
105
|
+
role = msg.get("role")
|
|
106
|
+
content = msg.get("content")
|
|
107
|
+
|
|
108
|
+
if not role:
|
|
109
|
+
# Try type field common in other schemas
|
|
110
|
+
msg_type = data.get("type")
|
|
111
|
+
if msg_type == "user":
|
|
112
|
+
role = "user"
|
|
113
|
+
elif msg_type == "model":
|
|
114
|
+
role = "assistant"
|
|
115
|
+
|
|
116
|
+
# Normalize role
|
|
117
|
+
if role == "model":
|
|
118
|
+
role = "assistant"
|
|
119
|
+
|
|
120
|
+
if not role:
|
|
121
|
+
# Maybe a tool result or system event
|
|
122
|
+
if "tool_result" in data:
|
|
123
|
+
role = "tool"
|
|
124
|
+
content = str(data["tool_result"])
|
|
125
|
+
else:
|
|
126
|
+
# Unknown or uninteresting line
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
# Normalize content
|
|
130
|
+
content_type = "text"
|
|
131
|
+
tool_name = None
|
|
132
|
+
tool_input = None
|
|
133
|
+
tool_result = None
|
|
134
|
+
|
|
135
|
+
if isinstance(content, list):
|
|
136
|
+
# Handle potential rich content
|
|
137
|
+
text_parts: list[str] = []
|
|
138
|
+
for part in content:
|
|
139
|
+
if isinstance(part, str):
|
|
140
|
+
text_parts.append(part)
|
|
141
|
+
elif isinstance(part, dict):
|
|
142
|
+
if "text" in part:
|
|
143
|
+
text_parts.append(str(part["text"]))
|
|
144
|
+
# Check for tool calls
|
|
145
|
+
if "functionCall" in part:
|
|
146
|
+
content_type = "tool_use"
|
|
147
|
+
tool_name = part["functionCall"].get("name")
|
|
148
|
+
tool_input = part["functionCall"].get("args")
|
|
149
|
+
content = " ".join(text_parts)
|
|
150
|
+
else:
|
|
151
|
+
content = str(content or "")
|
|
152
|
+
|
|
153
|
+
return ParsedMessage(
|
|
154
|
+
index=index,
|
|
155
|
+
role=role,
|
|
156
|
+
content=content,
|
|
157
|
+
content_type=content_type,
|
|
158
|
+
tool_name=tool_name,
|
|
159
|
+
tool_input=tool_input,
|
|
160
|
+
tool_result=tool_result,
|
|
161
|
+
timestamp=timestamp,
|
|
162
|
+
raw_json=data,
|
|
163
|
+
usage=self._extract_usage(data),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def _extract_usage(self, data: dict[str, Any]) -> TokenUsage | None:
|
|
167
|
+
"""Extract token usage from Gemini message data."""
|
|
168
|
+
# Gemini API standard is usageMetadata
|
|
169
|
+
usage_data = data.get("usageMetadata")
|
|
170
|
+
|
|
171
|
+
if not usage_data:
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
return TokenUsage(
|
|
175
|
+
input_tokens=usage_data.get("promptTokenCount", 0),
|
|
176
|
+
output_tokens=usage_data.get("candidatesTokenCount", 0),
|
|
177
|
+
# Gemini doesn't always split cache tokens in this view, strictly speaking
|
|
178
|
+
# but usually total is prompt + candidates
|
|
179
|
+
total_cost_usd=None, # Cost calculation not standard in CLI output
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def parse_lines(self, lines: list[str], start_index: int = 0) -> list[ParsedMessage]:
|
|
183
|
+
"""
|
|
184
|
+
Parse a list of transcript lines.
|
|
185
|
+
"""
|
|
186
|
+
parsed_messages = []
|
|
187
|
+
current_index = start_index
|
|
188
|
+
|
|
189
|
+
for line in lines:
|
|
190
|
+
message = self.parse_line(line, current_index)
|
|
191
|
+
if message:
|
|
192
|
+
parsed_messages.append(message)
|
|
193
|
+
current_index += 1
|
|
194
|
+
|
|
195
|
+
return parsed_messages
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Local storage layer for Gobby daemon."""
|
|
2
|
+
|
|
3
|
+
from gobby.storage.database import LocalDatabase
|
|
4
|
+
from gobby.storage.inter_session_messages import InterSessionMessageManager
|
|
5
|
+
from gobby.storage.mcp import LocalMCPManager
|
|
6
|
+
from gobby.storage.migrations import run_migrations
|
|
7
|
+
from gobby.storage.projects import LocalProjectManager
|
|
8
|
+
from gobby.storage.sessions import LocalSessionManager
|
|
9
|
+
from gobby.storage.task_dependencies import TaskDependencyManager
|
|
10
|
+
from gobby.storage.tasks import LocalTaskManager
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"InterSessionMessageManager",
|
|
14
|
+
"LocalDatabase",
|
|
15
|
+
"LocalMCPManager",
|
|
16
|
+
"LocalProjectManager",
|
|
17
|
+
"LocalSessionManager",
|
|
18
|
+
"LocalTaskManager",
|
|
19
|
+
"TaskDependencyManager",
|
|
20
|
+
"run_migrations",
|
|
21
|
+
]
|
gobby/storage/agents.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""Storage manager for agent runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
from gobby.storage.database import DatabaseProtocol
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
AgentRunStatus = Literal["pending", "running", "success", "error", "timeout", "cancelled"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class AgentRun:
|
|
20
|
+
"""Agent run data model."""
|
|
21
|
+
|
|
22
|
+
id: str
|
|
23
|
+
parent_session_id: str
|
|
24
|
+
provider: str
|
|
25
|
+
prompt: str
|
|
26
|
+
status: AgentRunStatus
|
|
27
|
+
created_at: str
|
|
28
|
+
updated_at: str
|
|
29
|
+
# Optional fields
|
|
30
|
+
child_session_id: str | None = None
|
|
31
|
+
workflow_name: str | None = None
|
|
32
|
+
model: str | None = None
|
|
33
|
+
result: str | None = None
|
|
34
|
+
error: str | None = None
|
|
35
|
+
tool_calls_count: int = 0
|
|
36
|
+
turns_used: int = 0
|
|
37
|
+
started_at: str | None = None
|
|
38
|
+
completed_at: str | None = None
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_row(cls, row: Any) -> AgentRun:
|
|
42
|
+
"""Create AgentRun from database row."""
|
|
43
|
+
return cls(
|
|
44
|
+
id=row["id"],
|
|
45
|
+
parent_session_id=row["parent_session_id"],
|
|
46
|
+
child_session_id=row["child_session_id"],
|
|
47
|
+
workflow_name=row["workflow_name"],
|
|
48
|
+
provider=row["provider"],
|
|
49
|
+
model=row["model"],
|
|
50
|
+
status=row["status"],
|
|
51
|
+
prompt=row["prompt"],
|
|
52
|
+
result=row["result"],
|
|
53
|
+
error=row["error"],
|
|
54
|
+
tool_calls_count=row["tool_calls_count"] or 0,
|
|
55
|
+
turns_used=row["turns_used"] or 0,
|
|
56
|
+
started_at=row["started_at"],
|
|
57
|
+
completed_at=row["completed_at"],
|
|
58
|
+
created_at=row["created_at"],
|
|
59
|
+
updated_at=row["updated_at"],
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict[str, Any]:
|
|
63
|
+
"""Convert to dictionary."""
|
|
64
|
+
return {
|
|
65
|
+
"id": self.id,
|
|
66
|
+
"parent_session_id": self.parent_session_id,
|
|
67
|
+
"child_session_id": self.child_session_id,
|
|
68
|
+
"workflow_name": self.workflow_name,
|
|
69
|
+
"provider": self.provider,
|
|
70
|
+
"model": self.model,
|
|
71
|
+
"status": self.status,
|
|
72
|
+
"prompt": self.prompt,
|
|
73
|
+
"result": self.result,
|
|
74
|
+
"error": self.error,
|
|
75
|
+
"tool_calls_count": self.tool_calls_count,
|
|
76
|
+
"turns_used": self.turns_used,
|
|
77
|
+
"started_at": self.started_at,
|
|
78
|
+
"completed_at": self.completed_at,
|
|
79
|
+
"created_at": self.created_at,
|
|
80
|
+
"updated_at": self.updated_at,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class LocalAgentRunManager:
|
|
85
|
+
"""Manager for agent run storage operations."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, db: DatabaseProtocol):
|
|
88
|
+
"""Initialize with database connection."""
|
|
89
|
+
self.db = db
|
|
90
|
+
|
|
91
|
+
def create(
|
|
92
|
+
self,
|
|
93
|
+
parent_session_id: str,
|
|
94
|
+
provider: str,
|
|
95
|
+
prompt: str,
|
|
96
|
+
workflow_name: str | None = None,
|
|
97
|
+
model: str | None = None,
|
|
98
|
+
child_session_id: str | None = None,
|
|
99
|
+
) -> AgentRun:
|
|
100
|
+
"""
|
|
101
|
+
Create a new agent run.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
parent_session_id: Session that spawned this agent.
|
|
105
|
+
provider: LLM provider (claude, gemini, etc.)
|
|
106
|
+
prompt: The prompt given to the agent.
|
|
107
|
+
workflow_name: Optional workflow being executed.
|
|
108
|
+
model: Optional model override.
|
|
109
|
+
child_session_id: Optional child session for the agent.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Created AgentRun.
|
|
113
|
+
"""
|
|
114
|
+
run_id = f"ar-{uuid.uuid4().hex[:12]}"
|
|
115
|
+
now = datetime.now(UTC).isoformat()
|
|
116
|
+
|
|
117
|
+
self.db.execute(
|
|
118
|
+
"""
|
|
119
|
+
INSERT INTO agent_runs (
|
|
120
|
+
id, parent_session_id, child_session_id, workflow_name,
|
|
121
|
+
provider, model, status, prompt, created_at, updated_at
|
|
122
|
+
)
|
|
123
|
+
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)
|
|
124
|
+
""",
|
|
125
|
+
(
|
|
126
|
+
run_id,
|
|
127
|
+
parent_session_id,
|
|
128
|
+
child_session_id,
|
|
129
|
+
workflow_name,
|
|
130
|
+
provider,
|
|
131
|
+
model,
|
|
132
|
+
prompt,
|
|
133
|
+
now,
|
|
134
|
+
now,
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
logger.debug(f"Created agent run {run_id} for session {parent_session_id}")
|
|
139
|
+
agent_run = self.get(run_id)
|
|
140
|
+
if agent_run is None:
|
|
141
|
+
raise RuntimeError(f"Failed to retrieve newly created agent run: {run_id}")
|
|
142
|
+
return agent_run
|
|
143
|
+
|
|
144
|
+
def get(self, run_id: str) -> AgentRun | None:
|
|
145
|
+
"""Get agent run by ID."""
|
|
146
|
+
row = self.db.fetchone("SELECT * FROM agent_runs WHERE id = ?", (run_id,))
|
|
147
|
+
return AgentRun.from_row(row) if row else None
|
|
148
|
+
|
|
149
|
+
def start(self, run_id: str) -> AgentRun | None:
|
|
150
|
+
"""Mark agent run as started."""
|
|
151
|
+
now = datetime.now(UTC).isoformat()
|
|
152
|
+
self.db.execute(
|
|
153
|
+
"""
|
|
154
|
+
UPDATE agent_runs
|
|
155
|
+
SET status = 'running', started_at = ?, updated_at = ?
|
|
156
|
+
WHERE id = ?
|
|
157
|
+
""",
|
|
158
|
+
(now, now, run_id),
|
|
159
|
+
)
|
|
160
|
+
return self.get(run_id)
|
|
161
|
+
|
|
162
|
+
def complete(
|
|
163
|
+
self,
|
|
164
|
+
run_id: str,
|
|
165
|
+
result: str,
|
|
166
|
+
tool_calls_count: int = 0,
|
|
167
|
+
turns_used: int = 0,
|
|
168
|
+
) -> AgentRun | None:
|
|
169
|
+
"""
|
|
170
|
+
Mark agent run as completed successfully.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
run_id: The agent run ID.
|
|
174
|
+
result: The agent's output/result.
|
|
175
|
+
tool_calls_count: Number of tool calls made.
|
|
176
|
+
turns_used: Number of turns used.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Updated AgentRun.
|
|
180
|
+
"""
|
|
181
|
+
now = datetime.now(UTC).isoformat()
|
|
182
|
+
self.db.execute(
|
|
183
|
+
"""
|
|
184
|
+
UPDATE agent_runs
|
|
185
|
+
SET status = 'success',
|
|
186
|
+
result = ?,
|
|
187
|
+
tool_calls_count = ?,
|
|
188
|
+
turns_used = ?,
|
|
189
|
+
completed_at = ?,
|
|
190
|
+
updated_at = ?
|
|
191
|
+
WHERE id = ?
|
|
192
|
+
""",
|
|
193
|
+
(result, tool_calls_count, turns_used, now, now, run_id),
|
|
194
|
+
)
|
|
195
|
+
return self.get(run_id)
|
|
196
|
+
|
|
197
|
+
def fail(
|
|
198
|
+
self,
|
|
199
|
+
run_id: str,
|
|
200
|
+
error: str,
|
|
201
|
+
tool_calls_count: int = 0,
|
|
202
|
+
turns_used: int = 0,
|
|
203
|
+
) -> AgentRun | None:
|
|
204
|
+
"""
|
|
205
|
+
Mark agent run as failed.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
run_id: The agent run ID.
|
|
209
|
+
error: Error message.
|
|
210
|
+
tool_calls_count: Number of tool calls made before failure.
|
|
211
|
+
turns_used: Number of turns used before failure.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Updated AgentRun.
|
|
215
|
+
"""
|
|
216
|
+
now = datetime.now(UTC).isoformat()
|
|
217
|
+
self.db.execute(
|
|
218
|
+
"""
|
|
219
|
+
UPDATE agent_runs
|
|
220
|
+
SET status = 'error',
|
|
221
|
+
error = ?,
|
|
222
|
+
tool_calls_count = ?,
|
|
223
|
+
turns_used = ?,
|
|
224
|
+
completed_at = ?,
|
|
225
|
+
updated_at = ?
|
|
226
|
+
WHERE id = ?
|
|
227
|
+
""",
|
|
228
|
+
(error, tool_calls_count, turns_used, now, now, run_id),
|
|
229
|
+
)
|
|
230
|
+
return self.get(run_id)
|
|
231
|
+
|
|
232
|
+
def timeout(self, run_id: str, turns_used: int = 0) -> AgentRun | None:
|
|
233
|
+
"""Mark agent run as timed out."""
|
|
234
|
+
now = datetime.now(UTC).isoformat()
|
|
235
|
+
self.db.execute(
|
|
236
|
+
"""
|
|
237
|
+
UPDATE agent_runs
|
|
238
|
+
SET status = 'timeout',
|
|
239
|
+
error = 'Execution timed out',
|
|
240
|
+
turns_used = ?,
|
|
241
|
+
completed_at = ?,
|
|
242
|
+
updated_at = ?
|
|
243
|
+
WHERE id = ?
|
|
244
|
+
""",
|
|
245
|
+
(turns_used, now, now, run_id),
|
|
246
|
+
)
|
|
247
|
+
return self.get(run_id)
|
|
248
|
+
|
|
249
|
+
def cancel(self, run_id: str) -> AgentRun | None:
|
|
250
|
+
"""Mark agent run as cancelled."""
|
|
251
|
+
now = datetime.now(UTC).isoformat()
|
|
252
|
+
self.db.execute(
|
|
253
|
+
"""
|
|
254
|
+
UPDATE agent_runs
|
|
255
|
+
SET status = 'cancelled', completed_at = ?, updated_at = ?
|
|
256
|
+
WHERE id = ?
|
|
257
|
+
""",
|
|
258
|
+
(now, now, run_id),
|
|
259
|
+
)
|
|
260
|
+
return self.get(run_id)
|
|
261
|
+
|
|
262
|
+
def update_child_session(self, run_id: str, child_session_id: str) -> AgentRun | None:
|
|
263
|
+
"""Update the child session ID for an agent run."""
|
|
264
|
+
now = datetime.now(UTC).isoformat()
|
|
265
|
+
self.db.execute(
|
|
266
|
+
"""
|
|
267
|
+
UPDATE agent_runs
|
|
268
|
+
SET child_session_id = ?, updated_at = ?
|
|
269
|
+
WHERE id = ?
|
|
270
|
+
""",
|
|
271
|
+
(child_session_id, now, run_id),
|
|
272
|
+
)
|
|
273
|
+
return self.get(run_id)
|
|
274
|
+
|
|
275
|
+
def list_by_session(
|
|
276
|
+
self,
|
|
277
|
+
parent_session_id: str,
|
|
278
|
+
status: AgentRunStatus | None = None,
|
|
279
|
+
limit: int = 100,
|
|
280
|
+
) -> list[AgentRun]:
|
|
281
|
+
"""
|
|
282
|
+
List agent runs for a session.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
parent_session_id: The parent session ID.
|
|
286
|
+
status: Optional status filter.
|
|
287
|
+
limit: Maximum number of results.
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
List of AgentRun objects.
|
|
291
|
+
"""
|
|
292
|
+
if status:
|
|
293
|
+
rows = self.db.fetchall(
|
|
294
|
+
"""
|
|
295
|
+
SELECT * FROM agent_runs
|
|
296
|
+
WHERE parent_session_id = ? AND status = ?
|
|
297
|
+
ORDER BY created_at DESC
|
|
298
|
+
LIMIT ?
|
|
299
|
+
""",
|
|
300
|
+
(parent_session_id, status, limit),
|
|
301
|
+
)
|
|
302
|
+
else:
|
|
303
|
+
rows = self.db.fetchall(
|
|
304
|
+
"""
|
|
305
|
+
SELECT * FROM agent_runs
|
|
306
|
+
WHERE parent_session_id = ?
|
|
307
|
+
ORDER BY created_at DESC
|
|
308
|
+
LIMIT ?
|
|
309
|
+
""",
|
|
310
|
+
(parent_session_id, limit),
|
|
311
|
+
)
|
|
312
|
+
return [AgentRun.from_row(row) for row in rows]
|
|
313
|
+
|
|
314
|
+
def list_running(self, limit: int = 100) -> list[AgentRun]:
|
|
315
|
+
"""List all currently running agent runs."""
|
|
316
|
+
rows = self.db.fetchall(
|
|
317
|
+
"""
|
|
318
|
+
SELECT * FROM agent_runs
|
|
319
|
+
WHERE status = 'running'
|
|
320
|
+
ORDER BY started_at ASC
|
|
321
|
+
LIMIT ?
|
|
322
|
+
""",
|
|
323
|
+
(limit,),
|
|
324
|
+
)
|
|
325
|
+
return [AgentRun.from_row(row) for row in rows]
|
|
326
|
+
|
|
327
|
+
def count_by_session(self, parent_session_id: str) -> dict[str, int]:
|
|
328
|
+
"""
|
|
329
|
+
Count agent runs by status for a session.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
parent_session_id: The parent session ID.
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
Dict mapping status to count.
|
|
336
|
+
"""
|
|
337
|
+
rows = self.db.fetchall(
|
|
338
|
+
"""
|
|
339
|
+
SELECT status, COUNT(*) as count
|
|
340
|
+
FROM agent_runs
|
|
341
|
+
WHERE parent_session_id = ?
|
|
342
|
+
GROUP BY status
|
|
343
|
+
""",
|
|
344
|
+
(parent_session_id,),
|
|
345
|
+
)
|
|
346
|
+
return {row["status"]: row["count"] for row in rows}
|
|
347
|
+
|
|
348
|
+
def delete(self, run_id: str) -> bool:
|
|
349
|
+
"""Delete an agent run."""
|
|
350
|
+
cursor = self.db.execute("DELETE FROM agent_runs WHERE id = ?", (run_id,))
|
|
351
|
+
return bool(cursor.rowcount and cursor.rowcount > 0)
|
|
352
|
+
|
|
353
|
+
def cleanup_stale_runs(self, timeout_minutes: int = 30) -> int:
|
|
354
|
+
"""
|
|
355
|
+
Mark stale running agent runs as timed out.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
timeout_minutes: Minutes of inactivity before timeout.
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
Number of runs timed out.
|
|
362
|
+
"""
|
|
363
|
+
now = datetime.now(UTC).isoformat()
|
|
364
|
+
cursor = self.db.execute(
|
|
365
|
+
"""
|
|
366
|
+
UPDATE agent_runs
|
|
367
|
+
SET status = 'timeout',
|
|
368
|
+
error = 'Stale run timed out',
|
|
369
|
+
completed_at = ?,
|
|
370
|
+
updated_at = ?
|
|
371
|
+
WHERE status = 'running'
|
|
372
|
+
AND datetime(started_at) < datetime('now', 'utc', ? || ' minutes')
|
|
373
|
+
""",
|
|
374
|
+
(now, now, f"-{timeout_minutes}"),
|
|
375
|
+
)
|
|
376
|
+
count = cursor.rowcount or 0
|
|
377
|
+
if count > 0:
|
|
378
|
+
logger.info(f"Timed out {count} stale agent runs (>{timeout_minutes}m)")
|
|
379
|
+
return count
|
|
380
|
+
|
|
381
|
+
def cleanup_stale_pending_runs(self, timeout_minutes: int = 60) -> int:
|
|
382
|
+
"""
|
|
383
|
+
Mark stale pending agent runs as failed.
|
|
384
|
+
|
|
385
|
+
Pending runs that never started within the timeout period are marked as errors.
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
timeout_minutes: Minutes since creation before marking as failed.
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
Number of runs failed.
|
|
392
|
+
"""
|
|
393
|
+
now = datetime.now(UTC).isoformat()
|
|
394
|
+
cursor = self.db.execute(
|
|
395
|
+
"""
|
|
396
|
+
UPDATE agent_runs
|
|
397
|
+
SET status = 'error',
|
|
398
|
+
error = 'Pending run never started',
|
|
399
|
+
completed_at = ?,
|
|
400
|
+
updated_at = ?
|
|
401
|
+
WHERE status = 'pending'
|
|
402
|
+
AND datetime(created_at) < datetime('now', 'utc', ? || ' minutes')
|
|
403
|
+
""",
|
|
404
|
+
(now, now, f"-{timeout_minutes}"),
|
|
405
|
+
)
|
|
406
|
+
count = cursor.rowcount or 0
|
|
407
|
+
if count > 0:
|
|
408
|
+
logger.info(f"Failed {count} stale pending agent runs (>{timeout_minutes}m)")
|
|
409
|
+
return count
|