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.



74
75
76
# File 'lib/rubino/interaction/polishing.rb', line 74

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)


81
82
83
# File 'lib/rubino/interaction/polishing.rb', line 81

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
62
63
64
65
66
67
68
69
# 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=)
    # NEVER let a dying detached worker auto-dump a backtrace into the user's
    # terminal. #run rescues Interrupted + StandardError and logs, but a
    # non-StandardError (an Interrupt raised inside its aux-LLM net/http read
    # on teardown, a Thread#raise) would otherwise hit Ruby's default
    # report_on_exception and print a raw `net/protocol.rb … Interrupt`
    # backtrace mid-chat. The work is best-effort and re-runs next session,
    # so a silent death on shutdown is correct.
    @thread.report_on_exception = false
  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.



88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
# File 'lib/rubino/interaction/polishing.rb', line 88

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