rubino-agent 0.3.0
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/.rspec +3 -0
- data/.rubocop.yml +115 -0
- data/.rubocop_todo.yml +955 -0
- data/.ruby-version +1 -0
- data/AGENTS.md +97 -0
- data/CHANGELOG.md +344 -0
- data/CONTRIBUTING.md +69 -0
- data/LICENSE +21 -0
- data/README.md +200 -0
- data/Rakefile +8 -0
- data/docs/agents.md +190 -0
- data/docs/api/v1.md +414 -0
- data/docs/architecture.md +177 -0
- data/docs/commands.md +375 -0
- data/docs/configuration.md +590 -0
- data/docs/getting-started.md +143 -0
- data/docs/jobs.md +332 -0
- data/docs/mcp.md +128 -0
- data/docs/memory.md +98 -0
- data/docs/models-and-keys.md +173 -0
- data/docs/oauth-providers.md +145 -0
- data/docs/plugins.md +195 -0
- data/docs/security.md +145 -0
- data/docs/skills.md +322 -0
- data/docs/tools.md +395 -0
- data/docs/troubleshooting.md +73 -0
- data/exe/rubino +9 -0
- data/install.sh +275 -0
- data/lib/rubino/active_skill.rb +50 -0
- data/lib/rubino/agent/agent_registry.rb +120 -0
- data/lib/rubino/agent/backoff_policy.rb +116 -0
- data/lib/rubino/agent/definition.rb +128 -0
- data/lib/rubino/agent/degenerate_recovery.rb +271 -0
- data/lib/rubino/agent/fallback_chain.rb +194 -0
- data/lib/rubino/agent/iteration_budget.rb +50 -0
- data/lib/rubino/agent/loop.rb +617 -0
- data/lib/rubino/agent/model_call_runner.rb +383 -0
- data/lib/rubino/agent/prompts/build.txt +69 -0
- data/lib/rubino/agent/prompts/compaction.txt +20 -0
- data/lib/rubino/agent/prompts/explore.txt +19 -0
- data/lib/rubino/agent/prompts/general.txt +20 -0
- data/lib/rubino/agent/prompts/plan.txt +31 -0
- data/lib/rubino/agent/response_validator.rb +70 -0
- data/lib/rubino/agent/router.rb +65 -0
- data/lib/rubino/agent/runner.rb +195 -0
- data/lib/rubino/agent/tool_executor.rb +402 -0
- data/lib/rubino/agent/truncation_continuation.rb +137 -0
- data/lib/rubino/api/middleware/auth.rb +43 -0
- data/lib/rubino/api/middleware/error_handler.rb +65 -0
- data/lib/rubino/api/middleware/json_parser.rb +100 -0
- data/lib/rubino/api/middleware/observability.rb +59 -0
- data/lib/rubino/api/middleware/rate_limit.rb +136 -0
- data/lib/rubino/api/operations/approvals/decide_operation.rb +49 -0
- data/lib/rubino/api/operations/clarifications/decide_operation.rb +44 -0
- data/lib/rubino/api/operations/cron_jobs/create_operation.rb +46 -0
- data/lib/rubino/api/operations/cron_jobs/delete_operation.rb +36 -0
- data/lib/rubino/api/operations/cron_jobs/list_operation.rb +55 -0
- data/lib/rubino/api/operations/cron_jobs/pause_operation.rb +34 -0
- data/lib/rubino/api/operations/cron_jobs/resume_operation.rb +34 -0
- data/lib/rubino/api/operations/cron_jobs/schedule_validation.rb +30 -0
- data/lib/rubino/api/operations/cron_jobs/show_operation.rb +32 -0
- data/lib/rubino/api/operations/cron_jobs/trigger_operation.rb +38 -0
- data/lib/rubino/api/operations/cron_jobs/update_operation.rb +42 -0
- data/lib/rubino/api/operations/files/read_operation.rb +40 -0
- data/lib/rubino/api/operations/files/upload_operation.rb +175 -0
- data/lib/rubino/api/operations/health_operation.rb +46 -0
- data/lib/rubino/api/operations/memory/delete_operation.rb +32 -0
- data/lib/rubino/api/operations/memory/index_operation.rb +80 -0
- data/lib/rubino/api/operations/memory/stats_operation.rb +28 -0
- data/lib/rubino/api/operations/metrics_operation.rb +18 -0
- data/lib/rubino/api/operations/mode/show_operation.rb +29 -0
- data/lib/rubino/api/operations/mode/update_operation.rb +42 -0
- data/lib/rubino/api/operations/models/list_operation.rb +45 -0
- data/lib/rubino/api/operations/oauth/connections/disconnect_operation.rb +77 -0
- data/lib/rubino/api/operations/oauth/connections/list_operation.rb +36 -0
- data/lib/rubino/api/operations/oauth/providers/callback_operation.rb +82 -0
- data/lib/rubino/api/operations/oauth/providers/connect_operation.rb +44 -0
- data/lib/rubino/api/operations/oauth/providers/list_operation.rb +35 -0
- data/lib/rubino/api/operations/oauth/serializer.rb +21 -0
- data/lib/rubino/api/operations/runs/create_operation.rb +77 -0
- data/lib/rubino/api/operations/runs/events_operation.rb +195 -0
- data/lib/rubino/api/operations/runs/stop_operation.rb +34 -0
- data/lib/rubino/api/operations/sessions/create_operation.rb +46 -0
- data/lib/rubino/api/operations/sessions/delete_operation.rb +33 -0
- data/lib/rubino/api/operations/sessions/index_operation.rb +82 -0
- data/lib/rubino/api/operations/sessions/retry_operation.rb +45 -0
- data/lib/rubino/api/operations/sessions/show_operation.rb +59 -0
- data/lib/rubino/api/operations/sessions/undo_operation.rb +38 -0
- data/lib/rubino/api/operations/skills/list_operation.rb +34 -0
- data/lib/rubino/api/operations/skills/toggle_operation.rb +40 -0
- data/lib/rubino/api/operations/tasks/index_operation.rb +30 -0
- data/lib/rubino/api/operations/tasks/serializer.rb +60 -0
- data/lib/rubino/api/operations/tasks/show_operation.rb +33 -0
- data/lib/rubino/api/operations/tasks/stop_operation.rb +47 -0
- data/lib/rubino/api/request.rb +54 -0
- data/lib/rubino/api/responses.rb +64 -0
- data/lib/rubino/api/router.rb +72 -0
- data/lib/rubino/api/schemas.rb +103 -0
- data/lib/rubino/api/server.rb +102 -0
- data/lib/rubino/api/tls.rb +108 -0
- data/lib/rubino/attachments/classification.rb +16 -0
- data/lib/rubino/attachments/classify.rb +171 -0
- data/lib/rubino/attachments/defang.rb +47 -0
- data/lib/rubino/attachments/policy.rb +36 -0
- data/lib/rubino/attachments/preamble.rb +120 -0
- data/lib/rubino/boot/encryption_key.rb +32 -0
- data/lib/rubino/cli/chat/bang_shell.rb +257 -0
- data/lib/rubino/cli/chat/completion_builder.rb +290 -0
- data/lib/rubino/cli/chat/idle_card_host.rb +69 -0
- data/lib/rubino/cli/chat/image_inbox.rb +168 -0
- data/lib/rubino/cli/chat/session_resolver.rb +176 -0
- data/lib/rubino/cli/chat_command.rb +1674 -0
- data/lib/rubino/cli/commands.rb +250 -0
- data/lib/rubino/cli/config_command.rb +96 -0
- data/lib/rubino/cli/doctor_command.rb +251 -0
- data/lib/rubino/cli/jobs_command.rb +60 -0
- data/lib/rubino/cli/memory_command.rb +135 -0
- data/lib/rubino/cli/onboarding_wizard.rb +207 -0
- data/lib/rubino/cli/server_command.rb +139 -0
- data/lib/rubino/cli/session_command.rb +125 -0
- data/lib/rubino/cli/setup_command.rb +107 -0
- data/lib/rubino/cli/skills_command.rb +85 -0
- data/lib/rubino/cli/tools_command.rb +81 -0
- data/lib/rubino/cli/trust_gate.rb +71 -0
- data/lib/rubino/commands/built_ins.rb +46 -0
- data/lib/rubino/commands/command.rb +116 -0
- data/lib/rubino/commands/executor.rb +550 -0
- data/lib/rubino/commands/handlers/agents.rb +510 -0
- data/lib/rubino/commands/handlers/config.rb +88 -0
- data/lib/rubino/commands/handlers/help.rb +148 -0
- data/lib/rubino/commands/handlers/jobs.rb +71 -0
- data/lib/rubino/commands/handlers/mcp.rb +229 -0
- data/lib/rubino/commands/handlers/memory.rb +200 -0
- data/lib/rubino/commands/handlers/sessions.rb +207 -0
- data/lib/rubino/commands/handlers/skills.rb +195 -0
- data/lib/rubino/commands/handlers/status.rb +211 -0
- data/lib/rubino/commands/loader.rb +90 -0
- data/lib/rubino/config/configuration.rb +455 -0
- data/lib/rubino/config/defaults.rb +569 -0
- data/lib/rubino/config/loader.rb +115 -0
- data/lib/rubino/config/reasoning_prefs.rb +67 -0
- data/lib/rubino/config/writer.rb +72 -0
- data/lib/rubino/context/compressor.rb +149 -0
- data/lib/rubino/context/environment_inspector.rb +176 -0
- data/lib/rubino/context/file_discovery.rb +45 -0
- data/lib/rubino/context/message_boundary.rb +39 -0
- data/lib/rubino/context/prompt_assembler.rb +382 -0
- data/lib/rubino/context/summary_builder.rb +159 -0
- data/lib/rubino/context/token_budget.rb +68 -0
- data/lib/rubino/context/tool_pair_sanitizer.rb +70 -0
- data/lib/rubino/database/connection.rb +77 -0
- data/lib/rubino/database/migrations/001_create_initial_schema.rb +156 -0
- data/lib/rubino/database/migrations/002_create_runs.rb +45 -0
- data/lib/rubino/database/migrations/003_create_skill_states.rb +15 -0
- data/lib/rubino/database/migrations/004_create_cron_jobs.rb +36 -0
- data/lib/rubino/database/migrations/005_create_oauth_connections.rb +27 -0
- data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +34 -0
- data/lib/rubino/database/migrations/007_create_messages_fts.rb +59 -0
- data/lib/rubino/database/migrations/008_create_memory_facts.rb +75 -0
- data/lib/rubino/database/migrations/009_create_memory_graph.rb +55 -0
- data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +20 -0
- data/lib/rubino/database/migrator.rb +48 -0
- data/lib/rubino/documents/converters/csv.rb +79 -0
- data/lib/rubino/documents/converters/docx.rb +129 -0
- data/lib/rubino/documents/converters/html.rb +28 -0
- data/lib/rubino/documents/converters/json.rb +35 -0
- data/lib/rubino/documents/converters/pdf.rb +59 -0
- data/lib/rubino/documents/converters/plain.rb +68 -0
- data/lib/rubino/documents/converters/pptx.rb +64 -0
- data/lib/rubino/documents/converters/xlsx.rb +62 -0
- data/lib/rubino/documents/converters/xml.rb +45 -0
- data/lib/rubino/documents/html.rb +71 -0
- data/lib/rubino/documents/registry.rb +68 -0
- data/lib/rubino/documents/table.rb +63 -0
- data/lib/rubino/documents.rb +50 -0
- data/lib/rubino/errors.rb +119 -0
- data/lib/rubino/files/workspace.rb +93 -0
- data/lib/rubino/interaction/cancel_token.rb +43 -0
- data/lib/rubino/interaction/clipboard_image.rb +84 -0
- data/lib/rubino/interaction/event_bus.rb +48 -0
- data/lib/rubino/interaction/events.rb +101 -0
- data/lib/rubino/interaction/image_input.rb +127 -0
- data/lib/rubino/interaction/input_queue.rb +117 -0
- data/lib/rubino/interaction/lifecycle.rb +299 -0
- data/lib/rubino/interaction/probe.rb +65 -0
- data/lib/rubino/interaction/state.rb +56 -0
- data/lib/rubino/jobs/cron_job_repository.rb +75 -0
- data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +32 -0
- data/lib/rubino/jobs/handlers/compact_session_job.rb +21 -0
- data/lib/rubino/jobs/handlers/distill_skill_job.rb +186 -0
- data/lib/rubino/jobs/handlers/extract_memory_job.rb +37 -0
- data/lib/rubino/jobs/handlers/summarize_session_job.rb +21 -0
- data/lib/rubino/jobs/queue.rb +184 -0
- data/lib/rubino/jobs/registry.rb +45 -0
- data/lib/rubino/jobs/runner.rb +79 -0
- data/lib/rubino/jobs/scheduler.rb +138 -0
- data/lib/rubino/jobs/webhook_delivery.rb +225 -0
- data/lib/rubino/jobs/worker.rb +59 -0
- data/lib/rubino/llm/adapter_factory.rb +47 -0
- data/lib/rubino/llm/adapter_response.rb +65 -0
- data/lib/rubino/llm/auxiliary_client.rb +61 -0
- data/lib/rubino/llm/bedrock_bearer_client.rb +235 -0
- data/lib/rubino/llm/content_builder.rb +55 -0
- data/lib/rubino/llm/credential_check.rb +93 -0
- data/lib/rubino/llm/error_classifier.rb +364 -0
- data/lib/rubino/llm/fake_provider.rb +292 -0
- data/lib/rubino/llm/inline_think_filter.rb +58 -0
- data/lib/rubino/llm/model_catalog.rb +29 -0
- data/lib/rubino/llm/provider_resolver.rb +48 -0
- data/lib/rubino/llm/reasoning_manager.rb +100 -0
- data/lib/rubino/llm/request.rb +56 -0
- data/lib/rubino/llm/ruby_llm_adapter.rb +794 -0
- data/lib/rubino/llm/scenario_loader.rb +68 -0
- data/lib/rubino/llm/scenario_selector.rb +80 -0
- data/lib/rubino/llm/scenarios/agent-creates-cron-failure.yml +29 -0
- data/lib/rubino/llm/scenarios/agent-creates-cron.yml +36 -0
- data/lib/rubino/llm/scenarios/analysis.yml +501 -0
- data/lib/rubino/llm/scenarios/complex-analysis.yml +598 -0
- data/lib/rubino/llm/scenarios/failure.yml +65 -0
- data/lib/rubino/llm/scenarios/happy-path.yml +24 -0
- data/lib/rubino/llm/scenarios/provider-quota-completed.yml +14 -0
- data/lib/rubino/llm/scenarios/wide-table.yml +121 -0
- data/lib/rubino/llm/scenarios/with-approvals.yml +50 -0
- data/lib/rubino/llm/scenarios/with-artifacts.yml +98 -0
- data/lib/rubino/llm/scenarios/with-clarify.yml +32 -0
- data/lib/rubino/llm/scenarios/with-reasoning.yml +175 -0
- data/lib/rubino/llm/scenarios/with-uploads.yml +104 -0
- data/lib/rubino/llm/thinking_support.rb +84 -0
- data/lib/rubino/llm/tool_bridge.rb +89 -0
- data/lib/rubino/logger.rb +99 -0
- data/lib/rubino/mcp/manager.rb +180 -0
- data/lib/rubino/mcp/mcp_tool_wrapper.rb +69 -0
- data/lib/rubino/mcp.rb +57 -0
- data/lib/rubino/memory/backend.rb +104 -0
- data/lib/rubino/memory/backends/default.rb +101 -0
- data/lib/rubino/memory/backends/sqlite.rb +653 -0
- data/lib/rubino/memory/backends.rb +53 -0
- data/lib/rubino/memory/deduplicator.rb +74 -0
- data/lib/rubino/memory/extractor.rb +85 -0
- data/lib/rubino/memory/flusher.rb +31 -0
- data/lib/rubino/memory/retriever.rb +50 -0
- data/lib/rubino/memory/sqlite_extraction_prompt.rb +70 -0
- data/lib/rubino/memory/sqlite_graph.rb +154 -0
- data/lib/rubino/memory/store.rb +228 -0
- data/lib/rubino/memory/threat_scanner.rb +68 -0
- data/lib/rubino/metrics.rb +175 -0
- data/lib/rubino/modes.rb +93 -0
- data/lib/rubino/oauth/connection_repository.rb +95 -0
- data/lib/rubino/oauth/provider/github.rb +75 -0
- data/lib/rubino/oauth/provider/google.rb +59 -0
- data/lib/rubino/oauth/provider.rb +149 -0
- data/lib/rubino/oauth/registry.rb +86 -0
- data/lib/rubino/oauth/token_encryptor.rb +87 -0
- data/lib/rubino/plugins/registry.rb +75 -0
- data/lib/rubino/plugins.rb +86 -0
- data/lib/rubino/run/approval_gate.rb +243 -0
- data/lib/rubino/run/attachment_downloader.rb +166 -0
- data/lib/rubino/run/event_store.rb +74 -0
- data/lib/rubino/run/executor.rb +383 -0
- data/lib/rubino/run/gate_registry.rb +39 -0
- data/lib/rubino/run/recorder.rb +69 -0
- data/lib/rubino/run/repository.rb +118 -0
- data/lib/rubino/run/session_approval_cache.rb +118 -0
- data/lib/rubino/security/allowlist_persister.rb +55 -0
- data/lib/rubino/security/approval_policy.rb +227 -0
- data/lib/rubino/security/command_allowlist.rb +24 -0
- data/lib/rubino/security/dangerous_patterns.rb +118 -0
- data/lib/rubino/security/deny_persister.rb +73 -0
- data/lib/rubino/security/doom_loop_detector.rb +43 -0
- data/lib/rubino/security/hardline_guard.rb +105 -0
- data/lib/rubino/security/pattern_matcher.rb +62 -0
- data/lib/rubino/security/prefix_deriver.rb +124 -0
- data/lib/rubino/security/readonly_commands.rb +211 -0
- data/lib/rubino/session/exporter.rb +101 -0
- data/lib/rubino/session/message.rb +77 -0
- data/lib/rubino/session/repository.rb +295 -0
- data/lib/rubino/session/store.rb +198 -0
- data/lib/rubino/session/summary_store.rb +65 -0
- data/lib/rubino/skills/prompt_index.rb +85 -0
- data/lib/rubino/skills/registry.rb +208 -0
- data/lib/rubino/skills/skill.rb +176 -0
- data/lib/rubino/skills/skill_tool.rb +215 -0
- data/lib/rubino/skills/state_repository.rb +37 -0
- data/lib/rubino/skills/toggle.rb +26 -0
- data/lib/rubino/tools/answer_child_tool.rb +83 -0
- data/lib/rubino/tools/ask_parent_tool.rb +232 -0
- data/lib/rubino/tools/attach_file_tool.rb +120 -0
- data/lib/rubino/tools/background_tasks.rb +520 -0
- data/lib/rubino/tools/base.rb +222 -0
- data/lib/rubino/tools/custom_tool_loader.rb +119 -0
- data/lib/rubino/tools/edit_tool.rb +122 -0
- data/lib/rubino/tools/git_tool.rb +71 -0
- data/lib/rubino/tools/github_tool.rb +233 -0
- data/lib/rubino/tools/glob_tool.rb +69 -0
- data/lib/rubino/tools/grep_tool.rb +206 -0
- data/lib/rubino/tools/memory_tool.rb +184 -0
- data/lib/rubino/tools/multi_edit_tool.rb +110 -0
- data/lib/rubino/tools/patch_tool.rb +260 -0
- data/lib/rubino/tools/probe_tool.rb +175 -0
- data/lib/rubino/tools/question_tool.rb +128 -0
- data/lib/rubino/tools/read_attachment_tool.rb +180 -0
- data/lib/rubino/tools/read_tool.rb +212 -0
- data/lib/rubino/tools/read_tracker.rb +98 -0
- data/lib/rubino/tools/registry.rb +166 -0
- data/lib/rubino/tools/result.rb +113 -0
- data/lib/rubino/tools/ruby_tool.rb +0 -0
- data/lib/rubino/tools/session_search_tool.rb +103 -0
- data/lib/rubino/tools/shell_input_tool.rb +96 -0
- data/lib/rubino/tools/shell_kill_tool.rb +76 -0
- data/lib/rubino/tools/shell_output_tool.rb +72 -0
- data/lib/rubino/tools/shell_registry.rb +158 -0
- data/lib/rubino/tools/shell_tail_tool.rb +118 -0
- data/lib/rubino/tools/shell_tool.rb +330 -0
- data/lib/rubino/tools/steer_tool.rb +118 -0
- data/lib/rubino/tools/subagent_probe.rb +89 -0
- data/lib/rubino/tools/summarize_file_tool.rb +182 -0
- data/lib/rubino/tools/task_result_tool.rb +90 -0
- data/lib/rubino/tools/task_stop_tool.rb +80 -0
- data/lib/rubino/tools/task_tool.rb +622 -0
- data/lib/rubino/tools/test_tool.rb +454 -0
- data/lib/rubino/tools/todo_tool.rb +93 -0
- data/lib/rubino/tools/tool_call_repository.rb +33 -0
- data/lib/rubino/tools/vision_tool.rb +85 -0
- data/lib/rubino/tools/webfetch_tool.rb +153 -0
- data/lib/rubino/tools/websearch_tool.rb +179 -0
- data/lib/rubino/tools/write_tool.rb +61 -0
- data/lib/rubino/trust.rb +88 -0
- data/lib/rubino/ui/api.rb +296 -0
- data/lib/rubino/ui/base.rb +252 -0
- data/lib/rubino/ui/bottom_composer.rb +1599 -0
- data/lib/rubino/ui/cli.rb +1987 -0
- data/lib/rubino/ui/completion_menu.rb +321 -0
- data/lib/rubino/ui/completion_source.rb +284 -0
- data/lib/rubino/ui/escape_reader.rb +169 -0
- data/lib/rubino/ui/indented_io.rb +88 -0
- data/lib/rubino/ui/input_history.rb +108 -0
- data/lib/rubino/ui/live_region.rb +183 -0
- data/lib/rubino/ui/markdown_renderer.rb +506 -0
- data/lib/rubino/ui/notifier.rb +163 -0
- data/lib/rubino/ui/null.rb +195 -0
- data/lib/rubino/ui/paste_store.rb +176 -0
- data/lib/rubino/ui/printer_base.rb +79 -0
- data/lib/rubino/ui/probe_wait_indicator.rb +75 -0
- data/lib/rubino/ui/queued_indicators.rb +66 -0
- data/lib/rubino/ui/status_bar.rb +100 -0
- data/lib/rubino/ui/stdout_proxy.rb +161 -0
- data/lib/rubino/ui/streaming_markdown.rb +186 -0
- data/lib/rubino/ui/subagent_cards.rb +134 -0
- data/lib/rubino/ui/subagent_view.rb +255 -0
- data/lib/rubino/ui.rb +21 -0
- data/lib/rubino/update_check.rb +187 -0
- data/lib/rubino/util/duration.rb +23 -0
- data/lib/rubino/util/hyperlink.rb +105 -0
- data/lib/rubino/util/output.rb +145 -0
- data/lib/rubino/util/secrets_mask.rb +83 -0
- data/lib/rubino/version.rb +5 -0
- data/lib/rubino/workspace.rb +85 -0
- data/lib/rubino-agent.rb +5 -0
- data/lib/rubino.rb +318 -0
- data/mise.toml +2 -0
- data/rubino-agent.gemspec +103 -0
- data/skills/ruby-expert/SKILL.md +67 -0
- data/skills/ruby-expert/references/concurrency.md +357 -0
- data/skills/ruby-expert/references/datetime-and-encoding.md +363 -0
- data/skills/ruby-expert/references/errors-and-types.md +460 -0
- data/skills/ruby-expert/references/gem-authoring.md +459 -0
- data/skills/ruby-expert/references/language-idioms.md +465 -0
- data/skills/ruby-expert/references/metaprogramming.md +339 -0
- data/skills/ruby-expert/references/oo-design.md +553 -0
- data/skills/ruby-expert/references/performance.md +383 -0
- data/skills/ruby-expert/references/rails.md +424 -0
- data/skills/ruby-expert/references/security.md +404 -0
- data/skills/ruby-expert/references/testing.md +473 -0
- data/skills/ruby-expert/references/tooling.md +466 -0
- metadata +856 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/rubino/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "rubino-agent"
|
|
7
|
+
spec.version = Rubino::VERSION
|
|
8
|
+
spec.authors = ["Jhon Rojas"]
|
|
9
|
+
spec.email = ["jhon@example.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "A lightweight Ruby coding and automation agent with persistent memory, sessions, and context compaction"
|
|
12
|
+
spec.description = "A standalone, self-contained coding and automation agent built on ruby_llm. " \
|
|
13
|
+
"Provides an agent loop, persistent memory, SQLite sessions, context compaction, " \
|
|
14
|
+
"a job system, a tool registry, and an extensible UI layer."
|
|
15
|
+
spec.homepage = "https://github.com/Jhonnyr97/rubino-agent"
|
|
16
|
+
spec.license = "MIT"
|
|
17
|
+
spec.required_ruby_version = ">= 3.1.0"
|
|
18
|
+
|
|
19
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
20
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
21
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
22
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
23
|
+
|
|
24
|
+
spec.files = Dir.chdir(__dir__) do
|
|
25
|
+
# Use git if available, otherwise glob
|
|
26
|
+
if system("git rev-parse --git-dir > /dev/null 2>&1")
|
|
27
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
28
|
+
(File.expand_path(f) == __FILE__) ||
|
|
29
|
+
f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
|
|
30
|
+
end
|
|
31
|
+
else
|
|
32
|
+
Dir.glob("{lib,exe}/**/*").reject { |f| File.directory?(f) } +
|
|
33
|
+
%w[Gemfile Rakefile README.md CHANGELOG.md]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
spec.bindir = "exe"
|
|
38
|
+
spec.executables = ["rubino"]
|
|
39
|
+
spec.require_paths = ["lib"]
|
|
40
|
+
|
|
41
|
+
# Core dependencies
|
|
42
|
+
spec.add_dependency "dry-configurable", "~> 1.0"
|
|
43
|
+
spec.add_dependency "dry-schema", "~> 1.13"
|
|
44
|
+
spec.add_dependency "faraday", "~> 2.9"
|
|
45
|
+
spec.add_dependency "faraday-retry", "~> 2.2"
|
|
46
|
+
spec.add_dependency "oauth2", "~> 2.0"
|
|
47
|
+
spec.add_dependency "puma", "~> 6.4"
|
|
48
|
+
spec.add_dependency "rack", "~> 3.1"
|
|
49
|
+
spec.add_dependency "ruby_llm", "~> 1.0"
|
|
50
|
+
spec.add_dependency "ruby_llm-mcp", "~> 1.0"
|
|
51
|
+
spec.add_dependency "rufus-scheduler", "~> 3.9"
|
|
52
|
+
spec.add_dependency "sequel", "~> 5.0"
|
|
53
|
+
spec.add_dependency "sqlite3", "~> 2.0"
|
|
54
|
+
spec.add_dependency "thor", "~> 1.3"
|
|
55
|
+
spec.add_dependency "zeitwerk", "~> 2.6"
|
|
56
|
+
|
|
57
|
+
# CLI UI dependencies
|
|
58
|
+
spec.add_dependency "kramdown", "~> 2.5"
|
|
59
|
+
spec.add_dependency "kramdown-parser-gfm", "~> 1.1"
|
|
60
|
+
spec.add_dependency "pastel", "~> 0.8"
|
|
61
|
+
spec.add_dependency "tty-box", "~> 0.7"
|
|
62
|
+
spec.add_dependency "tty-prompt", "~> 0.23"
|
|
63
|
+
spec.add_dependency "tty-spinner", "~> 0.9"
|
|
64
|
+
spec.add_dependency "tty-table", "~> 0.12"
|
|
65
|
+
spec.add_dependency "unicode-display_width", "~> 2.6"
|
|
66
|
+
|
|
67
|
+
# Reline used to ship with Ruby, but it was removed from default gems
|
|
68
|
+
# in Ruby 4.0 and is now a regular gem. UI::LineInput depends on it for
|
|
69
|
+
# the interactive prompt (history, completion, multi-line editing).
|
|
70
|
+
spec.add_dependency "reline", "~> 0.5"
|
|
71
|
+
|
|
72
|
+
# `csv` left the default gems in Ruby 3.4. The in-repo document converter
|
|
73
|
+
# (Rubino::Documents) uses it for the CORE csv->Markdown format, so it is a
|
|
74
|
+
# hard runtime dependency (the converter still falls back to a built-in
|
|
75
|
+
# splitter if it is ever absent, but we ship it so csv always works).
|
|
76
|
+
spec.add_dependency "csv", "~> 3.2"
|
|
77
|
+
|
|
78
|
+
# Optional document-conversion extraction gems (Rubino::Documents, #6). These
|
|
79
|
+
# are NOT hard runtime dependencies: each converter `require`s its gem lazily
|
|
80
|
+
# inside begin/rescue LoadError and reports itself unavailable when the gem is
|
|
81
|
+
# absent, so the module loads and runs with none of them installed (callers
|
|
82
|
+
# then fall back to the shell-extraction hint). They are declared as
|
|
83
|
+
# development dependencies so CI/specs can exercise the gem-backed converters;
|
|
84
|
+
# an end user installs only the formats they need (e.g. `gem install roo`).
|
|
85
|
+
# All MIT-licensed. html/xml use kramdown/rexml which are already present.
|
|
86
|
+
#
|
|
87
|
+
# NOTE: `ruby_powerpoint` is deliberately NOT in the dev bundle -- it pins
|
|
88
|
+
# `rubyzip ~> 1.0`, which is irreconcilable with `docx`/`roo` (rubyzip ~> 2.x)
|
|
89
|
+
# in a single Gemfile. The Pptx converter is therefore exercised by its
|
|
90
|
+
# degradation path and unit-level shaping (a stubbed gem interface) rather
|
|
91
|
+
# than the live gem; an end user who needs pptx installs ruby_powerpoint into
|
|
92
|
+
# their own (compatible) environment. This is exactly the optional-require
|
|
93
|
+
# design: a missing/absent gem never breaks the module.
|
|
94
|
+
spec.add_development_dependency "docx", "~> 0.8"
|
|
95
|
+
spec.add_development_dependency "pdf-reader", "~> 2.12"
|
|
96
|
+
spec.add_development_dependency "roo", "~> 2.10"
|
|
97
|
+
|
|
98
|
+
# Development dependencies
|
|
99
|
+
spec.add_development_dependency "rack-test", "~> 2.1"
|
|
100
|
+
spec.add_development_dependency "rspec", "~> 3.12"
|
|
101
|
+
spec.add_development_dependency "rubocop", "~> 1.60"
|
|
102
|
+
spec.add_development_dependency "rubocop-rspec", "~> 3.0"
|
|
103
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ruby-expert
|
|
3
|
+
description: Deep Ruby & Rails expertise — idioms, OO design, metaprogramming, errors/types, concurrency, Rails, testing, performance, security, tooling, gem authoring. Load when writing, reviewing, debugging, or designing Ruby or Rails code, or when a Ruby/Rails decision needs an authoritative answer.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Ruby expert
|
|
7
|
+
|
|
8
|
+
Authoritative, current (Ruby 3.2–3.4, Rails 7.1–8.x) knowledge for writing,
|
|
9
|
+
reviewing, and designing idiomatic Ruby. This file is the router: it carries the
|
|
10
|
+
non-negotiable defaults, then points you at one bundled reference for deep,
|
|
11
|
+
task-specific guidance. **Load the matching reference before answering a
|
|
12
|
+
non-trivial question in that area** — don't work from memory when a reference
|
|
13
|
+
covers it.
|
|
14
|
+
|
|
15
|
+
## Non-negotiable defaults
|
|
16
|
+
|
|
17
|
+
Apply these unless the surrounding project clearly does otherwise (existing
|
|
18
|
+
project conventions always win over these defaults):
|
|
19
|
+
|
|
20
|
+
- **Match the codebase first.** Read neighbouring files and mirror their naming,
|
|
21
|
+
structure, and idioms before introducing your own. Consistency beats personal
|
|
22
|
+
preference.
|
|
23
|
+
- **`# frozen_string_literal: true`** as the first line of every Ruby source file.
|
|
24
|
+
- **Naming:** `snake_case` for methods/variables, `CamelCase` for classes/modules,
|
|
25
|
+
`SCREAMING_SNAKE_CASE` for constants, `?` suffix for predicates, `!` suffix only
|
|
26
|
+
for the dangerous/mutating variant that has a safe sibling.
|
|
27
|
+
- **Keyword arguments** for anything with more than one or two parameters, or any
|
|
28
|
+
boolean/optional flag — never a positional boolean.
|
|
29
|
+
- **`rescue StandardError`**, never a bare `rescue` or `rescue Exception`. Never
|
|
30
|
+
rescue just to swallow — handle, re-raise, or don't rescue.
|
|
31
|
+
- **Two-space indentation, no tabs.** Guard clauses over deep nesting. Prefer the
|
|
32
|
+
smallest method that reads clearly.
|
|
33
|
+
- **Tests are part of "done."** New behavior ships with a spec; a bug fix ships
|
|
34
|
+
with the regression test that would have caught it.
|
|
35
|
+
- **Run the linter.** Honor the project's `.rubocop.yml` / `standard`; don't fight
|
|
36
|
+
it or disable cops without a reason in a comment.
|
|
37
|
+
- **Security is not optional.** Never interpolate untrusted input into SQL, shell,
|
|
38
|
+
`eval`, `send`, or deserialization. See `references/security.md`.
|
|
39
|
+
|
|
40
|
+
## Which reference to load
|
|
41
|
+
|
|
42
|
+
| Load `references/…` | When the task involves |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| `language-idioms.md` | Day-to-day Ruby: collections/Enumerable, pattern matching, blocks/procs/lambdas, keyword args, `Data`/`Struct`, hash idioms, nil handling, numbers/money |
|
|
45
|
+
| `datetime-and-encoding.md` | Dates/times/time zones (the `Time.now` vs `Time.zone.now` footgun, DST, parsing, monotonic clock) and string encoding (UTF-8, `force_encoding` vs `encode`, scrubbing bad bytes) |
|
|
46
|
+
| `metaprogramming.md` | `define_method`, `method_missing`, `send`, hooks, `class_eval`, refinements, building DSLs, introspection |
|
|
47
|
+
| `oo-design.md` | Class/module design, SOLID, composition vs inheritance, service/value/query/policy objects, Result objects, dependency injection, refactoring a god object |
|
|
48
|
+
| `errors-and-types.md` | Exception design, `rescue`/`retry`/`ensure`, custom errors, cause chaining, and RBS/Sorbet type checking |
|
|
49
|
+
| `concurrency.md` | Threads, mutexes/queues, the GVL, fibers, the `async` gem, Ractors, processes/fork — choosing a concurrency model |
|
|
50
|
+
| `rails.md` | Anything Rails: Active Record, migrations, controllers, routing, concerns, jobs, Hotwire, caching, Rails secure defaults |
|
|
51
|
+
| `testing.md` | RSpec or Minitest, FactoryBot, TDD, doubles/mocks, WebMock/VCR, request vs system specs, fixing flaky tests |
|
|
52
|
+
| `performance.md` | Profiling, memory/allocations, GC, YJIT, fixing a slow path or high-memory process |
|
|
53
|
+
| `security.md` | Injection (SQL/command/eval), mass assignment, deserialization, XSS/CSRF, authz/IDOR, secrets, Brakeman, dependency audit, ReDoS |
|
|
54
|
+
| `tooling.md` | Bundler, version managers (rbenv/asdf/mise/rv), the `debug` gem/pry, RuboCop/standard, Rake, CI, LSP |
|
|
55
|
+
| `gem-authoring.md` | Building or releasing a gem: gemspec, Zeitwerk, versioning/CHANGELOG, `rake release`, shipping assets |
|
|
56
|
+
|
|
57
|
+
When a task spans areas (e.g. "make this Rails query fast and safe"), load each
|
|
58
|
+
relevant reference. Each reference ends with a `## Quick checklist` you can scan
|
|
59
|
+
for the rules without re-reading the whole file.
|
|
60
|
+
|
|
61
|
+
## How to apply
|
|
62
|
+
|
|
63
|
+
1. Identify the area(s) the task touches and load the matching reference(s) above.
|
|
64
|
+
2. Inspect the actual project (Gemfile, `.rubocop.yml`, existing code) — its
|
|
65
|
+
conventions override the generic defaults here.
|
|
66
|
+
3. Write the change, then verify it: run the tests and the linter before calling
|
|
67
|
+
it done.
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
# Concurrency & parallelism
|
|
2
|
+
|
|
3
|
+
How to pick and use a concurrency model in modern Ruby (MRI/CRuby 3.2–3.4). The single most important fact: **MRI has a Global VM Lock (GVL), so threads give you concurrency for IO-bound work but NOT parallelism for CPU-bound work.** Choose the model from the workload, not from habit.
|
|
4
|
+
|
|
5
|
+
## The GVL/GIL — what it does and does not do
|
|
6
|
+
|
|
7
|
+
The GVL (historically "GIL") ensures only **one thread executes Ruby bytecode at a time** in a single MRI process.
|
|
8
|
+
|
|
9
|
+
- It **does** prevent true parallel execution of pure-Ruby CPU work. Two threads spinning on math run no faster than one.
|
|
10
|
+
- It is **released** during blocking IO (sockets, file reads, `sleep`, many DB driver calls) and during some C-extension sections. So while one thread waits on the network, another runs Ruby. This is why threads help IO-bound workloads.
|
|
11
|
+
- It does **NOT** make your code thread-safe. The GVL can switch threads between bytecode instructions, so `count += 1` (read, add, write) can interleave and lose updates. You still need locks. See "Thread-safety hazards" below.
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
# CPU-bound: threads do NOT help on MRI (GVL serializes the work)
|
|
15
|
+
threads = 4.times.map { Thread.new { fib(35) } } # ~same wall time as serial
|
|
16
|
+
threads.each(&:join)
|
|
17
|
+
|
|
18
|
+
# IO-bound: threads DO help (GVL released during the HTTP wait)
|
|
19
|
+
urls.map { |u| Thread.new { Net::HTTP.get(URI(u)) } }.each(&:join)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
JRuby and TruffleRuby have **no GVL** — threads run truly parallel there, so CPU-bound threading works on those runtimes.
|
|
23
|
+
|
|
24
|
+
## Thread basics
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
t = Thread.new(arg) do |x| # pass args explicitly; do NOT close over a loop var
|
|
28
|
+
do_work(x)
|
|
29
|
+
end
|
|
30
|
+
result = t.value # join + return the block's value (re-raises if it failed)
|
|
31
|
+
t.join # wait without caring about return value
|
|
32
|
+
t.join(5) # returns nil on timeout, else the thread
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
DO pass loop variables as block args; DON'T capture them by closure:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
# WRONG: all threads may see the final value of `i`
|
|
39
|
+
(0..9).each { |i| Thread.new { puts i } }
|
|
40
|
+
|
|
41
|
+
# RIGHT: bind per-thread via block argument
|
|
42
|
+
(0..9).each { |i| Thread.new(i) { |n| puts n } }
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Thread-local / fiber-local storage.** `Thread#[]` is actually *fiber*-local (scoped to the current fiber). Use `Thread#thread_variable_get/set` for true per-thread state.
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
Thread.current[:tag] = "fiber-local" # fiber-scoped (surprising name)
|
|
49
|
+
Thread.current.thread_variable_set(:id, 7) # genuinely thread-scoped
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Exceptions in threads
|
|
53
|
+
|
|
54
|
+
An unhandled exception in a thread is stored and **re-raised when you call `join`/`value`**. If you never join, the exception is silently swallowed.
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
t = Thread.new { raise "boom" }
|
|
58
|
+
sleep 0.1 # main thread keeps running; nothing printed yet
|
|
59
|
+
t.join # NOW it raises "boom"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
DON'T flip `Thread.abort_on_exception = true` globally in libraries (it crashes the whole process on any thread error). For dev visibility prefer `Thread.report_on_exception = true` (default since 2.5) which logs but doesn't abort. Better: always `join` or wrap work in `begin/rescue` and push errors to a `Queue`.
|
|
63
|
+
|
|
64
|
+
## Synchronization primitives
|
|
65
|
+
|
|
66
|
+
### Mutex
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
mutex = Mutex.new
|
|
70
|
+
mutex.synchronize { @balance += amount } # critical section
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
DON'T re-lock the same `Mutex` from the same thread (e.g. recursive call) — it raises `ThreadError` (deadlock). Use `Monitor` for reentrancy.
|
|
74
|
+
|
|
75
|
+
### Monitor (reentrant mutex + condition vars)
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
require "monitor"
|
|
79
|
+
class Counter
|
|
80
|
+
include MonitorMixin # adds #synchronize to instances
|
|
81
|
+
def initialize = (super; @n = 0)
|
|
82
|
+
def incr = synchronize { @n += 1 } # reentrant: safe to call other synchronized methods
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### ConditionVariable — wait/signal
|
|
87
|
+
|
|
88
|
+
Use when a thread must wait for a condition another thread sets. Always re-check the predicate in a loop (guard against spurious wakeups).
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
mutex, cond = Mutex.new, ConditionVariable.new
|
|
92
|
+
ready = false
|
|
93
|
+
|
|
94
|
+
# consumer
|
|
95
|
+
mutex.synchronize { cond.wait(mutex) until ready; consume }
|
|
96
|
+
# producer
|
|
97
|
+
mutex.synchronize { ready = true; cond.signal } # or broadcast for all waiters
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Queue / SizedQueue — the preferred producer-consumer tool
|
|
101
|
+
|
|
102
|
+
`Thread::Queue` is thread-safe and blocking out of the box. Prefer it over hand-rolled Mutex+ConditionVariable.
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
q = Thread::Queue.new # unbounded
|
|
106
|
+
sq = Thread::SizedQueue.new(100) # bounded -> applies backpressure on push
|
|
107
|
+
|
|
108
|
+
# producers
|
|
109
|
+
producers = files.map { |f| Thread.new { sq.push(parse(f)) } }
|
|
110
|
+
|
|
111
|
+
# consumers
|
|
112
|
+
workers = 4.times.map do
|
|
113
|
+
Thread.new do
|
|
114
|
+
while (item = sq.pop) # pop blocks until an item is available
|
|
115
|
+
handle(item)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
producers.each(&:join)
|
|
121
|
+
workers.size.times { sq.push(nil) } # poison pills to stop consumers
|
|
122
|
+
workers.each(&:join)
|
|
123
|
+
# Alt: sq.close then `while (i = sq.pop); ...` exits when closed+drained
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`SizedQueue` is the idiomatic way to bound memory and rate: `push` blocks when full.
|
|
127
|
+
|
|
128
|
+
## concurrent-ruby (`Concurrent::*`)
|
|
129
|
+
|
|
130
|
+
Battle-tested toolkit. Reach for it instead of building pools/atomics yourself.
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
require "concurrent-ruby"
|
|
134
|
+
|
|
135
|
+
# Bounded thread pool (don't spawn unbounded threads)
|
|
136
|
+
pool = Concurrent::FixedThreadPool.new(8)
|
|
137
|
+
pool.post { do_io }
|
|
138
|
+
pool.shutdown; pool.wait_for_termination
|
|
139
|
+
|
|
140
|
+
# Futures (run now, collect later)
|
|
141
|
+
futures = urls.map { |u| Concurrent::Future.execute { Net::HTTP.get(URI(u)) } }
|
|
142
|
+
results = futures.map(&:value) # blocks; check #rejected? / #reason for errors
|
|
143
|
+
|
|
144
|
+
# Thread-safe map (use instead of a plain Hash shared across threads)
|
|
145
|
+
cache = Concurrent::Map.new
|
|
146
|
+
cache.compute_if_absent(key) { expensive(key) } # atomic memoization
|
|
147
|
+
|
|
148
|
+
# Atomic counter (no Mutex needed)
|
|
149
|
+
counter = Concurrent::AtomicFixnum.new(0)
|
|
150
|
+
counter.increment
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Other useful types: `Concurrent::Array`/`Concurrent::Hash` (thread-safe wrappers), `Concurrent::Promises` (composable futures), `Concurrent::TimerTask`, `Concurrent::ThreadLocalVar`.
|
|
154
|
+
|
|
155
|
+
DON'T use raw `Concurrent::Future`/`Promise` without checking for rejection — a failed future returns `nil` from `value` and hides the error in `#reason`.
|
|
156
|
+
|
|
157
|
+
## Thread-safety hazards & patterns
|
|
158
|
+
|
|
159
|
+
### Shared mutable state
|
|
160
|
+
|
|
161
|
+
Any object mutated by multiple threads needs a lock or a thread-safe type. Plain `Hash`, `Array`, and `+=` are NOT atomic.
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
# WRONG: lost updates under contention
|
|
165
|
+
@total += n
|
|
166
|
+
@list << item
|
|
167
|
+
@hash[k] = v
|
|
168
|
+
|
|
169
|
+
# RIGHT: guard with a mutex, or use Concurrent::* types
|
|
170
|
+
@mutex.synchronize { @total += n }
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Check-then-act races
|
|
174
|
+
|
|
175
|
+
`if !exists then create` is two operations; another thread can act in between.
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
# WRONG (TOCTOU)
|
|
179
|
+
@conn = connect unless @conn
|
|
180
|
+
|
|
181
|
+
# RIGHT: atomic compute-if-absent, or lock the whole check+act
|
|
182
|
+
@mutex.synchronize { @conn ||= connect }
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Memoization races
|
|
186
|
+
|
|
187
|
+
`@x ||= compute` is fine for **idempotent, side-effect-free** values where a rare double-compute is harmless. It is NOT safe when `compute` has side effects or must run exactly once.
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
# Risky if compute is expensive/side-effectful: two threads may both run it
|
|
191
|
+
def config = @config ||= load_config
|
|
192
|
+
|
|
193
|
+
# Safe: compute exactly once
|
|
194
|
+
def config
|
|
195
|
+
@mutex.synchronize { @config ||= load_config }
|
|
196
|
+
end
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Pattern: **make objects immutable and share those**. Build state on one thread, `freeze` it, hand the frozen object to others. Frozen + no shared mutation = no locks needed.
|
|
200
|
+
|
|
201
|
+
## Fibers & the Fiber scheduler
|
|
202
|
+
|
|
203
|
+
Fibers are cooperative, lightweight units of execution (no OS thread per fiber). You can have hundreds of thousands. They yield control explicitly.
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
f = Fiber.new { puts "a"; Fiber.yield; puts "b" }
|
|
207
|
+
f.resume # => "a"
|
|
208
|
+
f.resume # => "b"
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Since Ruby 3.0 a **Fiber scheduler** can make blocking IO automatically yield, so thousands of fibers multiplex over a few threads with normal-looking blocking code. You rarely implement the scheduler yourself — use the `async` gem.
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
Fiber.set_scheduler(MyScheduler.new) # what `Async{}` does for you
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## The `async` gem — high-concurrency IO
|
|
218
|
+
|
|
219
|
+
Idiomatic high-level fiber concurrency. Code reads sequentially but runs concurrently; IO automatically suspends the fiber.
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
require "async"
|
|
223
|
+
require "async/http/internet"
|
|
224
|
+
|
|
225
|
+
Async do |task|
|
|
226
|
+
internet = Async::HTTP::Internet.new
|
|
227
|
+
results = urls.map do |u|
|
|
228
|
+
task.async { internet.get(u).read } # each runs concurrently
|
|
229
|
+
end.map(&:wait)
|
|
230
|
+
ensure
|
|
231
|
+
internet&.close
|
|
232
|
+
end
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Bound concurrency with a **Semaphore**; coordinate completion with a **Barrier**:
|
|
236
|
+
|
|
237
|
+
```ruby
|
|
238
|
+
require "async/semaphore"
|
|
239
|
+
require "async/barrier"
|
|
240
|
+
|
|
241
|
+
Async do
|
|
242
|
+
barrier = Async::Barrier.new
|
|
243
|
+
semaphore = Async::Semaphore.new(10, parent: barrier) # max 10 in flight
|
|
244
|
+
|
|
245
|
+
urls.each { |u| semaphore.async { fetch(u) } }
|
|
246
|
+
barrier.wait # wait for all spawned tasks
|
|
247
|
+
end
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
DON'T mix `async`-style fiber concurrency with blocking C-extensions that don't release the GVL or cooperate with the scheduler — they block the whole reactor. Use `async`-aware libraries (e.g. `async-http`, `async-postgres`).
|
|
251
|
+
|
|
252
|
+
## Ractors — true parallelism on MRI (experimental)
|
|
253
|
+
|
|
254
|
+
Ractors run Ruby code **in parallel** (each has its own GVL) by forbidding shared mutable state. As of 3.2–3.4 they are still **experimental** (emit a warning) and many gems aren't Ractor-safe.
|
|
255
|
+
|
|
256
|
+
- Only **shareable** objects cross Ractor boundaries: immutable/frozen objects, `Integer`, `Symbol`, `true/false/nil`, deeply-frozen structures, classes/modules. Check with `Ractor.shareable?(obj)`.
|
|
257
|
+
- Communicate by **message passing**, not shared memory: `send`/`receive` (copy, mailbox) and `yield`/`take` (push/pull).
|
|
258
|
+
|
|
259
|
+
```ruby
|
|
260
|
+
r = Ractor.new do
|
|
261
|
+
msg = Ractor.receive # block until a message arrives
|
|
262
|
+
msg * 2
|
|
263
|
+
end
|
|
264
|
+
r.send(21)
|
|
265
|
+
r.take # => 42 (the block's value)
|
|
266
|
+
|
|
267
|
+
# Parallel map across CPUs
|
|
268
|
+
ractors = inputs.map { |x| Ractor.new(x) { |v| heavy_cpu(v) } }
|
|
269
|
+
results = ractors.map(&:take)
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Limits to know: non-shareable globals/constants raise `IsolationError`; many stdlib/gems aren't Ractor-safe; debugging is harder; the warning is emitted on first use. Treat Ractors as promising for isolated CPU-bound fan-out, not as a drop-in thread replacement yet.
|
|
273
|
+
|
|
274
|
+
## Processes & fork — real parallelism, the safe default for CPU work on MRI
|
|
275
|
+
|
|
276
|
+
Separate processes each have their own GVL, so they run in parallel. The cost is no shared memory (communicate via pipes/IPC) and OS process overhead.
|
|
277
|
+
|
|
278
|
+
```ruby
|
|
279
|
+
pid = Process.fork do
|
|
280
|
+
# child: independent memory (copy-on-write of parent's pages)
|
|
281
|
+
result = heavy_cpu
|
|
282
|
+
# return value is NOT visible to parent — must use IPC
|
|
283
|
+
end
|
|
284
|
+
Process.wait(pid) # reap the child; avoid zombies
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Pass results back over an `IO.pipe` (or use a higher-level tool):
|
|
288
|
+
|
|
289
|
+
```ruby
|
|
290
|
+
reader, writer = IO.pipe
|
|
291
|
+
pid = fork { reader.close; writer.write(Marshal.dump(compute)); writer.close }
|
|
292
|
+
writer.close
|
|
293
|
+
data = Marshal.load(reader.read); reader.close
|
|
294
|
+
Process.wait(pid)
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
`fork` is **not available on Windows** and is fragile with threads (only the forking thread survives in the child) and with open connections/file handles — reconnect DBs/clients in the child.
|
|
298
|
+
|
|
299
|
+
### `parallel` gem — fork made easy
|
|
300
|
+
|
|
301
|
+
```ruby
|
|
302
|
+
require "parallel"
|
|
303
|
+
# CPU-bound: spread across cores with processes
|
|
304
|
+
Parallel.map(items, in_processes: 8) { |i| heavy_cpu(i) }
|
|
305
|
+
# IO-bound: lighter-weight threads
|
|
306
|
+
Parallel.map(items, in_threads: 16) { |i| fetch(i) }
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
`in_processes` serializes args/results via Marshal across the fork boundary — objects must be Marshalable, and side effects in children don't propagate back.
|
|
310
|
+
|
|
311
|
+
## Choosing a model
|
|
312
|
+
|
|
313
|
+
| Workload | Use |
|
|
314
|
+
|---|---|
|
|
315
|
+
| IO-bound, moderate concurrency | Threads + `SizedQueue`, or `Concurrent::FixedThreadPool` |
|
|
316
|
+
| IO-bound, very high concurrency (10k+ sockets) | Fibers via the `async` gem |
|
|
317
|
+
| CPU-bound on MRI | Processes (`fork` / `parallel` gem) or Ractors (if isolatable) |
|
|
318
|
+
| CPU-bound, want shared memory + parallelism | JRuby or TruffleRuby (no GVL) |
|
|
319
|
+
|
|
320
|
+
Rules of thumb:
|
|
321
|
+
- IO-bound -> **threads / async / fibers**.
|
|
322
|
+
- CPU-bound -> **processes / Ractors / JRuby / TruffleRuby**.
|
|
323
|
+
- Always **bound** concurrency (fixed pool, sized queue, semaphore). Unbounded `Thread.new` per item exhausts memory and OS threads.
|
|
324
|
+
|
|
325
|
+
Rails background jobs (ActiveJob/Sidekiq) are a separate, higher-level concern — see references/rails.md. Profiling concurrency for performance — see references/performance.md.
|
|
326
|
+
|
|
327
|
+
## Timeouts — `Timeout` caveats
|
|
328
|
+
|
|
329
|
+
`Timeout.timeout` raises an exception in the target thread at an **arbitrary point**, which can interrupt the middle of a critical section, leave locks held, or corrupt state. Treat it as a last resort.
|
|
330
|
+
|
|
331
|
+
```ruby
|
|
332
|
+
# RISKY: can fire mid-operation, leaving inconsistent state / undefined behavior
|
|
333
|
+
Timeout.timeout(5) { do_complex_thing }
|
|
334
|
+
|
|
335
|
+
# PREFER: native/library timeouts that abort cleanly at safe points
|
|
336
|
+
Net::HTTP.start(host, open_timeout: 5, read_timeout: 5) { ... }
|
|
337
|
+
db.connect(connect_timeout: 5)
|
|
338
|
+
socket.read_nonblock(...) # with IO.select for the deadline
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
If you must use `Timeout`, keep the block tiny, avoid holding mutexes inside it, and never wrap operations that mutate shared state without cleanup.
|
|
342
|
+
|
|
343
|
+
## Quick checklist
|
|
344
|
+
|
|
345
|
+
- MRI GVL: threads help **IO-bound**, never CPU-bound. CPU-bound -> processes/Ractors/JRuby/TruffleRuby.
|
|
346
|
+
- The GVL does **not** make code thread-safe; `+=`, `<<`, `Hash[]=` are not atomic.
|
|
347
|
+
- Always `join`/`value` threads (or push errors to a `Queue`) — unjoined exceptions vanish.
|
|
348
|
+
- Pass loop vars as block args to `Thread.new(x) { |x| }`; don't capture by closure.
|
|
349
|
+
- Prefer `Thread::Queue`/`SizedQueue` for producer-consumer; use `SizedQueue` for backpressure.
|
|
350
|
+
- Use `Concurrent::*` (FixedThreadPool, Future, Map, AtomicFixnum) over hand-rolled primitives.
|
|
351
|
+
- Guard check-then-act and side-effecting memoization with a `Mutex`/`Monitor`; `||=` only for idempotent values.
|
|
352
|
+
- Prefer immutable, `frozen` objects shared across threads to avoid locks entirely.
|
|
353
|
+
- Bound concurrency: fixed pools, sized queues, `Async::Semaphore`. Never unbounded `Thread.new`.
|
|
354
|
+
- High-concurrency IO -> `async` gem (`Async{}`, semaphores, barriers).
|
|
355
|
+
- Ractors are experimental: only shareable/frozen objects cross boundaries; communicate by message passing.
|
|
356
|
+
- `fork`: no Windows, fragile with threads/connections; reconnect clients in the child; reap with `Process.wait`.
|
|
357
|
+
- Avoid `Timeout.timeout` for stateful work — prefer library/socket-level timeouts.
|