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.
Files changed (383) hide show
  1. gobby/__init__.py +3 -0
  2. gobby/adapters/__init__.py +30 -0
  3. gobby/adapters/base.py +93 -0
  4. gobby/adapters/claude_code.py +276 -0
  5. gobby/adapters/codex.py +1292 -0
  6. gobby/adapters/gemini.py +343 -0
  7. gobby/agents/__init__.py +37 -0
  8. gobby/agents/codex_session.py +120 -0
  9. gobby/agents/constants.py +112 -0
  10. gobby/agents/context.py +362 -0
  11. gobby/agents/definitions.py +133 -0
  12. gobby/agents/gemini_session.py +111 -0
  13. gobby/agents/registry.py +618 -0
  14. gobby/agents/runner.py +968 -0
  15. gobby/agents/session.py +259 -0
  16. gobby/agents/spawn.py +916 -0
  17. gobby/agents/spawners/__init__.py +77 -0
  18. gobby/agents/spawners/base.py +142 -0
  19. gobby/agents/spawners/cross_platform.py +266 -0
  20. gobby/agents/spawners/embedded.py +225 -0
  21. gobby/agents/spawners/headless.py +226 -0
  22. gobby/agents/spawners/linux.py +125 -0
  23. gobby/agents/spawners/macos.py +277 -0
  24. gobby/agents/spawners/windows.py +308 -0
  25. gobby/agents/tty_config.py +319 -0
  26. gobby/autonomous/__init__.py +32 -0
  27. gobby/autonomous/progress_tracker.py +447 -0
  28. gobby/autonomous/stop_registry.py +269 -0
  29. gobby/autonomous/stuck_detector.py +383 -0
  30. gobby/cli/__init__.py +67 -0
  31. gobby/cli/__main__.py +8 -0
  32. gobby/cli/agents.py +529 -0
  33. gobby/cli/artifacts.py +266 -0
  34. gobby/cli/daemon.py +329 -0
  35. gobby/cli/extensions.py +526 -0
  36. gobby/cli/github.py +263 -0
  37. gobby/cli/init.py +53 -0
  38. gobby/cli/install.py +614 -0
  39. gobby/cli/installers/__init__.py +37 -0
  40. gobby/cli/installers/antigravity.py +65 -0
  41. gobby/cli/installers/claude.py +363 -0
  42. gobby/cli/installers/codex.py +192 -0
  43. gobby/cli/installers/gemini.py +294 -0
  44. gobby/cli/installers/git_hooks.py +377 -0
  45. gobby/cli/installers/shared.py +737 -0
  46. gobby/cli/linear.py +250 -0
  47. gobby/cli/mcp.py +30 -0
  48. gobby/cli/mcp_proxy.py +698 -0
  49. gobby/cli/memory.py +304 -0
  50. gobby/cli/merge.py +384 -0
  51. gobby/cli/projects.py +79 -0
  52. gobby/cli/sessions.py +622 -0
  53. gobby/cli/tasks/__init__.py +30 -0
  54. gobby/cli/tasks/_utils.py +658 -0
  55. gobby/cli/tasks/ai.py +1025 -0
  56. gobby/cli/tasks/commits.py +169 -0
  57. gobby/cli/tasks/crud.py +685 -0
  58. gobby/cli/tasks/deps.py +135 -0
  59. gobby/cli/tasks/labels.py +63 -0
  60. gobby/cli/tasks/main.py +273 -0
  61. gobby/cli/tasks/search.py +178 -0
  62. gobby/cli/tui.py +34 -0
  63. gobby/cli/utils.py +513 -0
  64. gobby/cli/workflows.py +927 -0
  65. gobby/cli/worktrees.py +481 -0
  66. gobby/config/__init__.py +129 -0
  67. gobby/config/app.py +551 -0
  68. gobby/config/extensions.py +167 -0
  69. gobby/config/features.py +472 -0
  70. gobby/config/llm_providers.py +98 -0
  71. gobby/config/logging.py +66 -0
  72. gobby/config/mcp.py +346 -0
  73. gobby/config/persistence.py +247 -0
  74. gobby/config/servers.py +141 -0
  75. gobby/config/sessions.py +250 -0
  76. gobby/config/tasks.py +784 -0
  77. gobby/hooks/__init__.py +104 -0
  78. gobby/hooks/artifact_capture.py +213 -0
  79. gobby/hooks/broadcaster.py +243 -0
  80. gobby/hooks/event_handlers.py +723 -0
  81. gobby/hooks/events.py +218 -0
  82. gobby/hooks/git.py +169 -0
  83. gobby/hooks/health_monitor.py +171 -0
  84. gobby/hooks/hook_manager.py +856 -0
  85. gobby/hooks/hook_types.py +575 -0
  86. gobby/hooks/plugins.py +813 -0
  87. gobby/hooks/session_coordinator.py +396 -0
  88. gobby/hooks/verification_runner.py +268 -0
  89. gobby/hooks/webhooks.py +339 -0
  90. gobby/install/claude/commands/gobby/bug.md +51 -0
  91. gobby/install/claude/commands/gobby/chore.md +51 -0
  92. gobby/install/claude/commands/gobby/epic.md +52 -0
  93. gobby/install/claude/commands/gobby/eval.md +235 -0
  94. gobby/install/claude/commands/gobby/feat.md +49 -0
  95. gobby/install/claude/commands/gobby/nit.md +52 -0
  96. gobby/install/claude/commands/gobby/ref.md +52 -0
  97. gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
  98. gobby/install/claude/hooks/hook_dispatcher.py +364 -0
  99. gobby/install/claude/hooks/validate_settings.py +102 -0
  100. gobby/install/claude/hooks-template.json +118 -0
  101. gobby/install/codex/hooks/hook_dispatcher.py +153 -0
  102. gobby/install/codex/prompts/forget.md +7 -0
  103. gobby/install/codex/prompts/memories.md +7 -0
  104. gobby/install/codex/prompts/recall.md +7 -0
  105. gobby/install/codex/prompts/remember.md +13 -0
  106. gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
  107. gobby/install/gemini/hooks-template.json +138 -0
  108. gobby/install/shared/plugins/code_guardian.py +456 -0
  109. gobby/install/shared/plugins/example_notify.py +331 -0
  110. gobby/integrations/__init__.py +10 -0
  111. gobby/integrations/github.py +145 -0
  112. gobby/integrations/linear.py +145 -0
  113. gobby/llm/__init__.py +40 -0
  114. gobby/llm/base.py +120 -0
  115. gobby/llm/claude.py +578 -0
  116. gobby/llm/claude_executor.py +503 -0
  117. gobby/llm/codex.py +322 -0
  118. gobby/llm/codex_executor.py +513 -0
  119. gobby/llm/executor.py +316 -0
  120. gobby/llm/factory.py +34 -0
  121. gobby/llm/gemini.py +258 -0
  122. gobby/llm/gemini_executor.py +339 -0
  123. gobby/llm/litellm.py +287 -0
  124. gobby/llm/litellm_executor.py +303 -0
  125. gobby/llm/resolver.py +499 -0
  126. gobby/llm/service.py +236 -0
  127. gobby/mcp_proxy/__init__.py +29 -0
  128. gobby/mcp_proxy/actions.py +175 -0
  129. gobby/mcp_proxy/daemon_control.py +198 -0
  130. gobby/mcp_proxy/importer.py +436 -0
  131. gobby/mcp_proxy/lazy.py +325 -0
  132. gobby/mcp_proxy/manager.py +798 -0
  133. gobby/mcp_proxy/metrics.py +609 -0
  134. gobby/mcp_proxy/models.py +139 -0
  135. gobby/mcp_proxy/registries.py +215 -0
  136. gobby/mcp_proxy/schema_hash.py +381 -0
  137. gobby/mcp_proxy/semantic_search.py +706 -0
  138. gobby/mcp_proxy/server.py +549 -0
  139. gobby/mcp_proxy/services/__init__.py +0 -0
  140. gobby/mcp_proxy/services/fallback.py +306 -0
  141. gobby/mcp_proxy/services/recommendation.py +224 -0
  142. gobby/mcp_proxy/services/server_mgmt.py +214 -0
  143. gobby/mcp_proxy/services/system.py +72 -0
  144. gobby/mcp_proxy/services/tool_filter.py +231 -0
  145. gobby/mcp_proxy/services/tool_proxy.py +309 -0
  146. gobby/mcp_proxy/stdio.py +565 -0
  147. gobby/mcp_proxy/tools/__init__.py +27 -0
  148. gobby/mcp_proxy/tools/agents.py +1103 -0
  149. gobby/mcp_proxy/tools/artifacts.py +207 -0
  150. gobby/mcp_proxy/tools/hub.py +335 -0
  151. gobby/mcp_proxy/tools/internal.py +337 -0
  152. gobby/mcp_proxy/tools/memory.py +543 -0
  153. gobby/mcp_proxy/tools/merge.py +422 -0
  154. gobby/mcp_proxy/tools/metrics.py +283 -0
  155. gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
  156. gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
  157. gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
  158. gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
  159. gobby/mcp_proxy/tools/orchestration/review.py +736 -0
  160. gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
  161. gobby/mcp_proxy/tools/session_messages.py +1056 -0
  162. gobby/mcp_proxy/tools/task_dependencies.py +219 -0
  163. gobby/mcp_proxy/tools/task_expansion.py +591 -0
  164. gobby/mcp_proxy/tools/task_github.py +393 -0
  165. gobby/mcp_proxy/tools/task_linear.py +379 -0
  166. gobby/mcp_proxy/tools/task_orchestration.py +77 -0
  167. gobby/mcp_proxy/tools/task_readiness.py +522 -0
  168. gobby/mcp_proxy/tools/task_sync.py +351 -0
  169. gobby/mcp_proxy/tools/task_validation.py +843 -0
  170. gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
  171. gobby/mcp_proxy/tools/tasks/_context.py +112 -0
  172. gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
  173. gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
  174. gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
  175. gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
  176. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
  177. gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
  178. gobby/mcp_proxy/tools/tasks/_search.py +215 -0
  179. gobby/mcp_proxy/tools/tasks/_session.py +125 -0
  180. gobby/mcp_proxy/tools/workflows.py +973 -0
  181. gobby/mcp_proxy/tools/worktrees.py +1264 -0
  182. gobby/mcp_proxy/transports/__init__.py +0 -0
  183. gobby/mcp_proxy/transports/base.py +95 -0
  184. gobby/mcp_proxy/transports/factory.py +44 -0
  185. gobby/mcp_proxy/transports/http.py +139 -0
  186. gobby/mcp_proxy/transports/stdio.py +213 -0
  187. gobby/mcp_proxy/transports/websocket.py +136 -0
  188. gobby/memory/backends/__init__.py +116 -0
  189. gobby/memory/backends/mem0.py +408 -0
  190. gobby/memory/backends/memu.py +485 -0
  191. gobby/memory/backends/null.py +111 -0
  192. gobby/memory/backends/openmemory.py +537 -0
  193. gobby/memory/backends/sqlite.py +304 -0
  194. gobby/memory/context.py +87 -0
  195. gobby/memory/manager.py +1001 -0
  196. gobby/memory/protocol.py +451 -0
  197. gobby/memory/search/__init__.py +66 -0
  198. gobby/memory/search/text.py +127 -0
  199. gobby/memory/viz.py +258 -0
  200. gobby/prompts/__init__.py +13 -0
  201. gobby/prompts/defaults/expansion/system.md +119 -0
  202. gobby/prompts/defaults/expansion/user.md +48 -0
  203. gobby/prompts/defaults/external_validation/agent.md +72 -0
  204. gobby/prompts/defaults/external_validation/external.md +63 -0
  205. gobby/prompts/defaults/external_validation/spawn.md +83 -0
  206. gobby/prompts/defaults/external_validation/system.md +6 -0
  207. gobby/prompts/defaults/features/import_mcp.md +22 -0
  208. gobby/prompts/defaults/features/import_mcp_github.md +17 -0
  209. gobby/prompts/defaults/features/import_mcp_search.md +16 -0
  210. gobby/prompts/defaults/features/recommend_tools.md +32 -0
  211. gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
  212. gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
  213. gobby/prompts/defaults/features/server_description.md +20 -0
  214. gobby/prompts/defaults/features/server_description_system.md +6 -0
  215. gobby/prompts/defaults/features/task_description.md +31 -0
  216. gobby/prompts/defaults/features/task_description_system.md +6 -0
  217. gobby/prompts/defaults/features/tool_summary.md +17 -0
  218. gobby/prompts/defaults/features/tool_summary_system.md +6 -0
  219. gobby/prompts/defaults/research/step.md +58 -0
  220. gobby/prompts/defaults/validation/criteria.md +47 -0
  221. gobby/prompts/defaults/validation/validate.md +38 -0
  222. gobby/prompts/loader.py +346 -0
  223. gobby/prompts/models.py +113 -0
  224. gobby/py.typed +0 -0
  225. gobby/runner.py +488 -0
  226. gobby/search/__init__.py +23 -0
  227. gobby/search/protocol.py +104 -0
  228. gobby/search/tfidf.py +232 -0
  229. gobby/servers/__init__.py +7 -0
  230. gobby/servers/http.py +636 -0
  231. gobby/servers/models.py +31 -0
  232. gobby/servers/routes/__init__.py +23 -0
  233. gobby/servers/routes/admin.py +416 -0
  234. gobby/servers/routes/dependencies.py +118 -0
  235. gobby/servers/routes/mcp/__init__.py +24 -0
  236. gobby/servers/routes/mcp/hooks.py +135 -0
  237. gobby/servers/routes/mcp/plugins.py +121 -0
  238. gobby/servers/routes/mcp/tools.py +1337 -0
  239. gobby/servers/routes/mcp/webhooks.py +159 -0
  240. gobby/servers/routes/sessions.py +582 -0
  241. gobby/servers/websocket.py +766 -0
  242. gobby/sessions/__init__.py +13 -0
  243. gobby/sessions/analyzer.py +322 -0
  244. gobby/sessions/lifecycle.py +240 -0
  245. gobby/sessions/manager.py +563 -0
  246. gobby/sessions/processor.py +225 -0
  247. gobby/sessions/summary.py +532 -0
  248. gobby/sessions/transcripts/__init__.py +41 -0
  249. gobby/sessions/transcripts/base.py +125 -0
  250. gobby/sessions/transcripts/claude.py +386 -0
  251. gobby/sessions/transcripts/codex.py +143 -0
  252. gobby/sessions/transcripts/gemini.py +195 -0
  253. gobby/storage/__init__.py +21 -0
  254. gobby/storage/agents.py +409 -0
  255. gobby/storage/artifact_classifier.py +341 -0
  256. gobby/storage/artifacts.py +285 -0
  257. gobby/storage/compaction.py +67 -0
  258. gobby/storage/database.py +357 -0
  259. gobby/storage/inter_session_messages.py +194 -0
  260. gobby/storage/mcp.py +680 -0
  261. gobby/storage/memories.py +562 -0
  262. gobby/storage/merge_resolutions.py +550 -0
  263. gobby/storage/migrations.py +860 -0
  264. gobby/storage/migrations_legacy.py +1359 -0
  265. gobby/storage/projects.py +166 -0
  266. gobby/storage/session_messages.py +251 -0
  267. gobby/storage/session_tasks.py +97 -0
  268. gobby/storage/sessions.py +817 -0
  269. gobby/storage/task_dependencies.py +223 -0
  270. gobby/storage/tasks/__init__.py +42 -0
  271. gobby/storage/tasks/_aggregates.py +180 -0
  272. gobby/storage/tasks/_crud.py +449 -0
  273. gobby/storage/tasks/_id.py +104 -0
  274. gobby/storage/tasks/_lifecycle.py +311 -0
  275. gobby/storage/tasks/_manager.py +889 -0
  276. gobby/storage/tasks/_models.py +300 -0
  277. gobby/storage/tasks/_ordering.py +119 -0
  278. gobby/storage/tasks/_path_cache.py +110 -0
  279. gobby/storage/tasks/_queries.py +343 -0
  280. gobby/storage/tasks/_search.py +143 -0
  281. gobby/storage/workflow_audit.py +393 -0
  282. gobby/storage/worktrees.py +547 -0
  283. gobby/sync/__init__.py +29 -0
  284. gobby/sync/github.py +333 -0
  285. gobby/sync/linear.py +304 -0
  286. gobby/sync/memories.py +284 -0
  287. gobby/sync/tasks.py +641 -0
  288. gobby/tasks/__init__.py +8 -0
  289. gobby/tasks/build_verification.py +193 -0
  290. gobby/tasks/commits.py +633 -0
  291. gobby/tasks/context.py +747 -0
  292. gobby/tasks/criteria.py +342 -0
  293. gobby/tasks/enhanced_validator.py +226 -0
  294. gobby/tasks/escalation.py +263 -0
  295. gobby/tasks/expansion.py +626 -0
  296. gobby/tasks/external_validator.py +764 -0
  297. gobby/tasks/issue_extraction.py +171 -0
  298. gobby/tasks/prompts/expand.py +327 -0
  299. gobby/tasks/research.py +421 -0
  300. gobby/tasks/tdd.py +352 -0
  301. gobby/tasks/tree_builder.py +263 -0
  302. gobby/tasks/validation.py +712 -0
  303. gobby/tasks/validation_history.py +357 -0
  304. gobby/tasks/validation_models.py +89 -0
  305. gobby/tools/__init__.py +0 -0
  306. gobby/tools/summarizer.py +170 -0
  307. gobby/tui/__init__.py +5 -0
  308. gobby/tui/api_client.py +281 -0
  309. gobby/tui/app.py +327 -0
  310. gobby/tui/screens/__init__.py +25 -0
  311. gobby/tui/screens/agents.py +333 -0
  312. gobby/tui/screens/chat.py +450 -0
  313. gobby/tui/screens/dashboard.py +377 -0
  314. gobby/tui/screens/memory.py +305 -0
  315. gobby/tui/screens/metrics.py +231 -0
  316. gobby/tui/screens/orchestrator.py +904 -0
  317. gobby/tui/screens/sessions.py +412 -0
  318. gobby/tui/screens/tasks.py +442 -0
  319. gobby/tui/screens/workflows.py +289 -0
  320. gobby/tui/screens/worktrees.py +174 -0
  321. gobby/tui/widgets/__init__.py +21 -0
  322. gobby/tui/widgets/chat.py +210 -0
  323. gobby/tui/widgets/conductor.py +104 -0
  324. gobby/tui/widgets/menu.py +132 -0
  325. gobby/tui/widgets/message_panel.py +160 -0
  326. gobby/tui/widgets/review_gate.py +224 -0
  327. gobby/tui/widgets/task_tree.py +99 -0
  328. gobby/tui/widgets/token_budget.py +166 -0
  329. gobby/tui/ws_client.py +258 -0
  330. gobby/utils/__init__.py +3 -0
  331. gobby/utils/daemon_client.py +235 -0
  332. gobby/utils/git.py +222 -0
  333. gobby/utils/id.py +38 -0
  334. gobby/utils/json_helpers.py +161 -0
  335. gobby/utils/logging.py +376 -0
  336. gobby/utils/machine_id.py +135 -0
  337. gobby/utils/metrics.py +589 -0
  338. gobby/utils/project_context.py +182 -0
  339. gobby/utils/project_init.py +263 -0
  340. gobby/utils/status.py +256 -0
  341. gobby/utils/validation.py +80 -0
  342. gobby/utils/version.py +23 -0
  343. gobby/workflows/__init__.py +4 -0
  344. gobby/workflows/actions.py +1310 -0
  345. gobby/workflows/approval_flow.py +138 -0
  346. gobby/workflows/artifact_actions.py +103 -0
  347. gobby/workflows/audit_helpers.py +110 -0
  348. gobby/workflows/autonomous_actions.py +286 -0
  349. gobby/workflows/context_actions.py +394 -0
  350. gobby/workflows/definitions.py +130 -0
  351. gobby/workflows/detection_helpers.py +208 -0
  352. gobby/workflows/engine.py +485 -0
  353. gobby/workflows/evaluator.py +669 -0
  354. gobby/workflows/git_utils.py +96 -0
  355. gobby/workflows/hooks.py +169 -0
  356. gobby/workflows/lifecycle_evaluator.py +613 -0
  357. gobby/workflows/llm_actions.py +70 -0
  358. gobby/workflows/loader.py +333 -0
  359. gobby/workflows/mcp_actions.py +60 -0
  360. gobby/workflows/memory_actions.py +272 -0
  361. gobby/workflows/premature_stop.py +164 -0
  362. gobby/workflows/session_actions.py +139 -0
  363. gobby/workflows/state_actions.py +123 -0
  364. gobby/workflows/state_manager.py +104 -0
  365. gobby/workflows/stop_signal_actions.py +163 -0
  366. gobby/workflows/summary_actions.py +344 -0
  367. gobby/workflows/task_actions.py +249 -0
  368. gobby/workflows/task_enforcement_actions.py +901 -0
  369. gobby/workflows/templates.py +52 -0
  370. gobby/workflows/todo_actions.py +84 -0
  371. gobby/workflows/webhook.py +223 -0
  372. gobby/workflows/webhook_executor.py +399 -0
  373. gobby/worktrees/__init__.py +5 -0
  374. gobby/worktrees/git.py +690 -0
  375. gobby/worktrees/merge/__init__.py +20 -0
  376. gobby/worktrees/merge/conflict_parser.py +177 -0
  377. gobby/worktrees/merge/resolver.py +485 -0
  378. gobby-0.2.5.dist-info/METADATA +351 -0
  379. gobby-0.2.5.dist-info/RECORD +383 -0
  380. gobby-0.2.5.dist-info/WHEEL +5 -0
  381. gobby-0.2.5.dist-info/entry_points.txt +2 -0
  382. gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
  383. 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