Module: Rubino::Output::HeadlessBlockLatch

Defined in:
lib/rubino/output/headless_block_latch.rb

Overview

Process-global fail-closed latch for headless (‘rubino prompt`/-q) runs (F1-subagents).

The one-shot CLI reads the PARENT’s UI::Null#approval_blocked? after the run to decide the exit code (#260). But a ‘task` subagent runs on a SEPARATE, fresh UI::Null (nested_ui ⇒ Null off the CLI), so when the CHILD’s dangerous tool is fail-closed-blocked the latch lands on that DISCARDED child adapter — the parent’s UI::Null never sees it, and the CLI reported exit 0 + empty stderr even though the block held and the tool never ran. A direct ‘rubino prompt` of the same dangerous action correctly exits 2; routing it through a subagent silently looked like success, hiding the refusal from CI.

Fix: every UI::Null records a fail-closed block HERE too while a headless run is active, regardless of which (parent or child) adapter caught it. The one-shot exit check consults this latch in addition to the parent adapter, so a subagent-blocked headless run exits non-zero with the block notice on stderr — the same outcome as the direct run. Reset around each one-shot run so a long-lived embedder/test process doesn’t carry a stale block across invocations.

Class Method Summary collapse

Class Method Details

.blocked?Boolean

True when any tool was fail-closed-blocked anywhere in this headless run.

Returns:

  • (Boolean)


37
38
39
# File 'lib/rubino/output/headless_block_latch.rb', line 37

def blocked?
  @mutex.synchronize { !@messages.empty? }
end

.messagesObject

The recorded block notices, in order, for the CLI to echo to stderr.



42
43
44
# File 'lib/rubino/output/headless_block_latch.rb', line 42

def messages
  @mutex.synchronize { @messages.dup }
end

.record(message) ⇒ Object

Record a fail-closed block message. Called from UI::Null#tool_blocked while Rubino.headless? — from the parent OR any (foreground) subagent.



32
33
34
# File 'lib/rubino/output/headless_block_latch.rb', line 32

def record(message)
  @mutex.synchronize { @messages << message.to_s }
end

.reset!Object

Clear the latch. Called at the start of each one-shot run so a reused process never inherits a stale block.



48
49
50
# File 'lib/rubino/output/headless_block_latch.rb', line 48

def reset!
  @mutex.synchronize { @messages = [] }
end