Class: Rubino::Interaction::Polishing
- Inherits:
-
Object
- Object
- Rubino::Interaction::Polishing
- Defined in:
- lib/rubino/interaction/polishing.rb
Overview
Runs the best-effort post-turn “polishing” (memory-extract / skill-distill / summarize) on a DETACHED background thread so it NEVER gates the next prompt (#319).
Before this, the post-turn jobs drained INLINE inside the live turn (Jobs::Queue#enqueue → Runner#run_job, synchronously), so ‘runner.run` didn’t return — and the REPL couldn’t read the next input — until the aux work finished. A 429 storm running the bounded retry-with-backoff (Memory::AuxRetry, honouring Retry-After) could hold the user hostage for ~80s. No industry agent does this: Claude Code runs resume/recap as background jobs, Cursor indexes async, aider offloads to a weak model.
This object owns ONE managed worker thread that:
* captures the live turn's UI + EventBus (both thread-local in Rubino)
and re-binds them inside the worker so the dim "polishing…" status row
and the "✓ saved to memory" note still surface on the right adapter;
* binds its CancelToken as Rubino.aux_cancel_token so the aux retry loop
can poll it and abort mid-backoff the instant the user presses Esc;
* drains the queued job rows via Jobs::Runner#run_job (each job is
failure-isolated and terminal in inline mode);
* on cancel, stops between jobs and leaves whatever already landed —
the per-session extraction cursor advances only over completed work,
so a cancelled/deferred turn is simply re-fed next time (best-effort,
nothing lost, #249/#298).
Coalescing (#319.4): #start is a no-op while a previous polishing run is still in flight — the older detached job is idempotent (it writes to the memory/skills store the NEXT turn never reads back), so a rapid burst of turns doesn’t stack N concurrent extraction passes; the queued rows the newer turns enqueue are picked up by the still-running drain (it re-scans the queue) or by the next polishing run, whichever fires first.
Instance Method Summary collapse
-
#cancel! ⇒ Object
Cancel the in-flight polishing (the single Esc / Ctrl+C path extends to here).
-
#initialize(config: nil) ⇒ Polishing
constructor
A new instance of Polishing.
-
#running? ⇒ Boolean
True while the detached worker is alive.
-
#start(ui:, event_bus:) ⇒ Object
Kick off a detached drain of the queued post-turn job rows.
-
#wait(timeout = nil) ⇒ Object
Block until the current polishing run finishes (or the timeout, if any, elapses).
Constructor Details
#initialize(config: nil) ⇒ Polishing
Returns a new instance of Polishing.
37 38 39 40 41 42 |
# File 'lib/rubino/interaction/polishing.rb', line 37 def initialize(config: nil) @config = config || Rubino.configuration @mutex = Mutex.new @thread = nil @cancel_token = nil end |
Instance Method Details
#cancel! ⇒ Object
Cancel the in-flight polishing (the single Esc / Ctrl+C path extends to here). Best-effort: flips the token so the worker stops between jobs and the aux retry loop aborts mid-backoff. Leaves partial work in place.
66 67 68 |
# File 'lib/rubino/interaction/polishing.rb', line 66 def cancel! @cancel_token&.cancel! end |
#running? ⇒ Boolean
True while the detached worker is alive. Drives the non-blocking “polishing… (Esc to skip)” indicator: the REPL shows it while this is true and clears it on completion.
73 74 75 |
# File 'lib/rubino/interaction/polishing.rb', line 73 def running? @mutex.synchronize { running_unsynced? } end |
#start(ui:, event_bus:) ⇒ Object
Kick off a detached drain of the queued post-turn job rows. Returns immediately. ui / event_bus are the live turn’s adapters, captured so the worker re-binds them (they’re thread-local). No-op when a prior polishing run is still alive (coalesce) or there’s nothing to do.
48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
# File 'lib/rubino/interaction/polishing.rb', line 48 def start(ui:, event_bus:) @mutex.synchronize do # Coalesce: a previous detached drain is still working. Leave it — # its writes are idempotent and the next turn doesn't read them back, # and it will sweep any rows the newer turns just enqueued. return if running_unsynced? token = CancelToken.new @cancel_token = token @thread = Thread.new { run(token, ui, event_bus) } @thread.name = "rubino-polishing" if @thread.respond_to?(:name=) end nil end |
#wait(timeout = nil) ⇒ Object
Block until the current polishing run finishes (or the timeout, if any, elapses). Used on a clean session teardown so a half-written extraction isn’t abandoned, and by specs. nil timeout ⇒ wait indefinitely.
80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 |
# File 'lib/rubino/interaction/polishing.rb', line 80 def wait(timeout = nil) thread = @mutex.synchronize { @thread } thread&.join(timeout) nil rescue Exception # rubocop:disable Lint/RescueException # A stray Ctrl+C (Interrupt/SignalException) landing WHILE this join runs # used to escape end_session!'s `ensure` as a raw backtrace — the # surrounding `rescue StandardError` does not catch a SignalException, so # a teardown-time interrupt dumped polishing.rb→runner.rb→chat_command.rb # over a clean exit (TUI-1). Thread#join is the only interrupt-prone call # on the teardown path; swallowing the signal here keeps the join # bounded-and-best-effort (the polishing worker is detached and its # partial work re-feeds next session anyway), so a teardown Ctrl+C exits # cleanly via "Session ended." like Claude Code / Codex. nil end |