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,317 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Octo
|
|
7
|
+
module Server
|
|
8
|
+
# Scheduler reads ~/.octo/schedules.yml and runs tasks on a cron-like schedule.
|
|
9
|
+
#
|
|
10
|
+
# It starts a background thread that ticks every 60 seconds, checks all
|
|
11
|
+
# configured schedules, and fires any task whose cron expression matches
|
|
12
|
+
# the current time.
|
|
13
|
+
#
|
|
14
|
+
# Schedule file format (~/.octo/schedules.yml):
|
|
15
|
+
#
|
|
16
|
+
# - name: daily_report
|
|
17
|
+
# task: daily_report # references ~/.octo/tasks/daily_report.md
|
|
18
|
+
# cron: "0 9 * * 1-5" # standard 5-field cron expression
|
|
19
|
+
# enabled: true # optional, defaults to true
|
|
20
|
+
#
|
|
21
|
+
# Cron field order: minute hour day-of-month month day-of-week
|
|
22
|
+
class Scheduler
|
|
23
|
+
SCHEDULES_FILE = File.expand_path("~/.octo/schedules.yml")
|
|
24
|
+
TASKS_DIR = File.expand_path("~/.octo/tasks")
|
|
25
|
+
|
|
26
|
+
def initialize(session_registry:, session_builder:, task_runner:)
|
|
27
|
+
@registry = session_registry
|
|
28
|
+
@session_builder = session_builder # callable: (name:, working_dir:) -> session_id
|
|
29
|
+
# Callable that runs a task on an agent with unified status/save/broadcast
|
|
30
|
+
# handling — signature: (session_id, agent, &block). Same contract as
|
|
31
|
+
# the one ChannelManager receives.
|
|
32
|
+
@task_runner = task_runner
|
|
33
|
+
@thread = nil
|
|
34
|
+
@running = false
|
|
35
|
+
@mutex = Mutex.new
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Start the background scheduler thread.
|
|
39
|
+
def start
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
return if @running
|
|
42
|
+
|
|
43
|
+
@running = true
|
|
44
|
+
@thread = Thread.new { run_loop }
|
|
45
|
+
@thread.name = "octo-scheduler"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Stop the background scheduler thread gracefully.
|
|
50
|
+
# NOTE: intentionally avoids Mutex here so it is safe to call from a
|
|
51
|
+
# signal trap context (Ruby disallows Mutex#synchronize inside traps).
|
|
52
|
+
def stop
|
|
53
|
+
@running = false
|
|
54
|
+
@thread&.wakeup rescue nil
|
|
55
|
+
@thread&.join(5)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def running?
|
|
59
|
+
@running
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Return all schedules from the config file.
|
|
63
|
+
def schedules
|
|
64
|
+
load_schedules
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# ── Schedule CRUD ────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
# Add or update a schedule entry in schedules.yml.
|
|
70
|
+
def add_schedule(name:, task:, cron:, enabled: true)
|
|
71
|
+
list = load_schedules
|
|
72
|
+
# Remove existing entry with the same name
|
|
73
|
+
list.reject! { |s| s["name"] == name }
|
|
74
|
+
list << {
|
|
75
|
+
"name" => name,
|
|
76
|
+
"task" => task,
|
|
77
|
+
"cron" => cron,
|
|
78
|
+
"enabled" => enabled
|
|
79
|
+
}
|
|
80
|
+
save_schedules(list)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Remove a schedule entry by name.
|
|
84
|
+
def remove_schedule(name)
|
|
85
|
+
list = load_schedules
|
|
86
|
+
before_count = list.size
|
|
87
|
+
list.reject! { |s| s["name"] == name }
|
|
88
|
+
save_schedules(list)
|
|
89
|
+
list.size < before_count
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Update an existing schedule entry (cron and/or enabled).
|
|
93
|
+
# Returns false if the schedule does not exist.
|
|
94
|
+
def update_schedule(name, cron: nil, enabled: nil)
|
|
95
|
+
list = load_schedules
|
|
96
|
+
entry = list.find { |s| s["name"] == name }
|
|
97
|
+
return false unless entry
|
|
98
|
+
|
|
99
|
+
entry["cron"] = cron unless cron.nil?
|
|
100
|
+
entry["enabled"] = enabled unless enabled.nil?
|
|
101
|
+
save_schedules(list)
|
|
102
|
+
true
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# ── Composite cron-task helpers ──────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
# Create a task file and its schedule in one step.
|
|
108
|
+
def create_cron_task(name:, content:, cron:, enabled: true)
|
|
109
|
+
write_task(name, content)
|
|
110
|
+
add_schedule(name: name, task: name, cron: cron, enabled: enabled)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Update a cron-task: optionally update content and/or schedule fields.
|
|
114
|
+
def update_cron_task(name, content: nil, cron: nil, enabled: nil)
|
|
115
|
+
raise "Cron task not found: #{name}" unless list_tasks.include?(name)
|
|
116
|
+
|
|
117
|
+
write_task(name, content) unless content.nil?
|
|
118
|
+
update_schedule(name, cron: cron, enabled: enabled) if cron || !enabled.nil?
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Delete a cron-task: remove both the task file and its schedule.
|
|
122
|
+
def delete_cron_task(name)
|
|
123
|
+
removed_schedule = remove_schedule(name)
|
|
124
|
+
removed_task = delete_task(name)
|
|
125
|
+
removed_schedule || removed_task
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Return a merged list of cron-tasks (task content + schedule metadata).
|
|
129
|
+
def list_cron_tasks
|
|
130
|
+
schedule_map = load_schedules.each_with_object({}) do |s, h|
|
|
131
|
+
h[s["task"]] = s if s.is_a?(Hash)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
list_tasks.map do |task_name|
|
|
135
|
+
content = begin; read_task(task_name); rescue StandardError; ""; end
|
|
136
|
+
schedule = schedule_map[task_name] || {}
|
|
137
|
+
{
|
|
138
|
+
"name" => task_name,
|
|
139
|
+
"content" => content,
|
|
140
|
+
"cron" => schedule["cron"],
|
|
141
|
+
"enabled" => schedule.fetch("enabled", true),
|
|
142
|
+
"scheduled" => !schedule.empty?
|
|
143
|
+
}
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# ── Task file helpers ────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
# Read the prompt content of a named task.
|
|
150
|
+
def read_task(task_name)
|
|
151
|
+
path = task_file_path(task_name)
|
|
152
|
+
raise "Task not found: #{task_name} (expected #{path})" unless File.exist?(path)
|
|
153
|
+
|
|
154
|
+
File.read(path)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Write the prompt content for a named task.
|
|
158
|
+
def write_task(task_name, content)
|
|
159
|
+
FileUtils.mkdir_p(TASKS_DIR)
|
|
160
|
+
File.write(task_file_path(task_name), content)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# List all existing task names.
|
|
164
|
+
def list_tasks
|
|
165
|
+
return [] unless Dir.exist?(TASKS_DIR)
|
|
166
|
+
|
|
167
|
+
Dir.glob(File.join(TASKS_DIR, "*.md")).map do |path|
|
|
168
|
+
File.basename(path, ".md")
|
|
169
|
+
end.sort
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Delete a task file and remove all schedules that reference it.
|
|
173
|
+
# Returns true if the task file existed and was deleted, false otherwise.
|
|
174
|
+
def delete_task(task_name)
|
|
175
|
+
path = task_file_path(task_name)
|
|
176
|
+
return false unless File.exist?(path)
|
|
177
|
+
|
|
178
|
+
File.delete(path)
|
|
179
|
+
# Remove all schedules referencing this task
|
|
180
|
+
load_schedules.select { |s| s["task"] == task_name }.each do |s|
|
|
181
|
+
remove_schedule(s["name"])
|
|
182
|
+
end
|
|
183
|
+
true
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Return the file path for a task.
|
|
187
|
+
def task_file_path(task_name)
|
|
188
|
+
File.join(TASKS_DIR, "#{task_name}.md")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# ── Internal ─────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
private def run_loop
|
|
194
|
+
loop do
|
|
195
|
+
break unless @running
|
|
196
|
+
|
|
197
|
+
tick(Time.now)
|
|
198
|
+
|
|
199
|
+
# Sleep until the start of the next minute
|
|
200
|
+
now = Time.now
|
|
201
|
+
sleep_s = 60 - now.sec
|
|
202
|
+
sleep(sleep_s)
|
|
203
|
+
end
|
|
204
|
+
rescue => e
|
|
205
|
+
Octo::Logger.error("scheduler_fatal_error", error: e)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Check all enabled schedules against the given time and fire matching ones.
|
|
209
|
+
private def tick(now)
|
|
210
|
+
load_schedules.each do |schedule|
|
|
211
|
+
next unless schedule["enabled"] != false
|
|
212
|
+
next unless cron_matches?(schedule["cron"].to_s, now)
|
|
213
|
+
|
|
214
|
+
fire_task(schedule)
|
|
215
|
+
rescue => e
|
|
216
|
+
Octo::Logger.error("scheduler_tick_error", schedule: schedule["name"], error: e)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Execute a scheduled task by creating a new agent session.
|
|
221
|
+
private def fire_task(schedule)
|
|
222
|
+
task_name = schedule["task"].to_s
|
|
223
|
+
prompt = read_task(task_name)
|
|
224
|
+
name = "⏰ #{schedule["name"]} #{Time.now.strftime("%H:%M")}"
|
|
225
|
+
|
|
226
|
+
working_dir = File.expand_path("~/octo_workspace")
|
|
227
|
+
FileUtils.mkdir_p(working_dir)
|
|
228
|
+
|
|
229
|
+
# Scheduled tasks run unattended — use auto_approve so request_user_feedback doesn't block.
|
|
230
|
+
session_id = @session_builder.call(name: name, working_dir: working_dir, permission_mode: :auto_approve, source: :cron)
|
|
231
|
+
|
|
232
|
+
Octo::Logger.info("scheduler_task_fired", task: task_name, session: session_id)
|
|
233
|
+
|
|
234
|
+
agent = nil
|
|
235
|
+
@registry.with_session(session_id) { |s| agent = s[:agent] }
|
|
236
|
+
return unless agent
|
|
237
|
+
|
|
238
|
+
# Delegate to the unified task runner (same code path as manual runs and
|
|
239
|
+
# channel-triggered runs). It handles:
|
|
240
|
+
# * status transitions (:running → :idle/:error)
|
|
241
|
+
# * broadcasting session_update
|
|
242
|
+
# * persisting the session JSON on success/interrupted/error ← the bit we were missing
|
|
243
|
+
# * idle-compression timer lifecycle
|
|
244
|
+
@task_runner.call(session_id, agent) { agent.run(prompt) }
|
|
245
|
+
|
|
246
|
+
Octo::Logger.info("scheduler_task_dispatched", task: task_name, session: session_id)
|
|
247
|
+
rescue => e
|
|
248
|
+
Octo::Logger.error("scheduler_fire_error", task: schedule["task"], error: e)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# ── Cron parsing ─────────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
# Returns true if the 5-field cron expression matches the given Time.
|
|
254
|
+
# Fields: minute hour day-of-month month day-of-week
|
|
255
|
+
private def cron_matches?(expr, time)
|
|
256
|
+
fields = expr.strip.split(/\s+/)
|
|
257
|
+
return false unless fields.size == 5
|
|
258
|
+
|
|
259
|
+
minute, hour, dom, month, dow = fields
|
|
260
|
+
|
|
261
|
+
cron_field_matches?(minute, time.min) &&
|
|
262
|
+
cron_field_matches?(hour, time.hour) &&
|
|
263
|
+
cron_field_matches?(dom, time.day) &&
|
|
264
|
+
cron_field_matches?(month, time.month) &&
|
|
265
|
+
cron_field_matches?(dow, time.wday)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Match a single cron field value against the actual time value.
|
|
269
|
+
# Supports: * (any), */n (step), n-m (range), n-m/s (range with step),
|
|
270
|
+
# and comma-separated lists of the above.
|
|
271
|
+
private def cron_field_matches?(field, value)
|
|
272
|
+
field.split(",").any? { |part| cron_part_matches?(part.strip, value) }
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
private def cron_part_matches?(part, value)
|
|
276
|
+
if part == "*"
|
|
277
|
+
true
|
|
278
|
+
elsif part.include?("/")
|
|
279
|
+
base, step = part.split("/")
|
|
280
|
+
step = step.to_i
|
|
281
|
+
return false if step.zero?
|
|
282
|
+
|
|
283
|
+
if base == "*"
|
|
284
|
+
(value % step).zero?
|
|
285
|
+
else
|
|
286
|
+
min, max = base.split("-").map(&:to_i)
|
|
287
|
+
max ||= value
|
|
288
|
+
value.between?(min, max) && ((value - min) % step).zero?
|
|
289
|
+
end
|
|
290
|
+
elsif part.include?("-")
|
|
291
|
+
min, max = part.split("-").map(&:to_i)
|
|
292
|
+
value.between?(min, max)
|
|
293
|
+
else
|
|
294
|
+
part.to_i == value
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# ── File I/O ─────────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
private def load_schedules
|
|
301
|
+
return [] unless File.exist?(SCHEDULES_FILE)
|
|
302
|
+
|
|
303
|
+
data = YAMLCompat.load_file(SCHEDULES_FILE, permitted_classes: [Symbol])
|
|
304
|
+
raw = data.is_a?(Hash) ? data["schedules"] : data
|
|
305
|
+
Array(raw).select { |s| s.is_a?(Hash) }
|
|
306
|
+
rescue => e
|
|
307
|
+
Octo::Logger.error("scheduler_load_schedules_error", error: e)
|
|
308
|
+
[]
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
private def save_schedules(list)
|
|
312
|
+
FileUtils.mkdir_p(File.dirname(SCHEDULES_FILE))
|
|
313
|
+
File.write(SCHEDULES_FILE, YAML.dump(list))
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require_relative "../banner"
|
|
6
|
+
require_relative "../version"
|
|
7
|
+
|
|
8
|
+
module Octo
|
|
9
|
+
module Server
|
|
10
|
+
# Master process — owns the listen socket, spawns/monitors worker processes.
|
|
11
|
+
#
|
|
12
|
+
# Lifecycle:
|
|
13
|
+
# octo server
|
|
14
|
+
# └─ Master.run (this file)
|
|
15
|
+
# ├─ creates TCPServer, holds it forever
|
|
16
|
+
# ├─ spawns Worker via spawn() — full new Ruby process, loads fresh gem
|
|
17
|
+
# ├─ traps USR1 → hot_restart (spawn new worker, gracefully stop old)
|
|
18
|
+
# └─ traps TERM/INT → shutdown (stop worker, exit cleanly)
|
|
19
|
+
#
|
|
20
|
+
# Worker receives:
|
|
21
|
+
# OCTO_WORKER=1 — "I am a worker, start HttpServer directly"
|
|
22
|
+
# OCTO_INHERIT_FD=<n> — file descriptor number of the inherited TCPServer socket
|
|
23
|
+
# OCTO_MASTER_PID=<n> — master PID so worker can send USR1 back on upgrade
|
|
24
|
+
class Master
|
|
25
|
+
# Worker exits with this code to request a hot restart (e.g. after gem upgrade).
|
|
26
|
+
RESTART_EXIT_CODE = 75
|
|
27
|
+
MAX_CONSECUTIVE_FAILURES = 5
|
|
28
|
+
|
|
29
|
+
def initialize(host:, port:, argv: nil, extra_flags: [])
|
|
30
|
+
@host = host
|
|
31
|
+
@port = port
|
|
32
|
+
@argv = argv # kept for backward compat but no longer used
|
|
33
|
+
@extra_flags = extra_flags
|
|
34
|
+
|
|
35
|
+
@socket = nil
|
|
36
|
+
@worker_pid = nil
|
|
37
|
+
@restart_requested = false
|
|
38
|
+
@shutdown_requested = false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def run
|
|
42
|
+
# 0. Kill any existing master on this port before binding.
|
|
43
|
+
kill_existing_master
|
|
44
|
+
|
|
45
|
+
# 1. Try to bind the socket.
|
|
46
|
+
# If port is 8888 (default), try fallback ports 8889-8893 if occupied.
|
|
47
|
+
# If port is non-default (user-specified), only try that exact port.
|
|
48
|
+
original_port = @port
|
|
49
|
+
max_port = (@port == 8888) ? (@port + 5) : @port
|
|
50
|
+
@socket = bind_with_fallback(@host, @port, max_port: max_port)
|
|
51
|
+
|
|
52
|
+
if @socket.nil?
|
|
53
|
+
if @port == 8888
|
|
54
|
+
Octo::Logger.error("[Master] No available ports in range 8888-8893")
|
|
55
|
+
else
|
|
56
|
+
Octo::Logger.error("[Master] Port #{@port} is in use")
|
|
57
|
+
end
|
|
58
|
+
exit(1)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
@socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
|
|
62
|
+
@port = @socket.local_address.ip_port # Update to actual bound port
|
|
63
|
+
|
|
64
|
+
# 2. Print banner after port is determined
|
|
65
|
+
print_banner(port_changed: @port != original_port, original_port: original_port)
|
|
66
|
+
|
|
67
|
+
write_pid_file
|
|
68
|
+
|
|
69
|
+
# 3. Signal handlers
|
|
70
|
+
Signal.trap("USR1") { @restart_requested = true }
|
|
71
|
+
Signal.trap("TERM") { @shutdown_requested = true }
|
|
72
|
+
Signal.trap("INT") { @shutdown_requested = true }
|
|
73
|
+
Signal.trap("HUP") { @shutdown_requested = true }
|
|
74
|
+
|
|
75
|
+
# 4. Spawn first worker
|
|
76
|
+
@worker_pid = spawn_worker
|
|
77
|
+
@consecutive_failures = 0
|
|
78
|
+
|
|
79
|
+
# 4. Monitor loop
|
|
80
|
+
loop do
|
|
81
|
+
if @shutdown_requested
|
|
82
|
+
shutdown
|
|
83
|
+
break
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
if @restart_requested
|
|
87
|
+
@restart_requested = false
|
|
88
|
+
hot_restart
|
|
89
|
+
@consecutive_failures = 0
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Non-blocking wait: check if worker has exited
|
|
93
|
+
pid, status = Process.waitpid2(@worker_pid, Process::WNOHANG)
|
|
94
|
+
if pid
|
|
95
|
+
exit_code = status.exitstatus
|
|
96
|
+
if exit_code == RESTART_EXIT_CODE
|
|
97
|
+
Octo::Logger.info("[Master] Worker requested restart (exit #{RESTART_EXIT_CODE}).")
|
|
98
|
+
@worker_pid = spawn_worker
|
|
99
|
+
@consecutive_failures = 0
|
|
100
|
+
elsif @shutdown_requested
|
|
101
|
+
break
|
|
102
|
+
else
|
|
103
|
+
@consecutive_failures += 1
|
|
104
|
+
if @consecutive_failures >= MAX_CONSECUTIVE_FAILURES
|
|
105
|
+
Octo::Logger.error("[Master] Worker failed #{MAX_CONSECUTIVE_FAILURES} times in a row, giving up.")
|
|
106
|
+
shutdown
|
|
107
|
+
break
|
|
108
|
+
end
|
|
109
|
+
delay = [0.5 * (2 ** (@consecutive_failures - 1)), 30].min # exponential backoff, max 30s
|
|
110
|
+
Octo::Logger.warn("[Master] Worker exited unexpectedly (exit #{exit_code}), failure #{@consecutive_failures}/#{MAX_CONSECUTIVE_FAILURES}, restarting in #{delay}s...")
|
|
111
|
+
sleep delay
|
|
112
|
+
@worker_pid = spawn_worker
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
sleep 0.1
|
|
117
|
+
end
|
|
118
|
+
ensure
|
|
119
|
+
remove_pid_file
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# Spawn a fresh Ruby process that loads the (possibly updated) gem from disk.
|
|
124
|
+
# The listen socket is inherited via its file descriptor number.
|
|
125
|
+
def spawn_worker
|
|
126
|
+
env = {
|
|
127
|
+
"OCTO_WORKER" => "1",
|
|
128
|
+
"OCTO_INHERIT_FD" => @socket.fileno.to_s,
|
|
129
|
+
"OCTO_MASTER_PID" => Process.pid.to_s
|
|
130
|
+
}
|
|
131
|
+
# Keep the socket fd open across exec — mark it as non-CLOEXEC.
|
|
132
|
+
@socket.close_on_exec = false
|
|
133
|
+
|
|
134
|
+
# Reconstruct the worker command explicitly.
|
|
135
|
+
# We cannot rely on ARGV (Thor has already consumed it), so we rebuild
|
|
136
|
+
# the minimal args: `octo server --host HOST --port PORT [extra_flags]`
|
|
137
|
+
ruby = RbConfig.ruby
|
|
138
|
+
script = File.expand_path($0)
|
|
139
|
+
worker_argv = ["server", "--host", @host.to_s, "--port", @port.to_s] + @extra_flags
|
|
140
|
+
|
|
141
|
+
Octo::Logger.info("[Master PID=#{Process.pid}] spawn: #{ruby} #{script} #{worker_argv.join(' ')}")
|
|
142
|
+
Octo::Logger.info("[Master PID=#{Process.pid}] env: #{env.inspect}")
|
|
143
|
+
|
|
144
|
+
# pgroup: 0 puts worker in its own process group.
|
|
145
|
+
# This lets Master send TERM/KILL to the entire group (-pid) on shutdown,
|
|
146
|
+
# ensuring grandchildren (e.g. chrome-devtools-mcp node process) are also
|
|
147
|
+
# cleaned up even if the worker is force-killed before its shutdown_proc runs.
|
|
148
|
+
#
|
|
149
|
+
# NOTE on stdio: we deliberately let the worker inherit Master's fd 0/1/2
|
|
150
|
+
# so users see startup banner / request logs in their terminal. Protection
|
|
151
|
+
# against Errno::EPIPE on broken parent stdout is installed inside the
|
|
152
|
+
# worker itself (see cli.rb worker entry — EPIPESafeIO wrapper).
|
|
153
|
+
pid = spawn(env, ruby, script, *worker_argv, pgroup: 0)
|
|
154
|
+
Octo::Logger.info("[Master PID=#{Process.pid}] Spawned worker PID=#{pid} pgroup=#{pid}")
|
|
155
|
+
pid
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Gracefully stop the old worker (so it can persist in-memory sessions),
|
|
159
|
+
# wait for it to exit, then spawn a new one.
|
|
160
|
+
def hot_restart
|
|
161
|
+
old_pid = @worker_pid
|
|
162
|
+
Octo::Logger.info("[Master] Hot restart: stopping old worker PID=#{old_pid}...")
|
|
163
|
+
|
|
164
|
+
# TERM the old worker's process group so grandchildren (node MCP, etc.)
|
|
165
|
+
# also get a chance to shut down cleanly (triggering interrupt_all_agents).
|
|
166
|
+
begin
|
|
167
|
+
Process.kill("TERM", -old_pid)
|
|
168
|
+
deadline = Time.now + 5
|
|
169
|
+
loop do
|
|
170
|
+
pid, = Process.waitpid2(old_pid, Process::WNOHANG)
|
|
171
|
+
break if pid
|
|
172
|
+
break if Time.now > deadline
|
|
173
|
+
sleep 0.1
|
|
174
|
+
end
|
|
175
|
+
Process.kill("KILL", -old_pid) rescue nil # force-kill entire group if still alive
|
|
176
|
+
rescue Errno::ESRCH
|
|
177
|
+
# already gone — fine
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Old worker is gone; now spawn the replacement.
|
|
181
|
+
new_pid = spawn_worker
|
|
182
|
+
@worker_pid = new_pid
|
|
183
|
+
Octo::Logger.info("[Master] Hot restart complete. New worker PID=#{new_pid}")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def shutdown
|
|
187
|
+
Octo::Logger.info("[Master] Shutting down (worker PID=#{@worker_pid})...")
|
|
188
|
+
if @worker_pid
|
|
189
|
+
begin
|
|
190
|
+
# TERM the entire worker process group so grandchildren (node MCP, etc.)
|
|
191
|
+
# are also signalled and can clean up before we force-kill.
|
|
192
|
+
Process.kill("TERM", -@worker_pid)
|
|
193
|
+
# Wait up to 2s for worker graceful exit, then KILL the whole group
|
|
194
|
+
deadline = Time.now + 3
|
|
195
|
+
loop do
|
|
196
|
+
pid, = Process.waitpid2(@worker_pid, Process::WNOHANG)
|
|
197
|
+
break if pid
|
|
198
|
+
if Time.now > deadline
|
|
199
|
+
Octo::Logger.warn("[Master] Worker did not exit in time, sending KILL...")
|
|
200
|
+
Process.kill("KILL", -@worker_pid) rescue nil
|
|
201
|
+
break
|
|
202
|
+
end
|
|
203
|
+
sleep 0.1
|
|
204
|
+
end
|
|
205
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
206
|
+
# already gone
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
@socket.close rescue nil
|
|
210
|
+
Octo::Logger.info("[Master] Exited.")
|
|
211
|
+
exit(0)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def pid_file_path
|
|
215
|
+
File.join(Dir.tmpdir, "octo-master-#{@port}.pid")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def write_pid_file
|
|
219
|
+
File.write(pid_file_path, Process.pid.to_s)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def remove_pid_file
|
|
223
|
+
File.delete(pid_file_path) if File.exist?(pid_file_path)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def port_free_within?(seconds)
|
|
227
|
+
deadline = Time.now + seconds
|
|
228
|
+
loop do
|
|
229
|
+
begin
|
|
230
|
+
TCPServer.new(@host, @port).close
|
|
231
|
+
return true
|
|
232
|
+
rescue Errno::EADDRINUSE
|
|
233
|
+
return false if Time.now > deadline
|
|
234
|
+
sleep 0.1
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Try to bind to preferred_port, fall back to next ports if occupied.
|
|
240
|
+
# Returns the bound TCPServer, or nil if all ports in range are occupied.
|
|
241
|
+
def bind_with_fallback(host, preferred_port, max_port:)
|
|
242
|
+
(preferred_port..max_port).each do |port|
|
|
243
|
+
begin
|
|
244
|
+
server = TCPServer.new(host, port)
|
|
245
|
+
Octo::Logger.info("[Master] Bound to port #{port}") if port != preferred_port
|
|
246
|
+
return server
|
|
247
|
+
rescue Errno::EADDRINUSE
|
|
248
|
+
next
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
nil
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def print_banner(port_changed: false, original_port: nil)
|
|
255
|
+
banner = Octo::Banner.new
|
|
256
|
+
puts ""
|
|
257
|
+
puts banner.logo
|
|
258
|
+
puts banner.tagline
|
|
259
|
+
puts ""
|
|
260
|
+
|
|
261
|
+
if port_changed
|
|
262
|
+
puts " [!] Port #{original_port} is in use, using #{@port} instead"
|
|
263
|
+
puts ""
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
puts " Web UI: #{banner.highlight("http://#{@host}:#{@port}")}"
|
|
267
|
+
puts " Version: #{Octo::VERSION}"
|
|
268
|
+
puts " Press Ctrl-C to stop."
|
|
269
|
+
puts ""
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Scan all fallback port PID files to prevent duplicate masters
|
|
273
|
+
# when a previous instance bound to a non-default fallback port.
|
|
274
|
+
def kill_existing_master
|
|
275
|
+
max_port = (@port == 8888) ? (@port + 5) : @port
|
|
276
|
+
(@port..max_port).each do |port|
|
|
277
|
+
kill_master_on_port(port)
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
private def kill_master_on_port(port)
|
|
282
|
+
path = File.join(Dir.tmpdir, "octo-master-#{port}.pid")
|
|
283
|
+
return unless File.exist?(path)
|
|
284
|
+
|
|
285
|
+
pid = File.read(path).strip.to_i
|
|
286
|
+
if pid <= 0
|
|
287
|
+
File.delete(path) rescue nil
|
|
288
|
+
return
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
begin
|
|
292
|
+
Process.kill("TERM", pid)
|
|
293
|
+
Octo::Logger.info("[Master] Sent TERM to existing master (PID=#{pid}, port=#{port}), waiting...")
|
|
294
|
+
|
|
295
|
+
deadline = Time.now + 5
|
|
296
|
+
until process_dead?(pid) || Time.now > deadline
|
|
297
|
+
sleep 0.1
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
unless process_dead?(pid)
|
|
301
|
+
Octo::Logger.warn("[Master] PID=#{pid} still alive after 5s, sending KILL...")
|
|
302
|
+
Process.kill("KILL", pid) rescue Errno::ESRCH
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
Octo::Logger.info("[Master] Existing master PID=#{pid} (port=#{port}) stopped.")
|
|
306
|
+
rescue Errno::ESRCH
|
|
307
|
+
Octo::Logger.info("[Master] Existing master PID=#{pid} already gone.")
|
|
308
|
+
rescue Errno::EPERM
|
|
309
|
+
Octo::Logger.warn("[Master] Could not stop existing master (PID=#{pid}) — permission denied.")
|
|
310
|
+
ensure
|
|
311
|
+
File.delete(path) if File.exist?(path)
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
private def process_dead?(pid)
|
|
316
|
+
Process.kill(0, pid)
|
|
317
|
+
false
|
|
318
|
+
rescue Errno::ESRCH
|
|
319
|
+
true
|
|
320
|
+
rescue Errno::EPERM
|
|
321
|
+
false
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|