octo-agent 0.11.2
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.
- checksums.yaml +7 -0
- data/.clacky/skills/commit/SKILL.md +423 -0
- data/.clacky/skills/gem-release/SKILL.md +199 -0
- data/.clacky/skills/gem-release/scripts/release.sh +304 -0
- data/.clacky/skills/oss-upload/SKILL.md +47 -0
- data/.octorules +106 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +76 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/CONTRIBUTING.md +92 -0
- data/Dockerfile +28 -0
- data/LICENSE.txt +22 -0
- data/POSITIONING.md +46 -0
- data/README.md +134 -0
- data/README_CN.md +134 -0
- data/Rakefile +34 -0
- data/benchmark/fixtures/sample_project/Gemfile +3 -0
- data/benchmark/fixtures/sample_project/lib/api_handler.rb +32 -0
- data/benchmark/fixtures/sample_project/lib/order_calculator.rb +23 -0
- data/benchmark/fixtures/sample_project/lib/user_renderer.rb +20 -0
- data/benchmark/fixtures/sample_project/spec/order_calculator_spec.rb +20 -0
- data/benchmark/results/EVALUATION_REPORT.md +165 -0
- data/benchmark/results/baseline_20260511_174424.json +128 -0
- data/benchmark/results/report_20260511_175256.json +271 -0
- data/benchmark/results/report_20260511_175444.json +271 -0
- data/benchmark/results/treatment_20260511_175103.json +130 -0
- data/benchmark/runner.rb +441 -0
- data/bin/octo +7 -0
- data/docs/agent-first-ui-design.md +77 -0
- data/docs/billing-system.md +318 -0
- data/docs/channel-architecture.md +235 -0
- data/docs/engineering-article.md +343 -0
- data/docs/session-skill-invocation.md +69 -0
- data/docs/time_machine_design.md +247 -0
- data/docs/ui2-architecture.md +124 -0
- data/homebrew/README.md +96 -0
- data/homebrew/openocto.rb +24 -0
- data/lib/octo/agent/hook_manager.rb +61 -0
- data/lib/octo/agent/llm_caller.rb +800 -0
- data/lib/octo/agent/memory_updater.rb +246 -0
- data/lib/octo/agent/message_compressor.rb +225 -0
- data/lib/octo/agent/message_compressor_helper.rb +869 -0
- data/lib/octo/agent/next_message_suggester.rb +215 -0
- data/lib/octo/agent/session_serializer.rb +685 -0
- data/lib/octo/agent/skill_auto_creator.rb +114 -0
- data/lib/octo/agent/skill_evolution.rb +61 -0
- data/lib/octo/agent/skill_manager.rb +466 -0
- data/lib/octo/agent/skill_reflector.rb +89 -0
- data/lib/octo/agent/system_prompt_builder.rb +101 -0
- data/lib/octo/agent/time_machine.rb +214 -0
- data/lib/octo/agent/tool_executor.rb +454 -0
- data/lib/octo/agent/tool_registry.rb +150 -0
- data/lib/octo/agent.rb +2180 -0
- data/lib/octo/agent_config.rb +989 -0
- data/lib/octo/agent_profile.rb +112 -0
- data/lib/octo/anthropic_stream_aggregator.rb +137 -0
- data/lib/octo/background_task_registry.rb +324 -0
- data/lib/octo/banner.rb +34 -0
- data/lib/octo/bedrock_stream_aggregator.rb +137 -0
- data/lib/octo/block_font.rb +331 -0
- data/lib/octo/cli.rb +968 -0
- data/lib/octo/client.rb +623 -0
- data/lib/octo/default_agents/SOUL.md +3 -0
- data/lib/octo/default_agents/USER.md +1 -0
- data/lib/octo/default_agents/base_prompt.md +66 -0
- data/lib/octo/default_agents/coding/profile.yml +2 -0
- data/lib/octo/default_agents/coding/system_prompt.md +67 -0
- data/lib/octo/default_agents/general/profile.yml +2 -0
- data/lib/octo/default_agents/general/system_prompt.md +16 -0
- data/lib/octo/default_parsers/doc_parser.rb +69 -0
- data/lib/octo/default_parsers/docx_parser.rb +188 -0
- data/lib/octo/default_parsers/pdf_parser.rb +120 -0
- data/lib/octo/default_parsers/pdf_parser_ocr.py +103 -0
- data/lib/octo/default_parsers/pdf_parser_plumber.py +62 -0
- data/lib/octo/default_parsers/pptx_parser.rb +140 -0
- data/lib/octo/default_parsers/xlsx_parser.rb +121 -0
- data/lib/octo/default_skills/browser-setup/SKILL.md +426 -0
- data/lib/octo/default_skills/channel-manager/SKILL.md +623 -0
- data/lib/octo/default_skills/channel-manager/dingtalk_setup.rb +191 -0
- data/lib/octo/default_skills/channel-manager/discord_setup.rb +199 -0
- data/lib/octo/default_skills/channel-manager/feishu_setup.rb +574 -0
- data/lib/octo/default_skills/channel-manager/import_lark_skills.rb +97 -0
- data/lib/octo/default_skills/channel-manager/install_feishu_skills.rb +105 -0
- data/lib/octo/default_skills/channel-manager/weixin_setup.rb +274 -0
- data/lib/octo/default_skills/code-explorer/SKILL.md +36 -0
- data/lib/octo/default_skills/cron-task-creator/SKILL.md +257 -0
- data/lib/octo/default_skills/cron-task-creator/evals/evals.json +38 -0
- data/lib/octo/default_skills/onboard/SKILL.md +578 -0
- data/lib/octo/default_skills/onboard/scripts/import_external_skills.rb +413 -0
- data/lib/octo/default_skills/onboard/scripts/install_builtin_skills.rb +97 -0
- data/lib/octo/default_skills/persist-memory/SKILL.md +59 -0
- data/lib/octo/default_skills/personal-website/SKILL.md +113 -0
- data/lib/octo/default_skills/personal-website/publish.rb +235 -0
- data/lib/octo/default_skills/product-help/SKILL.md +123 -0
- data/lib/octo/default_skills/product-help/docs/agent-config.md +74 -0
- data/lib/octo/default_skills/product-help/docs/best-practices.md +49 -0
- data/lib/octo/default_skills/product-help/docs/browser-tool.md +53 -0
- data/lib/octo/default_skills/product-help/docs/built-in-skills.md +43 -0
- data/lib/octo/default_skills/product-help/docs/cli-reference.md +82 -0
- data/lib/octo/default_skills/product-help/docs/create-your-first-skill.md +47 -0
- data/lib/octo/default_skills/product-help/docs/faq.md +98 -0
- data/lib/octo/default_skills/product-help/docs/how-to-use-a-skill.md +58 -0
- data/lib/octo/default_skills/product-help/docs/installation.md +59 -0
- data/lib/octo/default_skills/product-help/docs/memory-system.md +61 -0
- data/lib/octo/default_skills/product-help/docs/octorules.md +62 -0
- data/lib/octo/default_skills/product-help/docs/session-management.md +63 -0
- data/lib/octo/default_skills/product-help/docs/skill-basics.md +55 -0
- data/lib/octo/default_skills/product-help/docs/skill-frontmatter.md +61 -0
- data/lib/octo/default_skills/product-help/docs/web-server.md +49 -0
- data/lib/octo/default_skills/product-help/docs/what-is-octo.md +37 -0
- data/lib/octo/default_skills/product-help/docs/windows-installation.md +36 -0
- data/lib/octo/default_skills/product-help/docs/writing-tips.md +53 -0
- data/lib/octo/default_skills/recall-memory/SKILL.md +65 -0
- data/lib/octo/default_skills/skill-add/SKILL.md +59 -0
- data/lib/octo/default_skills/skill-add/scripts/install_from_zip.rb +295 -0
- data/lib/octo/default_skills/skill-creator/SKILL.md +602 -0
- data/lib/octo/default_skills/skill-creator/agents/analyzer.md +274 -0
- data/lib/octo/default_skills/skill-creator/agents/comparator.md +202 -0
- data/lib/octo/default_skills/skill-creator/agents/grader.md +223 -0
- data/lib/octo/default_skills/skill-creator/eval-viewer/generate_review.py +471 -0
- data/lib/octo/default_skills/skill-creator/eval-viewer/viewer.html +1325 -0
- data/lib/octo/default_skills/skill-creator/references/schemas.md +430 -0
- data/lib/octo/default_skills/skill-creator/scripts/__init__.py +0 -0
- data/lib/octo/default_skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- data/lib/octo/default_skills/skill-creator/scripts/generate_report.py +326 -0
- data/lib/octo/default_skills/skill-creator/scripts/improve_description.py +310 -0
- data/lib/octo/default_skills/skill-creator/scripts/quick_validate.py +103 -0
- data/lib/octo/default_skills/skill-creator/scripts/run_eval.py +317 -0
- data/lib/octo/default_skills/skill-creator/scripts/run_loop.py +331 -0
- data/lib/octo/default_skills/skill-creator/scripts/utils.py +47 -0
- data/lib/octo/default_skills/skill-creator/scripts/validate_skill_frontmatter.rb +143 -0
- data/lib/octo/idle_compression_timer.rb +115 -0
- data/lib/octo/json_ui_controller.rb +204 -0
- data/lib/octo/message_format/anthropic.rb +409 -0
- data/lib/octo/message_format/bedrock.rb +361 -0
- data/lib/octo/message_format/open_ai.rb +222 -0
- data/lib/octo/message_history.rb +373 -0
- data/lib/octo/openai_stream_aggregator.rb +130 -0
- data/lib/octo/plain_ui_controller.rb +166 -0
- data/lib/octo/providers.rb +534 -0
- data/lib/octo/server/browser_manager.rb +397 -0
- data/lib/octo/server/channel/adapters/base.rb +82 -0
- data/lib/octo/server/channel/adapters/dingtalk/adapter.rb +314 -0
- data/lib/octo/server/channel/adapters/dingtalk/api_client.rb +391 -0
- data/lib/octo/server/channel/adapters/dingtalk/stream_client.rb +203 -0
- data/lib/octo/server/channel/adapters/discord/adapter.rb +229 -0
- data/lib/octo/server/channel/adapters/discord/api_client.rb +107 -0
- data/lib/octo/server/channel/adapters/discord/gateway_client.rb +270 -0
- data/lib/octo/server/channel/adapters/feishu/adapter.rb +320 -0
- data/lib/octo/server/channel/adapters/feishu/bot.rb +478 -0
- data/lib/octo/server/channel/adapters/feishu/file_processor.rb +36 -0
- data/lib/octo/server/channel/adapters/feishu/message_parser.rb +129 -0
- data/lib/octo/server/channel/adapters/feishu/ws_client.rb +423 -0
- data/lib/octo/server/channel/adapters/telegram/adapter.rb +375 -0
- data/lib/octo/server/channel/adapters/telegram/api_client.rb +205 -0
- data/lib/octo/server/channel/adapters/wecom/adapter.rb +148 -0
- data/lib/octo/server/channel/adapters/wecom/media_downloader.rb +115 -0
- data/lib/octo/server/channel/adapters/wecom/ws_client.rb +395 -0
- data/lib/octo/server/channel/adapters/weixin/adapter.rb +692 -0
- data/lib/octo/server/channel/adapters/weixin/api_client.rb +402 -0
- data/lib/octo/server/channel/channel_config.rb +178 -0
- data/lib/octo/server/channel/channel_manager.rb +468 -0
- data/lib/octo/server/channel/channel_ui_controller.rb +224 -0
- data/lib/octo/server/channel.rb +33 -0
- data/lib/octo/server/discover.rb +77 -0
- data/lib/octo/server/epipe_safe_io.rb +105 -0
- data/lib/octo/server/http_server.rb +3554 -0
- data/lib/octo/server/scheduler.rb +317 -0
- data/lib/octo/server/server_master.rb +325 -0
- data/lib/octo/server/session_registry.rb +431 -0
- data/lib/octo/server/web_ui_controller.rb +487 -0
- data/lib/octo/session_manager.rb +385 -0
- data/lib/octo/skill.rb +466 -0
- data/lib/octo/skill_loader.rb +328 -0
- data/lib/octo/tools/base.rb +118 -0
- data/lib/octo/tools/browser.rb +625 -0
- data/lib/octo/tools/edit.rb +165 -0
- data/lib/octo/tools/file_reader.rb +549 -0
- data/lib/octo/tools/glob.rb +162 -0
- data/lib/octo/tools/grep.rb +356 -0
- data/lib/octo/tools/invoke_skill.rb +96 -0
- data/lib/octo/tools/list_tasks.rb +54 -0
- data/lib/octo/tools/redo_task.rb +41 -0
- data/lib/octo/tools/request_user_feedback.rb +84 -0
- data/lib/octo/tools/security.rb +333 -0
- data/lib/octo/tools/terminal/output_cleaner.rb +63 -0
- data/lib/octo/tools/terminal/persistent_session.rb +268 -0
- data/lib/octo/tools/terminal/safe_rm.sh +106 -0
- data/lib/octo/tools/terminal/session_manager.rb +213 -0
- data/lib/octo/tools/terminal.rb +1828 -0
- data/lib/octo/tools/todo_manager.rb +374 -0
- data/lib/octo/tools/trash_manager.rb +388 -0
- data/lib/octo/tools/undo_task.rb +35 -0
- data/lib/octo/tools/web_fetch.rb +242 -0
- data/lib/octo/tools/web_search.rb +260 -0
- data/lib/octo/tools/write.rb +77 -0
- data/lib/octo/ui2/block_font.rb +10 -0
- data/lib/octo/ui2/components/base_component.rb +163 -0
- data/lib/octo/ui2/components/command_suggestions.rb +290 -0
- data/lib/octo/ui2/components/common_component.rb +96 -0
- data/lib/octo/ui2/components/inline_input.rb +226 -0
- data/lib/octo/ui2/components/input_area.rb +1338 -0
- data/lib/octo/ui2/components/message_component.rb +99 -0
- data/lib/octo/ui2/components/modal_component.rb +419 -0
- data/lib/octo/ui2/components/todo_area.rb +149 -0
- data/lib/octo/ui2/components/tool_component.rb +107 -0
- data/lib/octo/ui2/components/welcome_banner.rb +139 -0
- data/lib/octo/ui2/layout_manager.rb +807 -0
- data/lib/octo/ui2/line_editor.rb +363 -0
- data/lib/octo/ui2/markdown_renderer.rb +100 -0
- data/lib/octo/ui2/output_buffer.rb +370 -0
- data/lib/octo/ui2/progress_handle.rb +362 -0
- data/lib/octo/ui2/progress_indicator.rb +55 -0
- data/lib/octo/ui2/screen_buffer.rb +273 -0
- data/lib/octo/ui2/terminal_detector.rb +119 -0
- data/lib/octo/ui2/theme_manager.rb +85 -0
- data/lib/octo/ui2/themes/base_theme.rb +105 -0
- data/lib/octo/ui2/themes/hacker_theme.rb +62 -0
- data/lib/octo/ui2/themes/minimal_theme.rb +56 -0
- data/lib/octo/ui2/thinking_verbs.rb +26 -0
- data/lib/octo/ui2/ui_controller.rb +1625 -0
- data/lib/octo/ui2/view_renderer.rb +177 -0
- data/lib/octo/ui2.rb +40 -0
- data/lib/octo/ui_interface.rb +154 -0
- data/lib/octo/utils/arguments_parser.rb +191 -0
- data/lib/octo/utils/browser_detector.rb +195 -0
- data/lib/octo/utils/encoding.rb +92 -0
- data/lib/octo/utils/environment_detector.rb +140 -0
- data/lib/octo/utils/file_ignore_helper.rb +170 -0
- data/lib/octo/utils/file_processor.rb +601 -0
- data/lib/octo/utils/gitignore_parser.rb +154 -0
- data/lib/octo/utils/limit_stack.rb +152 -0
- data/lib/octo/utils/logger.rb +124 -0
- data/lib/octo/utils/login_shell.rb +72 -0
- data/lib/octo/utils/model_pricing.rb +646 -0
- data/lib/octo/utils/parser_manager.rb +165 -0
- data/lib/octo/utils/path_helper.rb +15 -0
- data/lib/octo/utils/scripts_manager.rb +59 -0
- data/lib/octo/utils/string_matcher.rb +158 -0
- data/lib/octo/utils/trash_directory.rb +112 -0
- data/lib/octo/utils/workspace_rules.rb +46 -0
- data/lib/octo/version.rb +5 -0
- data/lib/octo/web/app.css +7141 -0
- data/lib/octo/web/app.js +543 -0
- data/lib/octo/web/apple-touch-icon.png +0 -0
- data/lib/octo/web/auth.js +150 -0
- data/lib/octo/web/channels.js +276 -0
- data/lib/octo/web/datepicker.js +205 -0
- data/lib/octo/web/favicon.png +0 -0
- data/lib/octo/web/i18n.js +1073 -0
- data/lib/octo/web/icon-512.png +0 -0
- data/lib/octo/web/icon-dark.svg +25 -0
- data/lib/octo/web/icon.svg +29 -0
- data/lib/octo/web/index.html +871 -0
- data/lib/octo/web/marked.min.js +69 -0
- data/lib/octo/web/onboard.js +491 -0
- data/lib/octo/web/profile.js +442 -0
- data/lib/octo/web/sessions.js +4421 -0
- data/lib/octo/web/settings.js +913 -0
- data/lib/octo/web/sidebar.js +32 -0
- data/lib/octo/web/skills.js +885 -0
- data/lib/octo/web/tasks.js +297 -0
- data/lib/octo/web/theme.js +105 -0
- data/lib/octo/web/trash.js +343 -0
- data/lib/octo/web/vendor/hljs/highlight.min.js +1244 -0
- data/lib/octo/web/vendor/hljs/hljs-theme.css +95 -0
- data/lib/octo/web/vendor/katex/auto-render.min.js +1 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/katex.min.css +1 -0
- data/lib/octo/web/vendor/katex/katex.min.js +1 -0
- data/lib/octo/web/version.js +449 -0
- data/lib/octo/web/weixin-qr.html +209 -0
- data/lib/octo/web/ws-dispatcher.js +357 -0
- data/lib/octo/web/ws.js +128 -0
- data/lib/octo.rb +145 -0
- data/scripts/build/build.sh +329 -0
- data/scripts/build/lib/apt.sh +56 -0
- data/scripts/build/lib/brew.sh +89 -0
- data/scripts/build/lib/colors.sh +17 -0
- data/scripts/build/lib/gem.sh +95 -0
- data/scripts/build/lib/mise.sh +125 -0
- data/scripts/build/lib/network.sh +157 -0
- data/scripts/build/lib/os.sh +57 -0
- data/scripts/build/lib/shell.sh +37 -0
- data/scripts/build/src/install.sh.cc +174 -0
- data/scripts/build/src/install_browser.sh.cc +101 -0
- data/scripts/build/src/install_full.sh.cc +290 -0
- data/scripts/build/src/install_rails_deps.sh.cc +145 -0
- data/scripts/build/src/install_system_deps.sh.cc +123 -0
- data/scripts/build/src/uninstall.sh.cc +101 -0
- data/scripts/install.ps1 +532 -0
- data/scripts/install.sh +567 -0
- data/scripts/install_browser.sh +479 -0
- data/scripts/install_full.sh +838 -0
- data/scripts/install_rails_deps.sh +746 -0
- data/scripts/install_system_deps.sh +518 -0
- data/scripts/uninstall.sh +287 -0
- data/sig/octo.rbs +4 -0
- metadata +614 -0
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Octo
|
|
4
|
+
module Server
|
|
5
|
+
# SessionRegistry is the single authoritative source for session state.
|
|
6
|
+
#
|
|
7
|
+
# It owns two concerns:
|
|
8
|
+
# 1. Runtime state — agent instance, thread, status, pending_task, idle_timer.
|
|
9
|
+
# 2. Session list — reads from disk (via session_manager) and enriches with
|
|
10
|
+
# live runtime status. `list` is the only place the session
|
|
11
|
+
# list is assembled; no callers should build it elsewhere.
|
|
12
|
+
#
|
|
13
|
+
# Lazy restore: `ensure(session_id)` loads a disk session into the registry on
|
|
14
|
+
# demand. All session-specific APIs call this before touching the registry so
|
|
15
|
+
# disk-only sessions (e.g. loaded via loadMore) just work transparently.
|
|
16
|
+
#
|
|
17
|
+
# Thread safety: all public methods are protected by a Mutex.
|
|
18
|
+
class SessionRegistry
|
|
19
|
+
SESSION_TIMEOUT = 24 * 60 * 60 # 24 hours of inactivity before cleanup
|
|
20
|
+
|
|
21
|
+
def initialize(session_manager: nil, session_restorer: nil, agent_config:)
|
|
22
|
+
@sessions = {}
|
|
23
|
+
@mutex = Mutex.new
|
|
24
|
+
@session_manager = session_manager
|
|
25
|
+
@session_restorer = session_restorer
|
|
26
|
+
@agent_config = agent_config
|
|
27
|
+
# Tracks sessions currently being restored from disk.
|
|
28
|
+
# Other threads calling ensure() for the same id will wait via @restore_cond
|
|
29
|
+
# instead of seeing a half-built session (agent=nil).
|
|
30
|
+
@restoring = {}
|
|
31
|
+
@restore_cond = ConditionVariable.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Create a new (empty) session entry and return its id.
|
|
35
|
+
# agent/ui/thread are set later via with_session once they are constructed.
|
|
36
|
+
def create(session_id:)
|
|
37
|
+
raise ArgumentError, "session_id is required" if session_id.nil? || session_id.empty?
|
|
38
|
+
|
|
39
|
+
session = {
|
|
40
|
+
id: session_id,
|
|
41
|
+
status: :idle,
|
|
42
|
+
error: nil,
|
|
43
|
+
updated_at: Time.now,
|
|
44
|
+
agent: nil,
|
|
45
|
+
ui: nil,
|
|
46
|
+
thread: nil,
|
|
47
|
+
idle_timer: nil,
|
|
48
|
+
pending_task: nil,
|
|
49
|
+
pending_working_dir: nil
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@mutex.synchronize { @sessions[session_id] = session }
|
|
53
|
+
session_id
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Ensure a session is in the registry, loading from disk if necessary.
|
|
57
|
+
# Returns true if the session is now available, false if not found anywhere.
|
|
58
|
+
#
|
|
59
|
+
# Thread-safe: if two threads race on the same session_id, the second one
|
|
60
|
+
# waits for the first to finish restoring (including agent construction) rather
|
|
61
|
+
# than seeing a half-built entry with agent=nil.
|
|
62
|
+
def ensure(session_id)
|
|
63
|
+
session_data = nil
|
|
64
|
+
|
|
65
|
+
@mutex.synchronize do
|
|
66
|
+
# Another thread is currently restoring this session (including the case where
|
|
67
|
+
# @registry.create was already called but with_session agent-set is not yet done) —
|
|
68
|
+
# wait for it to finish so callers never see agent=nil.
|
|
69
|
+
if @restoring[session_id]
|
|
70
|
+
@restore_cond.wait(@mutex) until !@restoring[session_id]
|
|
71
|
+
return @sessions.key?(session_id)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Already fully ready (not being restored) — fast path.
|
|
75
|
+
return true if @sessions.key?(session_id)
|
|
76
|
+
|
|
77
|
+
return false unless @session_manager && @session_restorer
|
|
78
|
+
|
|
79
|
+
session_data = @session_manager.load(session_id)
|
|
80
|
+
return false unless session_data
|
|
81
|
+
|
|
82
|
+
# Mark as "restore in progress" so concurrent callers wait.
|
|
83
|
+
@restoring[session_id] = true
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Run the (potentially slow) restore outside the mutex so other sessions
|
|
87
|
+
# are not blocked during agent construction.
|
|
88
|
+
begin
|
|
89
|
+
@session_restorer.call(session_data)
|
|
90
|
+
ensure
|
|
91
|
+
@mutex.synchronize do
|
|
92
|
+
@restoring.delete(session_id)
|
|
93
|
+
@restore_cond.broadcast
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
@sessions.key?(session_id)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Restore all sessions from disk (up to n per source type) into the registry.
|
|
101
|
+
# Used at startup. Already-registered sessions are skipped.
|
|
102
|
+
def restore_from_disk(n: 5)
|
|
103
|
+
return unless @session_manager && @session_restorer
|
|
104
|
+
|
|
105
|
+
all = @session_manager.all_sessions
|
|
106
|
+
.sort_by { |s| s[:created_at] || "" }
|
|
107
|
+
.reverse
|
|
108
|
+
|
|
109
|
+
# Take up to n per source type
|
|
110
|
+
counts = Hash.new(0)
|
|
111
|
+
all.each do |session_data|
|
|
112
|
+
src = (session_data[:source] || "manual").to_s
|
|
113
|
+
next if counts[src] >= n
|
|
114
|
+
next if exist?(session_data[:session_id])
|
|
115
|
+
@session_restorer.call(session_data)
|
|
116
|
+
counts[src] += 1
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Retrieve a session hash by id (returns nil if not found).
|
|
121
|
+
def get(session_id)
|
|
122
|
+
@mutex.synchronize { @sessions[session_id]&.dup }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Update arbitrary runtime fields of a session (status, error, pending_*, etc.).
|
|
126
|
+
def update(session_id, **fields)
|
|
127
|
+
@mutex.synchronize do
|
|
128
|
+
session = @sessions[session_id]
|
|
129
|
+
return false unless session
|
|
130
|
+
|
|
131
|
+
fields[:updated_at] = Time.now
|
|
132
|
+
session.merge!(fields)
|
|
133
|
+
true
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Return a session list from disk enriched with live registry status.
|
|
138
|
+
# Sorted by created_at descending (newest first).
|
|
139
|
+
#
|
|
140
|
+
# Parameters (all optional, independent):
|
|
141
|
+
# source: "manual"|"cron"|"channel"|"setup"|nil
|
|
142
|
+
# nil = no source filter (all sessions)
|
|
143
|
+
# profile: "general"|"coding"|nil
|
|
144
|
+
# nil = no agent_profile filter
|
|
145
|
+
# limit: max sessions to return (applies to NON-PINNED only; see below)
|
|
146
|
+
# before: ISO8601 cursor — only sessions with created_at < before
|
|
147
|
+
# (also applies to NON-PINNED only; pinned items are a separate
|
|
148
|
+
# logical section, they should never be paginated away)
|
|
149
|
+
# include_pinned: when true (default), all matching pinned sessions are
|
|
150
|
+
# always returned on the FIRST page (before == nil) regardless
|
|
151
|
+
# of limit. Subsequent pages (before set) contain only
|
|
152
|
+
# non-pinned sessions. This guarantees that users who pinned
|
|
153
|
+
# an old session always see it at the top of the sidebar,
|
|
154
|
+
# even if many newer sessions exist.
|
|
155
|
+
#
|
|
156
|
+
# Ordering of the returned array:
|
|
157
|
+
# [ ...all_pinned_matching (newest-first), ...non_pinned (newest-first, limited) ]
|
|
158
|
+
#
|
|
159
|
+
# source and profile are orthogonal — either can be nil independently.
|
|
160
|
+
def list(limit: nil, before: nil, q: nil, date: nil, type: nil, include_pinned: true)
|
|
161
|
+
return [] unless @session_manager
|
|
162
|
+
|
|
163
|
+
live = @mutex.synchronize do
|
|
164
|
+
@sessions.transform_values do |s|
|
|
165
|
+
model_info = s[:agent]&.current_model_info
|
|
166
|
+
live_name = s[:agent]&.name
|
|
167
|
+
live_name = nil if live_name&.empty?
|
|
168
|
+
{ status: s[:status], error: s[:error], model: model_info&.dig(:model), model_id: model_info&.dig(:id), name: live_name,
|
|
169
|
+
total_tasks: s[:agent]&.total_tasks,
|
|
170
|
+
reasoning_effort: s[:agent]&.reasoning_effort,
|
|
171
|
+
latest_latency: s[:agent]&.latest_latency }
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
all = @session_manager.all_sessions # already sorted newest-first
|
|
176
|
+
|
|
177
|
+
# ── type filter (replaces old source/profile split) ──────────────────
|
|
178
|
+
# type=coding → agent_profile == "coding"
|
|
179
|
+
# type=manual/cron/channel/setup → source match (profile=general implied)
|
|
180
|
+
if type
|
|
181
|
+
if type == "coding"
|
|
182
|
+
all = all.select { |s| (s[:agent_profile] || "general").to_s == "coding" }
|
|
183
|
+
else
|
|
184
|
+
all = all.select { |s| s_source(s) == type && (s[:agent_profile] || "general").to_s != "coding" }
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# ── date filter (YYYY-MM-DD, matches created_at prefix) ──────────────
|
|
189
|
+
all = all.select { |s| s[:created_at].to_s.start_with?(date) } if date
|
|
190
|
+
|
|
191
|
+
# ── name / id search ─────────────────────────────────────────────────
|
|
192
|
+
if q && !q.empty?
|
|
193
|
+
q_down = q.downcase
|
|
194
|
+
all = all.select { |s|
|
|
195
|
+
(s[:name] || "").downcase.include?(q_down) ||
|
|
196
|
+
(s[:session_id] || "").downcase.include?(q_down)
|
|
197
|
+
}
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# ── Split pinned vs non-pinned BEFORE applying `before`/`limit`.
|
|
201
|
+
# Pinned sessions bypass pagination entirely so an old pinned session
|
|
202
|
+
# never falls off the first page just because newer sessions exist.
|
|
203
|
+
# (Regression fix for 0.9.37: previously `all_sessions` was only
|
|
204
|
+
# sorted by created_at and `limit` cut off old pinned rows, making
|
|
205
|
+
# them invisible until the user clicked "load more".)
|
|
206
|
+
pinned, non_pinned = all.partition { |s| s[:pinned] }
|
|
207
|
+
|
|
208
|
+
# `before` cursor ONLY applies to non-pinned (paginated) sessions.
|
|
209
|
+
non_pinned = non_pinned.select { |s| (s[:created_at] || "") < before } if before
|
|
210
|
+
non_pinned = non_pinned.first(limit) if limit
|
|
211
|
+
|
|
212
|
+
# Pinned section: only included on the first page (before == nil) so
|
|
213
|
+
# "load more" responses don't re-send them. On first page, return ALL
|
|
214
|
+
# matching pinned sessions regardless of limit.
|
|
215
|
+
pinned_section = (include_pinned && before.nil?) ? pinned : []
|
|
216
|
+
|
|
217
|
+
ordered = pinned_section + non_pinned
|
|
218
|
+
|
|
219
|
+
ordered.map { |s| build_enriched_row(s, live[s[:session_id]]) }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Return the same enriched hash that a `list` row would produce, for a
|
|
223
|
+
# single session — merging on-disk fields with in-memory live fields.
|
|
224
|
+
# Returns nil if the session is unknown on disk.
|
|
225
|
+
#
|
|
226
|
+
# This is the targeted, O(1) counterpart to `list` used by the WS layer
|
|
227
|
+
# when it only needs one row (e.g. pushing a fresh snapshot to a client
|
|
228
|
+
# that just (re)subscribed, or broadcasting a status-change update).
|
|
229
|
+
def snapshot(session_id)
|
|
230
|
+
return nil unless @session_manager
|
|
231
|
+
disk = @session_manager.load(session_id)
|
|
232
|
+
return nil unless disk
|
|
233
|
+
|
|
234
|
+
live = @mutex.synchronize do
|
|
235
|
+
s = @sessions[session_id]
|
|
236
|
+
next nil unless s
|
|
237
|
+
model_info = s[:agent]&.current_model_info
|
|
238
|
+
live_name = s[:agent]&.name
|
|
239
|
+
live_name = nil if live_name&.empty?
|
|
240
|
+
{ status: s[:status], error: s[:error], model: model_info&.dig(:model), model_id: model_info&.dig(:id),
|
|
241
|
+
name: live_name, total_tasks: s[:agent]&.total_tasks,
|
|
242
|
+
reasoning_effort: s[:agent]&.reasoning_effort,
|
|
243
|
+
latest_latency: s[:agent]&.latest_latency }
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
build_enriched_row(disk, live)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Merge a single disk-side session hash with the corresponding live
|
|
250
|
+
# in-memory agent fields (may be nil) into the row shape the frontend
|
|
251
|
+
# consumes.
|
|
252
|
+
private def build_enriched_row(s, ls)
|
|
253
|
+
id = s[:session_id]
|
|
254
|
+
{
|
|
255
|
+
id: id,
|
|
256
|
+
name: ls&.dig(:name) || s[:name] || "",
|
|
257
|
+
status: ls ? ls[:status].to_s : "idle",
|
|
258
|
+
error: ls ? ls[:error] : nil,
|
|
259
|
+
model: ls&.dig(:model),
|
|
260
|
+
model_id: ls&.dig(:model_id),
|
|
261
|
+
source: s_source(s),
|
|
262
|
+
agent_profile: (s[:agent_profile] || "general").to_s,
|
|
263
|
+
working_dir: s[:working_dir],
|
|
264
|
+
created_at: s[:created_at],
|
|
265
|
+
updated_at: s[:updated_at],
|
|
266
|
+
total_tasks: ls&.dig(:total_tasks) || s.dig(:stats, :total_tasks) || 0,
|
|
267
|
+
# latest_latency is in-memory only (live sessions) — not persisted
|
|
268
|
+
# at the session-level on disk. The on-disk source of truth is
|
|
269
|
+
# per-assistant-message `latency` fields in messages[]. Reloaded
|
|
270
|
+
# sessions start with nil and get populated on the next LLM call.
|
|
271
|
+
latest_latency: ls&.dig(:latest_latency),
|
|
272
|
+
reasoning_effort: ls&.dig(:reasoning_effort) || s.dig(:config, :reasoning_effort),
|
|
273
|
+
pinned: s[:pinned] || false,
|
|
274
|
+
}
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# Normalize source field from a disk session hash.
|
|
279
|
+
# "system" is a legacy value renamed to "setup" — treat them as equivalent.
|
|
280
|
+
def s_source(s)
|
|
281
|
+
src = (s[:source] || "manual").to_s
|
|
282
|
+
src == "system" ? "setup" : src
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
public
|
|
286
|
+
|
|
287
|
+
# Count all cron sessions on disk (not filtered by pagination).
|
|
288
|
+
def cron_count
|
|
289
|
+
return 0 unless @session_manager
|
|
290
|
+
@session_manager.all_sessions.count { |s| s_source(s) == "cron" }
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Delete a session from registry (and interrupt its thread).
|
|
294
|
+
def delete(session_id)
|
|
295
|
+
@mutex.synchronize do
|
|
296
|
+
session = @sessions.delete(session_id)
|
|
297
|
+
return false unless session
|
|
298
|
+
|
|
299
|
+
session[:idle_timer]&.cancel
|
|
300
|
+
session[:thread]&.raise(Octo::AgentInterrupted, "Session deleted")
|
|
301
|
+
true
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# True if the session exists in registry (runtime).
|
|
306
|
+
def exist?(session_id)
|
|
307
|
+
@mutex.synchronize { @sessions.key?(session_id) }
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Execute a block with exclusive access to the raw session hash.
|
|
311
|
+
def with_session(session_id)
|
|
312
|
+
@mutex.synchronize do
|
|
313
|
+
session = @sessions[session_id]
|
|
314
|
+
return nil unless session
|
|
315
|
+
yield session
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Remove sessions idle longer than SESSION_TIMEOUT.
|
|
320
|
+
def cleanup_stale!
|
|
321
|
+
cutoff = Time.now - SESSION_TIMEOUT
|
|
322
|
+
@mutex.synchronize do
|
|
323
|
+
@sessions.delete_if do |_id, session|
|
|
324
|
+
session[:status] == :idle && session[:updated_at] < cutoff
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def count_by_status(status)
|
|
330
|
+
@mutex.synchronize do
|
|
331
|
+
@sessions.count { |_, s| s[:status] == status }
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def max_running_agents
|
|
336
|
+
@agent_config.max_running_agents
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def max_idle_agents
|
|
340
|
+
@agent_config.max_idle_agents
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def running_full?
|
|
344
|
+
count_by_status(:running) >= max_running_agents
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Evict oldest idle agents beyond MAX_IDLE_AGENTS.
|
|
348
|
+
# Persists session data to disk before releasing the agent from memory.
|
|
349
|
+
def evict_excess_idle!
|
|
350
|
+
to_evict = []
|
|
351
|
+
|
|
352
|
+
@mutex.synchronize do
|
|
353
|
+
idle = @sessions.select { |_, s| s[:status] == :idle && s[:agent] }
|
|
354
|
+
.sort_by { |_, s| s[:updated_at] || Time.at(0) }
|
|
355
|
+
|
|
356
|
+
while idle.size > max_idle_agents
|
|
357
|
+
id, session = idle.shift
|
|
358
|
+
to_evict << [id, session]
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
to_evict.each { |id, session| persist_and_release(id, session) }
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Yield [session_id, agent, thread] for each session that currently has
|
|
366
|
+
# an in-memory agent. Used by the worker's graceful-shutdown path to
|
|
367
|
+
# flush any unsaved @history (e.g. a user message added at the start
|
|
368
|
+
# of Agent#run that hasn't yet reached the save-on-completion branch
|
|
369
|
+
# in run_agent_task).
|
|
370
|
+
#
|
|
371
|
+
# The session id list is snapshotted under the mutex so concurrent
|
|
372
|
+
# mutations don't disturb iteration; the yield happens outside the
|
|
373
|
+
# mutex so callers can do slow I/O (JSON serialization, File.write)
|
|
374
|
+
# without blocking other registry operations.
|
|
375
|
+
def each_live_agent
|
|
376
|
+
snapshot = @mutex.synchronize do
|
|
377
|
+
@sessions.filter_map do |id, s|
|
|
378
|
+
agent = s[:agent]
|
|
379
|
+
next nil unless agent
|
|
380
|
+
[id, agent, s[:thread]]
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
snapshot.each { |id, agent, thread| yield id, agent, thread }
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
private def persist_and_release(id, session)
|
|
387
|
+
agent = session[:agent]
|
|
388
|
+
@session_manager&.save(agent.to_session_data(status: :success)) if agent
|
|
389
|
+
|
|
390
|
+
@mutex.synchronize do
|
|
391
|
+
s = @sessions[id]
|
|
392
|
+
next unless s
|
|
393
|
+
s[:idle_timer]&.cancel
|
|
394
|
+
s[:agent] = nil
|
|
395
|
+
s[:ui] = nil
|
|
396
|
+
s[:idle_timer] = nil
|
|
397
|
+
s[:thread] = nil
|
|
398
|
+
@sessions.delete(id)
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Build a summary hash for API responses (for in-registry sessions).
|
|
403
|
+
# Used when we need live agent fields (name, cost, etc.) after ensure().
|
|
404
|
+
def session_summary(session_id)
|
|
405
|
+
session = @mutex.synchronize { @sessions[session_id] }
|
|
406
|
+
return nil unless session
|
|
407
|
+
agent = session[:agent]
|
|
408
|
+
return nil unless agent
|
|
409
|
+
|
|
410
|
+
model_info = agent.current_model_info
|
|
411
|
+
|
|
412
|
+
{
|
|
413
|
+
id: session[:id],
|
|
414
|
+
name: agent.name,
|
|
415
|
+
working_dir: agent.working_dir,
|
|
416
|
+
status: session[:status],
|
|
417
|
+
created_at: agent.created_at,
|
|
418
|
+
updated_at: session[:updated_at].iso8601,
|
|
419
|
+
total_tasks: agent.total_tasks || 0,
|
|
420
|
+
error: session[:error],
|
|
421
|
+
model: model_info&.dig(:model),
|
|
422
|
+
permission_mode: agent.permission_mode,
|
|
423
|
+
source: agent.source.to_s,
|
|
424
|
+
agent_profile: agent.agent_profile.name,
|
|
425
|
+
pinned: agent.pinned || false,
|
|
426
|
+
latest_latency: agent.latest_latency,
|
|
427
|
+
}
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
end
|