Class: Terret::Tools::Approvals
- Inherits:
-
Hames::Service
- Object
- Hames::Service
- Terret::Tools::Approvals
- Defined in:
- lib/terret/approvals.rb
Overview
ctx — durable human-in-the-loop gating (plan §6.3, §12 M6). An execute-stage middleware parks calls whose definition demands a decision, appends durable approval/requested, and resumes when a matching approval/resolved lands in the log (the socket's approve/deny frames append exactly that). Both sides are durable, so a parked call survives a process death: on resume the gate finds the recorded verdict and never parks. It gates on tools/execute rather than tools/pre_execute so pre_execute vetoes (the per-agent AllowList) settle a call before a human is ever asked.
Instance Method Summary collapse
-
#deny_pending!(session_id, reason: "cancelled") ⇒ Object
Cancel's escape hatch: deny everything parked for a session, durably.
-
#pending(session_id) ⇒ Object
Log-derived: requested without a matching resolved, within the OPEN turn.
- #pending?(session_id, call_id) ⇒ Boolean
- #start(ctx) ⇒ Object
Instance Method Details
#deny_pending!(session_id, reason: "cancelled") ⇒ Object
Cancel's escape hatch: deny everything parked for a session, durably. Each denial is an ordinary approval/resolved append, so an in-process parked fiber unparks through the same listener as a socket verdict, and a restart-orphaned request settles for good.
67 68 69 70 71 72 73 74 75 76 77 78 79 80 |
# File 'lib/terret/approvals.rb', line 67 def deny_pending!(session_id, reason: "cancelled") pending(session_id).each do |call_id| @ctx[:sessions].append(session_id, "approval/resolved", { call_id: call_id, verdict: "denied", reason: reason }) end # The durable denials above wake one waiter per unique pending id # through the resolved listener. A provider that reused a call id within # one parallel batch parked more than one fiber on that id, though, and # a cancel means all of them: sweep whatever is still parked for this # session onto the denial they share, so no fiber is left holding the # barrier open. Waiters the appends already woke are gone from the map, # so this touches only the ones a single durable denial could not reach. drain_session_waiters(session_id, { verdict: "denied", reason: reason }) end |
#pending(session_id) ⇒ Object
Log-derived: requested without a matching resolved, within the OPEN turn. The in-memory waiter map is never consulted — after a restart it is empty while the log still knows what is owed. Nothing is pending once the turn that asked has closed: a request its turn outlived was settled by that turn ending, and a provider is free to reuse the call id afterwards.
50 51 52 53 54 55 56 57 58 59 |
# File 'lib/terret/approvals.rb', line 50 def pending(session_id) events = open_turn(session_id) resolved = events.filter_map { |e| e.payload[:call_id] if e.type == "approval/resolved" } events.filter_map do |e| next unless e.type == "approval/requested" next if resolved.include?(e.payload[:call_id]) e.payload[:call_id] end end |
#pending?(session_id, call_id) ⇒ Boolean
61 |
# File 'lib/terret/approvals.rb', line 61 def pending?(session_id, call_id) = pending(session_id).include?(call_id) |
#start(ctx) ⇒ Object
19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
# File 'lib/terret/approvals.rb', line 19 def start(ctx) @ctx = ctx # A unique per-park token => { session_id:, call_id:, queue: }. Keyed by # the token rather than by [session_id, call_id] because provider # tool-call ids are NOT contractually unique: two calls of one parallel # batch can arrive sharing an id, and keying by it let the second park # overwrite the first's queue so a single verdict woke only the survivor # and the first fiber parked forever, hanging the barrier. Each park now # owns its own entry and its own wake. The DURABLE correlation # (approval/requested and /resolved, matched in the log on call_id + # name + args) is unchanged; only this in-memory map needed unique keys. @waiting = {} @waiting_mutex = Mutex.new # tests resolve from another thread; wake_one scans @park_seq = 0 ctx.on("tools/execute") do |call, next_| gate(call, next_) end ctx.on("session/event") do |ev| next unless ev.type == "approval/resolved" wake_one(ev.session_id, ev.payload[:call_id], ev.payload) end end |