Class: Rubino::Interaction::Polishing

Inherits:
Object
  • Object
show all
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

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.

Returns:

  • (Boolean)


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