rubino-agent 0.3.0 → 0.5.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.
Files changed (196) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +11 -2
  3. data/AGENTS.md +1 -1
  4. data/CHANGELOG.md +172 -5
  5. data/CONTRIBUTING.md +10 -1
  6. data/README.md +14 -5
  7. data/Rakefile +31 -0
  8. data/docs/agents.md +42 -23
  9. data/docs/architecture.md +2 -2
  10. data/docs/commands.md +35 -3
  11. data/docs/configuration.md +20 -23
  12. data/docs/getting-started.md +5 -3
  13. data/docs/security.md +16 -5
  14. data/docs/skills.md +31 -0
  15. data/docs/troubleshooting.md +1 -1
  16. data/exe/rubino +16 -2
  17. data/install.sh +721 -59
  18. data/lib/rubino/active_agent.rb +73 -0
  19. data/lib/rubino/agent/action_claim_guard.rb +881 -0
  20. data/lib/rubino/agent/agent_registry.rb +5 -2
  21. data/lib/rubino/agent/definition.rb +1 -9
  22. data/lib/rubino/agent/fallback_chain.rb +0 -6
  23. data/lib/rubino/agent/iteration_budget.rb +109 -3
  24. data/lib/rubino/agent/loop.rb +476 -20
  25. data/lib/rubino/agent/model_call_runner.rb +81 -3
  26. data/lib/rubino/agent/prompts/build.txt +22 -5
  27. data/lib/rubino/agent/response_validator.rb +8 -0
  28. data/lib/rubino/agent/runner.rb +133 -8
  29. data/lib/rubino/agent/tool_executor.rb +166 -14
  30. data/lib/rubino/agent/truncation_continuation.rb +4 -1
  31. data/lib/rubino/api/server.rb +19 -0
  32. data/lib/rubino/attachments/classify.rb +35 -17
  33. data/lib/rubino/boot/config_guard.rb +71 -0
  34. data/lib/rubino/cli/chat/completion_builder.rb +42 -6
  35. data/lib/rubino/cli/chat/idle_card_host.rb +7 -1
  36. data/lib/rubino/cli/chat/session_resolver.rb +87 -21
  37. data/lib/rubino/cli/chat_command.rb +1189 -50
  38. data/lib/rubino/cli/commands.rb +282 -2
  39. data/lib/rubino/cli/config_command.rb +68 -8
  40. data/lib/rubino/cli/doctor_command.rb +204 -12
  41. data/lib/rubino/cli/jobs_command.rb +12 -0
  42. data/lib/rubino/cli/memory_command.rb +53 -20
  43. data/lib/rubino/cli/onboarding_wizard.rb +79 -6
  44. data/lib/rubino/cli/session_command.rb +172 -18
  45. data/lib/rubino/cli/setup_command.rb +131 -8
  46. data/lib/rubino/cli/skills_command.rb +183 -9
  47. data/lib/rubino/cli/trust_gate.rb +16 -7
  48. data/lib/rubino/commands/built_ins.rb +2 -0
  49. data/lib/rubino/commands/command.rb +12 -2
  50. data/lib/rubino/commands/executor.rb +149 -12
  51. data/lib/rubino/commands/handlers/agent_switch.rb +100 -0
  52. data/lib/rubino/commands/handlers/agents.rb +156 -41
  53. data/lib/rubino/commands/handlers/config.rb +4 -1
  54. data/lib/rubino/commands/handlers/help.rb +113 -14
  55. data/lib/rubino/commands/handlers/memory.rb +15 -5
  56. data/lib/rubino/commands/handlers/sessions.rb +26 -3
  57. data/lib/rubino/commands/handlers/status.rb +9 -4
  58. data/lib/rubino/commands/loader.rb +12 -0
  59. data/lib/rubino/config/configuration.rb +86 -24
  60. data/lib/rubino/config/defaults.rb +140 -33
  61. data/lib/rubino/config/loader.rb +62 -12
  62. data/lib/rubino/config/validator.rb +341 -0
  63. data/lib/rubino/config/writer.rb +123 -31
  64. data/lib/rubino/context/compressor.rb +184 -22
  65. data/lib/rubino/context/environment_inspector.rb +2 -2
  66. data/lib/rubino/context/file_discovery.rb +2 -2
  67. data/lib/rubino/context/message_boundary.rb +27 -1
  68. data/lib/rubino/context/project_languages.rb +90 -0
  69. data/lib/rubino/context/prompt_assembler.rb +105 -22
  70. data/lib/rubino/context/summary_builder.rb +45 -4
  71. data/lib/rubino/context/token_budget.rb +36 -11
  72. data/lib/rubino/context/token_estimate.rb +45 -0
  73. data/lib/rubino/context/tool_result_pruner.rb +81 -0
  74. data/lib/rubino/database/connection.rb +154 -3
  75. data/lib/rubino/database/migrations/001_create_initial_schema.rb +314 -40
  76. data/lib/rubino/database/migrator.rb +98 -5
  77. data/lib/rubino/documents/cap_exceeded.rb +13 -0
  78. data/lib/rubino/documents/converters/csv.rb +4 -3
  79. data/lib/rubino/documents/converters/docx.rb +29 -5
  80. data/lib/rubino/documents/converters/html.rb +5 -1
  81. data/lib/rubino/documents/converters/json.rb +2 -1
  82. data/lib/rubino/documents/converters/pdf.rb +11 -2
  83. data/lib/rubino/documents/converters/plain.rb +2 -1
  84. data/lib/rubino/documents/converters/pptx.rb +11 -2
  85. data/lib/rubino/documents/converters/xlsx.rb +35 -4
  86. data/lib/rubino/documents/converters/xml.rb +2 -1
  87. data/lib/rubino/documents/limits.rb +210 -0
  88. data/lib/rubino/documents.rb +10 -3
  89. data/lib/rubino/errors.rb +36 -5
  90. data/lib/rubino/interaction/cancel_token.rb +19 -3
  91. data/lib/rubino/interaction/events.rb +13 -0
  92. data/lib/rubino/interaction/lifecycle.rb +99 -13
  93. data/lib/rubino/interaction/polishing.rb +176 -0
  94. data/lib/rubino/jobs/cron_job_repository.rb +5 -8
  95. data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +11 -0
  96. data/lib/rubino/jobs/handlers/distill_skill_job.rb +65 -9
  97. data/lib/rubino/jobs/queue.rb +63 -8
  98. data/lib/rubino/jobs/runner.rb +24 -6
  99. data/lib/rubino/jobs/worker.rb +0 -4
  100. data/lib/rubino/llm/adapter_response.rb +47 -4
  101. data/lib/rubino/llm/credential_check.rb +15 -16
  102. data/lib/rubino/llm/error_classifier.rb +89 -1
  103. data/lib/rubino/llm/inline_think_filter.rb +69 -12
  104. data/lib/rubino/llm/request.rb +30 -3
  105. data/lib/rubino/llm/ruby_llm_adapter.rb +394 -46
  106. data/lib/rubino/llm/tool_bridge.rb +113 -9
  107. data/lib/rubino/mcp/manager.rb +18 -1
  108. data/lib/rubino/mcp/mcp_tool_wrapper.rb +14 -3
  109. data/lib/rubino/memory/aux_retry.rb +107 -0
  110. data/lib/rubino/memory/backends/sqlite.rb +73 -44
  111. data/lib/rubino/memory/backends.rb +23 -7
  112. data/lib/rubino/memory/salience_gate.rb +103 -0
  113. data/lib/rubino/memory/sqlite_extraction.rb +70 -0
  114. data/lib/rubino/memory/sqlite_extraction_prompt.rb +11 -0
  115. data/lib/rubino/memory/store.rb +33 -5
  116. data/lib/rubino/memory/threat_scanner.rb +52 -0
  117. data/lib/rubino/output/cost.rb +52 -0
  118. data/lib/rubino/output/headless_block_latch.rb +53 -0
  119. data/lib/rubino/output/result_serializer.rb +222 -0
  120. data/lib/rubino/output/turn_recorder.rb +77 -0
  121. data/lib/rubino/security/approval_policy.rb +227 -32
  122. data/lib/rubino/security/command_allowlist.rb +79 -4
  123. data/lib/rubino/security/doom_loop_detector.rb +21 -2
  124. data/lib/rubino/security/hardline_guard.rb +189 -16
  125. data/lib/rubino/security/pattern_matcher.rb +28 -5
  126. data/lib/rubino/security/prefix_deriver.rb +25 -6
  127. data/lib/rubino/security/readonly_commands.rb +145 -5
  128. data/lib/rubino/security/secret_path.rb +134 -0
  129. data/lib/rubino/security/url_safety.rb +255 -0
  130. data/lib/rubino/session/repository.rb +212 -11
  131. data/lib/rubino/session/store.rb +139 -14
  132. data/lib/rubino/skills/installer.rb +230 -0
  133. data/lib/rubino/skills/prompt_index.rb +2 -2
  134. data/lib/rubino/skills/registry.rb +52 -1
  135. data/lib/rubino/skills/skill.rb +64 -3
  136. data/lib/rubino/skills/skill_tool.rb +16 -5
  137. data/lib/rubino/tools/background_tasks.rb +157 -13
  138. data/lib/rubino/tools/base.rb +204 -3
  139. data/lib/rubino/tools/edit_tool.rb +73 -18
  140. data/lib/rubino/tools/glob_tool.rb +48 -9
  141. data/lib/rubino/tools/grep_tool.rb +103 -9
  142. data/lib/rubino/tools/multi_edit_tool.rb +64 -9
  143. data/lib/rubino/tools/patch_tool.rb +5 -0
  144. data/lib/rubino/tools/read_attachment_tool.rb +3 -1
  145. data/lib/rubino/tools/read_tool.rb +33 -15
  146. data/lib/rubino/tools/read_tracker.rb +153 -35
  147. data/lib/rubino/tools/registry.rb +113 -12
  148. data/lib/rubino/tools/result.rb +9 -1
  149. data/lib/rubino/tools/ruby_tool.rb +0 -0
  150. data/lib/rubino/tools/shell_registry.rb +70 -0
  151. data/lib/rubino/tools/shell_tool.rb +40 -1
  152. data/lib/rubino/tools/summarize_file_tool.rb +6 -0
  153. data/lib/rubino/tools/task_stop_tool.rb +10 -16
  154. data/lib/rubino/tools/task_tool.rb +36 -8
  155. data/lib/rubino/tools/vision_tool.rb +5 -0
  156. data/lib/rubino/tools/webfetch_tool.rb +39 -7
  157. data/lib/rubino/tools/websearch_tool.rb +92 -30
  158. data/lib/rubino/tools/write_tool.rb +23 -4
  159. data/lib/rubino/ui/api.rb +10 -1
  160. data/lib/rubino/ui/base.rb +11 -0
  161. data/lib/rubino/ui/bottom_composer.rb +382 -74
  162. data/lib/rubino/ui/cli.rb +515 -83
  163. data/lib/rubino/ui/completion_menu.rb +11 -7
  164. data/lib/rubino/ui/headless_trace.rb +63 -0
  165. data/lib/rubino/ui/live_region.rb +70 -7
  166. data/lib/rubino/ui/markdown_renderer.rb +142 -7
  167. data/lib/rubino/ui/notifier.rb +0 -2
  168. data/lib/rubino/ui/null.rb +52 -5
  169. data/lib/rubino/ui/paste_store.rb +16 -2
  170. data/lib/rubino/ui/queued_indicators.rb +6 -1
  171. data/lib/rubino/ui/status_bar.rb +61 -7
  172. data/lib/rubino/ui/streaming_markdown.rb +59 -6
  173. data/lib/rubino/ui/subagent_view.rb +29 -4
  174. data/lib/rubino/ui/tool_label.rb +52 -0
  175. data/lib/rubino/update_check.rb +39 -4
  176. data/lib/rubino/util/atomic_file.rb +117 -0
  177. data/lib/rubino/util/ignore_rules.rb +120 -0
  178. data/lib/rubino/util/output.rb +229 -12
  179. data/lib/rubino/util/secrets_mask.rb +70 -7
  180. data/lib/rubino/util/spill_store.rb +153 -0
  181. data/lib/rubino/version.rb +1 -1
  182. data/lib/rubino/workspace.rb +9 -1
  183. data/lib/rubino.rb +191 -7
  184. data/rubino-agent.gemspec +1 -0
  185. data/skills/ruby-expert/SKILL.md +1 -0
  186. metadata +42 -12
  187. data/lib/rubino/agent/router.rb +0 -65
  188. data/lib/rubino/database/migrations/002_create_runs.rb +0 -45
  189. data/lib/rubino/database/migrations/003_create_skill_states.rb +0 -15
  190. data/lib/rubino/database/migrations/004_create_cron_jobs.rb +0 -36
  191. data/lib/rubino/database/migrations/005_create_oauth_connections.rb +0 -27
  192. data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +0 -34
  193. data/lib/rubino/database/migrations/007_create_messages_fts.rb +0 -59
  194. data/lib/rubino/database/migrations/008_create_memory_facts.rb +0 -75
  195. data/lib/rubino/database/migrations/009_create_memory_graph.rb +0 -55
  196. data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +0 -20
@@ -73,6 +73,12 @@ module Rubino
73
73
  # error can't bleed into the empty-retry count.
74
74
  error_attempts = 0
75
75
 
76
+ # Cumulative error-path backoff already spent on THIS call (seconds), the
77
+ # denominator of the TOTAL-wall-time cap (#dead-host). Reset per call! so
78
+ # a fresh turn gets the full retry budget. A fallback rotation also zeroes
79
+ # it (handle_error! → activate_fallback!) so the new adapter starts clean.
80
+ @error_retry_spent = 0.0
81
+
76
82
  # The degenerate-response recovery ladder (Slice 5). Fresh per call! so
77
83
  # its per-turn counters (prefill ≤2, empty ≤3) reset exactly where the
78
84
  # reference zeroes them on a successful content turn.
@@ -237,7 +243,10 @@ module Rubino
237
243
  thinking: request.thinking,
238
244
  prefill: seed,
239
245
  image_paths: request.image_paths,
240
- stream: request.stream?
246
+ stream: request.stream?,
247
+ on_intermediate_message: request.on_intermediate_message,
248
+ on_round_trip: request.on_round_trip,
249
+ budget_exhausted: request.budget_exhausted
241
250
  )
242
251
  end
243
252
 
@@ -281,13 +290,29 @@ module Rubino
281
290
  classified = LLM::ErrorClassifier.classify(error)
282
291
 
283
292
  unless classified.retryable && attempts < api_max_retries
284
- return 0 if activate_fallback!(iteration)
293
+ return reset_error_budget! if activate_fallback!(iteration)
285
294
 
286
295
  raise_with_auth_hint(error, classified)
287
296
  end
288
297
 
289
298
  attempts += 1
290
299
  wait = error_backoff(attempts, classified, error)
300
+
301
+ # TOTAL wall-time cap (#dead-host). A permanently-unreachable host fails
302
+ # with a RETRYABLE connection timeout every attempt, so the count budget
303
+ # alone lets backoff stack to ~75-110s before giving up. Once the backoff
304
+ # already spent PLUS this next planned wait would cross the budget, stop
305
+ # retrying: try a fallback first (resets the budget for the new adapter),
306
+ # otherwise fail fast with a clear "gave up after ~Ns" message. This
307
+ # bounds the dead-host wall WITHOUT cutting genuine recovery inside the
308
+ # window — a transient blip that clears before the budget still retries.
309
+ if exceeds_total_budget?(wait)
310
+ return reset_error_budget! if activate_fallback!(iteration)
311
+
312
+ raise_retry_budget_exhausted!(error, attempts)
313
+ end
314
+
315
+ @error_retry_spent += wait
291
316
  @event_bus.emit(Interaction::Events::MODEL_CALL_STARTED,
292
317
  iteration: iteration, error_retry: attempts)
293
318
  log_safely(event: "llm.retry", attempt: attempts, sleep: wait, error: error.message)
@@ -295,6 +320,37 @@ module Rubino
295
320
  attempts
296
321
  end
297
322
 
323
+ # A fallback rotation gives the NEW adapter a fresh count budget AND a fresh
324
+ # wall-time budget — the time spent on the dead primary shouldn't penalise a
325
+ # healthy fallback. Zero the spent-clock and return 0 (the loop's reset
326
+ # sentinel for error_attempts).
327
+ def reset_error_budget!
328
+ @error_retry_spent = 0.0
329
+ 0
330
+ end
331
+
332
+ # True when the cumulative error-path backoff already spent plus the next
333
+ # planned wait would cross the total wall-time budget. nil budget ⇒ no total
334
+ # cap (count-based retries only — the pre-cap behaviour).
335
+ def exceeds_total_budget?(next_wait)
336
+ budget = retry_total_timeout
337
+ return false if budget.nil?
338
+
339
+ (@error_retry_spent + next_wait) > budget
340
+ end
341
+
342
+ # Fail fast on the dead-host path: the host keeps timing out and the
343
+ # wall-time budget is spent, so surface a clear, actionable message instead
344
+ # of stalling for another full backoff. Preserves the original error and
345
+ # the auth-hint upgrade for the (rare) auth-shaped retryable case.
346
+ def raise_retry_budget_exhausted!(error, attempts)
347
+ spent = @error_retry_spent.round(1)
348
+ raise Rubino::Error,
349
+ "Gave up after ~#{spent}s and #{attempts - 1} retries: the provider host is " \
350
+ "unreachable or persistently failing (#{error.message}). Check the model's " \
351
+ "base_url / network, or configure a fallback model."
352
+ end
353
+
298
354
  # Jittered backoff for an invalid/empty response — 5s base, 120s cap,
299
355
  # via the INVALID_RESPONSE preset.
300
356
  def empty_backoff(attempt)
@@ -311,9 +367,14 @@ module Rubino
311
367
  retry_after: retry_after_for(classified, error))
312
368
  end
313
369
 
370
+ # The per-retry backoff CEILING for this error. The non-overload path now
371
+ # honours the (previously dead) api_retry_backoff_cap_seconds knob (16s)
372
+ # instead of the hardcoded 60s ERROR_PATH ceiling — capping the worst
373
+ # single wait to ~24s. Overload/unknown still ride the higher overload cap
374
+ # so a 529 backs off long enough to clear the hot window.
314
375
  def error_backoff_cap(classified)
315
376
  overload = [LLM::FailoverReason::OVERLOADED, LLM::FailoverReason::UNKNOWN]
316
- base = BackoffPolicy::ERROR_PATH[:max]
377
+ base = backoff_cap
317
378
  overload.include?(classified.reason) ? [base, overload_backoff_cap].max : base
318
379
  end
319
380
 
@@ -373,6 +434,23 @@ module Rubino
373
434
  @config.dig("agent", "api_retry_backoff_overload_cap_seconds") || 60
374
435
  end
375
436
 
437
+ # Per-retry backoff ceiling for the ordinary error path (non-overload). The
438
+ # ERROR_PATH preset max is the fallback when the knob is unset.
439
+ def backoff_cap
440
+ @config.dig("agent", "api_retry_backoff_cap_seconds") || BackoffPolicy::ERROR_PATH[:max]
441
+ end
442
+
443
+ # Total error-path retry wall-time budget (seconds), or nil for no total
444
+ # cap. A non-positive value is treated as nil (no cap) rather than an
445
+ # instant give-up — a 0 here is almost certainly a misconfig.
446
+ def retry_total_timeout
447
+ raw = @config.dig("agent", "api_retry_total_timeout_seconds")
448
+ return nil if raw.nil?
449
+
450
+ n = Float(raw, exception: false)
451
+ n if n&.positive?
452
+ end
453
+
376
454
  def log_safely(**fields)
377
455
  Rubino.logger.warn(**fields)
378
456
  rescue StandardError
@@ -1,7 +1,11 @@
1
1
  [Identity]
2
- You are rubino, a software engineering assistant running in the user's
3
- real environment. You read, edit, and run code with actual tool access —
4
- treat the file system, git, and the shell as production by default.
2
+ You are rubino, a general-purpose software engineering assistant running in
3
+ the user's real environment. You read, edit, and run code with actual tool
4
+ access — treat the file system, git, and the shell as production by default.
5
+ You are not tied to any one language or framework: work in whatever language
6
+ and stack the project in front of you uses (Python, JavaScript, Go, Ruby,
7
+ Rust, …). Detect the project's language from its files and conventions; never
8
+ assume or default to one.
5
9
 
6
10
  [Principles]
7
11
  - Smallest change that solves the task. No speculative refactors, no
@@ -27,8 +31,10 @@ treat the file system, git, and the shell as production by default.
27
31
  map-reduces the file in a separate context and returns only the summary,
28
32
  so the raw text never fills this conversation. Reach for `read` (with
29
33
  offset/limit) or `grep` only when you need exact lines, not an overview.
30
- - For arbitrary code execution prefer `ruby` (sandboxed eval) over
31
- `shell`. Use `shell` for binaries the host already provides.
34
+ - The `ruby` tool runs sandboxed Ruby for quick computation/scripting —
35
+ reach for it when Ruby fits the project. Otherwise use `shell` for the
36
+ host's binaries and the project's own toolchain (its interpreter, package
37
+ manager, test runner). Match the project's language; don't default to Ruby.
32
38
  - When multiple tool calls are independent (no data dependency), issue
33
39
  them in parallel — one message with several tool uses.
34
40
  - Cite files as `path/to/file.rb:42` so the user can jump straight to the
@@ -43,6 +49,17 @@ treat the file system, git, and the shell as production by default.
43
49
  several background subagents at once; check one early with `task_result` or
44
50
  stop it with `task_stop`. Pass `background: false` only when the very next
45
51
  step depends on the subagent's output.
52
+ - A BACKGROUND subagent runs ASYNCHRONOUSLY: there is nothing to "wait" for and
53
+ no result to poll for now — the user is notified and you receive the
54
+ `[background-task]` result automatically when it finishes. So do not narrate
55
+ waiting (no "I'll wait for it", no "task_result isn't available yet, nothing I
56
+ can do"); just finish your current reply or move on to other work. If you
57
+ genuinely cannot proceed without the answer this step, use `background: false`
58
+ instead of waiting.
59
+ - Subagents are FULLY capable: the general subagent has every tool available,
60
+ including `task` (it can spawn its own subagents) and `ask_parent` (it can ask
61
+ YOU a clarifying question mid-task). Never decline to delegate by claiming a
62
+ subagent lacks `task`/`ask_parent` or can't reach the parent — that is false.
46
63
 
47
64
  [Safety]
48
65
  - Destructive shell commands (`rm -rf`, `git push --force`, `git reset
@@ -27,6 +27,13 @@ module Rubino
27
27
  def valid?(response)
28
28
  return [false, :nil_response] if response.nil?
29
29
  return [false, :interrupted] if response.interrupted?
30
+ # A budget-Halt response (#355a) is a deliberate turn-ending signal — the
31
+ # streaming round-trip loop was cut short because the iteration/time
32
+ # budget ran out, not a model failure. It may legitimately carry no
33
+ # content (no preamble streamed), so it must NOT be judged empty and sent
34
+ # through the recovery ladder; the Loop reads #halted? and runs its
35
+ # budget-exhausted summary.
36
+ return [true, nil] if response.respond_to?(:halted?) && response.halted?
30
37
  return [true, nil] if response.has_tool_calls?
31
38
  return [false, :empty_response] if response.content.to_s.strip.empty?
32
39
 
@@ -41,6 +48,7 @@ module Rubino
41
48
  # Tool-call responses are never degenerate — the tool call IS the answer.
42
49
  def degenerate?(response)
43
50
  return false if response.nil? || response.interrupted?
51
+ return false if response.respond_to?(:halted?) && response.halted?
44
52
  return false if response.has_tool_calls?
45
53
 
46
54
  !content_after_think_block?(response.content)
@@ -13,7 +13,7 @@ module Rubino
13
13
 
14
14
  def initialize(session_id: nil, model_override: nil, provider_override: nil,
15
15
  max_turns: nil, ignore_rules: false, ui: nil, agent_definition: nil,
16
- event_bus: nil, announce_session: true)
16
+ event_bus: nil, announce_session: true, session_source: "cli")
17
17
  @ui = ui || Rubino.ui
18
18
  # An in-chat rewind/fork builds a runner on the child session but has its
19
19
  # own purpose-built "┄ rewound to message N — editing ┄" marker, so the
@@ -33,14 +33,32 @@ module Rubino
33
33
  @max_turns = max_turns
34
34
  @ignore_rules = ignore_rules
35
35
  @agent_definition = agent_definition
36
+ # The `source` stamped on a freshly-created session row. Defaults to
37
+ # "cli" (a user-driven REPL/one-shot session); the `task` tool passes
38
+ # "subagent" so internal subagent prompt-sessions can be filtered out of
39
+ # the user-facing /sessions picker + `sessions list` (they're machinery,
40
+ # not the user's own conversations) while staying resumable by explicit
41
+ # id. Like Claude Code hiding its Task subagent sessions from the picker.
42
+ @session_source = session_source
36
43
  # Pre-instantiate so cancel! is meaningful between turns and during the
37
44
  # window between Signal.trap install and run() — a too-early Ctrl+C
38
45
  # used to land on a nil token and silently no-op, then the next run
39
46
  # started fresh and the user's cancel was lost.
40
47
  @cancel_token = Interaction::CancelToken.new
48
+ # Detached post-turn polishing worker (#319): owns the background thread
49
+ # that drains memory-extract / skill-distill / summarize OFF the live
50
+ # turn so the next prompt is never gated, and is cancellable via Esc.
51
+ # Reused across this runner's turns so #running? / #cancel! address the
52
+ # CURRENT polishing run (coalescing rapid turns).
53
+ @polishing = Interaction::Polishing.new(config: @config)
41
54
  @session = load_or_create_session(session_id)
42
55
  end
43
56
 
57
+ # The detached post-turn polishing worker, so the CLI can show the
58
+ # non-blocking "polishing… (Esc to skip)" indicator while it runs and
59
+ # extend the single Esc/cancel path to it (#319).
60
+ attr_reader :polishing
61
+
44
62
  # Executes a full interaction turn, swallowing failures so CLI callers
45
63
  # can stay in the REPL after a model/tool error. The friendly UI
46
64
  # message is emitted, but the bus event INTERACTION_FAILED is NOT
@@ -89,18 +107,68 @@ module Rubino
89
107
  cancel_token: @cancel_token,
90
108
  model_override: @explicit_model_override,
91
109
  provider_override: @provider_override,
92
- max_tool_iterations: @max_turns
110
+ max_tool_iterations: @max_turns,
111
+ polishing: @polishing
93
112
  )
94
113
 
95
- lifecycle.execute(input, image_paths: image_paths, input_queue: input_queue,
96
- paste_expansions: paste_expansions)
114
+ response = lifecycle.execute(input, image_paths: image_paths, input_queue: input_queue,
115
+ paste_expansions: paste_expansions)
116
+
117
+ # Adopt an automatic-compaction swap so the NEXT turn runs on the (small)
118
+ # compaction child, not the dead parent (P3 F1). When #check_and_compact
119
+ # fires, it reassigns the lifecycle's session to the child; without
120
+ # picking that up here the Runner would rebuild every subsequent turn's
121
+ # Lifecycle on the un-shrunk parent → re-compact every turn (superlinear
122
+ # DB/context bloat + ~2.9x slowdown). This is the automatic-path
123
+ # counterpart to the manual /compact swap (chat_command rebuilds the
124
+ # runner on result[:compact_into]).
125
+ @session = lifecycle.active_session
126
+
127
+ response
128
+ end
129
+
130
+ # Pins the agent Definition this runner threads into every subsequent turn
131
+ # (the sticky `/agent <name>` / Tab-cycle switch). Lifecycle reads
132
+ # @agent_definition fresh on each #run!, so swapping it here takes effect
133
+ # from the NEXT turn — the agent's system prompt and tool scope come along.
134
+ # nil restores the default (build) persona. The reader feeds the CLI
135
+ # status bar and a one-shot route that wants to restore it afterwards.
136
+ attr_accessor :agent_definition
137
+
138
+ # Runs ONE turn under +definition+ (a one-shot `/<name> <message>` route)
139
+ # without disturbing the runner's sticky agent. The override is swapped in
140
+ # for the single #run and restored in the ensure, so the next idle prompt
141
+ # is back on whatever the user had pinned.
142
+ def run_with_agent(definition, input, **)
143
+ sticky = @agent_definition
144
+ @agent_definition = definition
145
+ run(input, **)
146
+ ensure
147
+ @agent_definition = sticky
97
148
  end
98
149
 
99
150
  # Flips the current turn's cancel token. Called from the UI thread when
100
151
  # the user hits Esc or a second Ctrl+C while the worker is mid-stream.
101
152
  # No-op when no turn is in flight.
102
- def cancel!
103
- @cancel_token&.cancel!
153
+ #
154
+ # ONE Esc cancels whatever is in flight (#319): the FOREGROUND turn OR the
155
+ # DETACHED post-turn polishing. Flipping both tokens is safe — a token is
156
+ # one-shot and idle-when-untouched, so cancelling the not-running side is a
157
+ # harmless no-op. The polishing worker stops between jobs and its aux
158
+ # retry/backoff aborts mid-wait, leaving partial work in place.
159
+ # +reason+ records WHY the turn was cancelled so the result label stays
160
+ # truthful: :user (Esc/Ctrl+C, default) vs :external (SIGTERM/SIGHUP
161
+ # teardown). Plumbed through to the CancelToken / Interrupted (#361b).
162
+ def cancel!(reason: :user)
163
+ @cancel_token&.cancel!(reason: reason)
164
+ @polishing&.cancel!
165
+ end
166
+
167
+ # True while the detached post-turn polishing is still draining — drives
168
+ # the non-blocking "polishing… (Esc to skip)" indicator the CLI shows
169
+ # without owning the input.
170
+ def polishing?
171
+ @polishing&.running? || false
104
172
  end
105
173
 
106
174
  # Switches the LIVE model for this runner (the in-chat `/model <name>`).
@@ -136,6 +204,11 @@ module Rubino
136
204
  @session_repo.end_session!(@session[:id])
137
205
  rescue StandardError
138
206
  nil
207
+ ensure
208
+ # Let any in-flight detached polishing settle (bounded) so a clean
209
+ # teardown doesn't abandon a half-written extraction (#319). Best-effort:
210
+ # the cursor re-feeds anything unfinished next session anyway.
211
+ @polishing&.wait(3)
139
212
  end
140
213
 
141
214
  private
@@ -170,9 +243,31 @@ module Rubino
170
243
  "Try `rubino sessions list`, or resume by id prefix."
171
244
  end
172
245
 
246
+ # Owner-guard on EXPLICIT resume (#347): auto-resume already skips a
247
+ # session a DIFFERENT live process is actively writing, but explicit
248
+ # `--resume <id>` / `-s <id>` had NO guard — N processes could latch
249
+ # the same "active" row and interleave writes into one malformed
250
+ # transcript (user user user … assistant), poisoning the next resume's
251
+ # history. When the target is live-owned by another process, fork a
252
+ # fresh child that inherits the full history instead of stomping the
253
+ # live session; the user keeps their context and the two writers never
254
+ # interleave.
255
+ # ATOMICALLY claim the row for THIS process (#390/residual #376).
256
+ # The old code checked `owned_by_other_live_process?` then later
257
+ # stamped owner_pid — a TOCTOU window where two concurrent
258
+ # `--resume <id>` both read the same dead owner_pid, both passed the
259
+ # check, and both stamped+wrote the live row (user,user … interleave).
260
+ # claim_for_resume! folds the check and stamp into one compare-and-swap
261
+ # (same idiom as Jobs::Queue#claim!): exactly one racer wins, the
262
+ # loser gets false and forks a fresh child off the busy parent.
263
+ return fork_busy_session(session) unless @session_repo.claim_for_resume!(session)
264
+
173
265
  # An existing row is already in the DB; mark it so the lazy-persist
174
- # path (#144) treats it as persisted and never re-inserts.
266
+ # path (#144) treats it as persisted and never re-inserts. We now own
267
+ # owner_pid (stamped atomically above) so a later concurrent resume
268
+ # sees us as the live owner and forks rather than interleaving.
175
269
  session[:persisted] = true
270
+ session[:owner_pid] = Process.pid
176
271
  @ui.status("Resuming session: #{session[:id][0..7]}...") if @announce_session
177
272
  session
178
273
  else
@@ -182,7 +277,7 @@ module Rubino
182
277
  # record carries a real id so the whole turn pipeline works unchanged;
183
278
  # Lifecycle#persist_user_message flips it to a real row on demand.
184
279
  session = @session_repo.build(
185
- source: "cli",
280
+ source: @session_source,
186
281
  model: @model_id,
187
282
  provider: @provider_override || LLM::ProviderResolver.resolve(@model_id)
188
283
  )
@@ -190,6 +285,36 @@ module Rubino
190
285
  session
191
286
  end
192
287
  end
288
+
289
+ # Forks a child session off a parent another live process is still writing
290
+ # (#347), copying the parent's full history so the explicit-resume user
291
+ # keeps their context, while writing to a SEPARATE row so the two writers
292
+ # never interleave into one malformed transcript. The child is owned by
293
+ # THIS process. Mirrors the /branch copy (history + extraction watermark +
294
+ # message_count sync) without a probe seed.
295
+ def fork_busy_session(parent)
296
+ store = @message_store
297
+ child = @session_repo.create(
298
+ source: "cli",
299
+ model: parent[:model] || @model_id,
300
+ provider: parent[:provider] || @provider_override,
301
+ title: parent[:title],
302
+ parent_session_id: parent[:id],
303
+ cwd: parent[:cwd]
304
+ )
305
+ store.copy_into(child[:id], store.for_session(parent[:id]))
306
+ store.seed_extraction_cursor(child[:id])
307
+ @session_repo.update(child[:id], message_count: store.count(child[:id]))
308
+
309
+ if @announce_session
310
+ @ui.status(
311
+ "Session #{parent[:id][0..7]} is in use by another rubino — " \
312
+ "forked a copy: #{child[:id][0..7]}"
313
+ )
314
+ end
315
+ child[:persisted] = true
316
+ child
317
+ end
193
318
  end
194
319
  end
195
320
  end