Module: Brainiac::Plugins::Basecamp::SessionRegistry

Defined in:
lib/brainiac/plugins/basecamp/session_registry.rb

Overview

First-class agent session liveness tracking.

Tracks active agent sessions by task_id (e.g., "gate-glados-1234", "final-decision-1234", "epic-review-45920028") with PID, dispatch timestamp, and agent name.

The registry answers "is an agent actually running for this task?" directly via PID liveness checks — replacing the prior pattern of inferring liveness from Fizzy assignment or elapsed time.

Persistence: Sessions are written to disk for crash recovery diagnostics, but the registry is treated as VOLATILE — all sessions are cleared on server restart (a restarted server cannot trust stale PIDs).

Usage:

SessionRegistry.register_session("gate-glados-1234", pid)
SessionRegistry.alive?("gate-glados-1234")  # => true/false (checks PID)
SessionRegistry.mark_dead("gate-glados-1234")
SessionRegistry.sessions_for_epic("epic-45920028")

Constant Summary collapse

BRAINIAC_DIR =
ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
SESSIONS_FILE =
File.join(BRAINIAC_DIR, "basecamp_sessions.json")
IMPLEMENTATION_SESSION_PREFIX =
"implementation-"

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.suppress_global_forward=(value) ⇒ Object (writeonly)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Suppress forwarding to global register_session (for testing).



314
315
316
# File 'lib/brainiac/plugins/basecamp/session_registry.rb', line 314

def suppress_global_forward=(value)
  @suppress_global_forward = value
end

Class Method Details

.active_sessions_for_card(card_number) ⇒ Array<Hash>

Get all active sessions for a card number.

Parameters:

  • card_number (Integer)

    Fizzy card number

Returns:

  • (Array<Hash>)


206
207
208
209
210
211
212
# File 'lib/brainiac/plugins/basecamp/session_registry.rb', line 206

def active_sessions_for_card(card_number)
  sessions.values.select do |s|
    s["card_number"] == card_number.to_i &&
      s["status"] == "active" &&
      pid_alive?(s["pid"])
  end
end

.active_sessions_for_epic(epic_id) ⇒ Array<Hash>

Get all active (alive) sessions for an epic.

Parameters:

  • epic_id (String)

    Epic ID

Returns:

  • (Array<Hash>)

    Active session records



121
122
123
# File 'lib/brainiac/plugins/basecamp/session_registry.rb', line 121

def active_sessions_for_epic(epic_id)
  sessions_for_epic(epic_id).select { |s| s["status"] == "active" && pid_alive?(s["pid"]) }
end

.alive?(task_id) ⇒ Boolean

Check if a session is alive by verifying the PID is still running.

Parameters:

  • task_id (String)

    Session task ID

Returns:

  • (Boolean)

    true if session exists and its PID is alive



81
82
83
84
85
86
87
88
89
90
# File 'lib/brainiac/plugins/basecamp/session_registry.rb', line 81

def alive?(task_id)
  session = sessions[task_id.to_s]
  return false unless session
  return false if session["status"] == "dead"

  pid = session["pid"]
  return false unless pid&.positive?

  pid_alive?(pid)
end

.any_alive_for_card?(card_number) ⇒ Boolean

Check if any session is alive for a given card number. Searches all sessions (gates, final decision, epic review) for this card.

Parameters:

  • card_number (Integer)

    Fizzy card number

Returns:

  • (Boolean)


138
139
140
141
142
143
144
# File 'lib/brainiac/plugins/basecamp/session_registry.rb', line 138

def any_alive_for_card?(card_number)
  sessions.values.any? do |s|
    s["card_number"] == card_number.to_i &&
      s["status"] == "active" &&
      pid_alive?(s["pid"])
  end
end

.clear_all!Integer

Clear all sessions. Called on server restart. Marks all sessions as dead since we can't trust PIDs after restart.

Returns:

  • (Integer)

    Number of sessions cleared



218
219
220
221
222
223
224
225
226
227
228
# File 'lib/brainiac/plugins/basecamp/session_registry.rb', line 218

def clear_all!
  count = sessions.size
  sessions.each_value do |s|
    s["status"] = "dead"
    s["ended_at"] = Time.now.iso8601
  end
  persist!

  LOG.info "[Basecamp:SessionRegistry] Cleared #{count} session(s) on startup" if defined?(LOG) && count.positive?
  count
end

.find_session(task_id) ⇒ Hash?

Get session for a specific task.

Parameters:

  • task_id (String)

    Task ID

Returns:

  • (Hash, nil)

    Session record or nil



129
130
131
# File 'lib/brainiac/plugins/basecamp/session_registry.rb', line 129

def find_session(task_id)
  sessions[task_id.to_s]
end

.implementation_alive?(card_number) ⇒ Boolean

Returns:

  • (Boolean)


153
154
155
# File 'lib/brainiac/plugins/basecamp/session_registry.rb', line 153

def implementation_alive?(card_number)
  alive?(implementation_session_id(card_number))
end

.implementation_session_id(card_number) ⇒ Object

Stable task ID for the implementation agent assigned to a Fizzy card. Keep this distinct from gate and final-decision IDs so a live reviewer cannot prevent implementation work from being re-dispatched.



149
150
151
# File 'lib/brainiac/plugins/basecamp/session_registry.rb', line 149

def implementation_session_id(card_number)
  "#{IMPLEMENTATION_SESSION_PREFIX}#{card_number.to_i}"
end

.install_global_registration_hook!Object

Installs the observer once the core session helper is available. Keeping the wrapper here means Basecamp remains compatible with the normal Fizzy assignment flow, while liveness remains owned by SessionRegistry.



181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
# File 'lib/brainiac/plugins/basecamp/session_registry.rb', line 181

def install_global_registration_hook!
  return if @global_registration_hook_installed
  return unless Object.private_method_defined?(:register_session)

  observer = Module.new do
    def register_session(card_key, pid, **kwargs)
      result = super
      Brainiac::Plugins::Basecamp::SessionRegistry.track_global_implementation_session(
        card_key,
        pid,
        log_file: kwargs[:log_file],
        agent_name: kwargs[:agent_name]
      )
      result
    end
  end

  Object.prepend(observer)
  @global_registration_hook_installed = true
end

.mark_dead(task_id) ⇒ Boolean

Mark a session as dead (without checking PID). Use when you know the agent has finished or crashed.

Parameters:

  • task_id (String)

    Session task ID

Returns:

  • (Boolean)

    true if session existed and was marked dead



97
98
99
100
101
102
103
104
105
106
107
# File 'lib/brainiac/plugins/basecamp/session_registry.rb', line 97

def mark_dead(task_id)
  session = sessions[task_id.to_s]
  return false unless session

  session["status"] = "dead"
  session["ended_at"] = Time.now.iso8601
  persist!

  LOG.info "[Basecamp:SessionRegistry] Marked dead: #{task_id} (pid=#{session['pid']})" if defined?(LOG)
  true
end

.reap_dead!Array<String>

Reap sessions whose PIDs are no longer alive. Call this periodically to detect agents that crashed without notification.

Returns:

  • (Array<String>)

    Task IDs that were reaped



266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
# File 'lib/brainiac/plugins/basecamp/session_registry.rb', line 266

def reap_dead!
  reaped = []

  sessions.each do |task_id, session|
    next unless session["status"] == "active"

    pid = session["pid"]
    next unless pid&.positive?
    next if pid_alive?(pid)

    session["status"] = "dead"
    session["ended_at"] = Time.now.iso8601
    session["death_reason"] = "pid_exited"
    reaped << task_id
    LOG.info "[Basecamp:SessionRegistry] Reaped dead session: #{task_id} (pid=#{pid} no longer running)" if defined?(LOG)
  end

  persist! if reaped.any?
  reaped
end

.register_session(task_id, pid, log_file: nil, agent_name: nil, epic_id: nil, card_number: nil) ⇒ Hash

Register an active agent session.

Parameters:

  • task_id (String)

    Unique key for this session (e.g. "gate-glados-1234")

  • pid (Integer)

    Process ID of the agent

  • log_file (String, nil) (defaults to: nil)

    Path to the agent's log file

  • agent_name (String, nil) (defaults to: nil)

    Name of the agent running

  • epic_id (String, nil) (defaults to: nil)

    Epic ID this session belongs to

  • card_number (Integer, nil) (defaults to: nil)

    Fizzy card number

Returns:

  • (Hash)

    The registered session record



45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'lib/brainiac/plugins/basecamp/session_registry.rb', line 45

def register_session(task_id, pid, log_file: nil, agent_name: nil, epic_id: nil, card_number: nil)
  session = {
    "task_id" => task_id.to_s,
    "pid" => pid.to_i,
    "agent_name" => agent_name,
    "epic_id" => epic_id,
    "card_number" => card_number&.to_i,
    "log_file" => log_file,
    "started_at" => Time.now.iso8601,
    "status" => "active"
  }

  sessions[task_id.to_s] = session
  persist!

  LOG.info "[Basecamp:SessionRegistry] Registered session: #{task_id} (pid=#{pid}, agent=#{agent_name})" if defined?(LOG)

  # Also forward to the global register_session if it exists (for waybar/UI).
  # Skip implementation-* sessions — fizzy already registers those as card-NNNN
  # and we don't want duplicate entries in the tray.
  if Object.respond_to?(:register_session, true) && !@suppress_global_forward &&
     !task_id.to_s.start_with?("implementation-")
    begin
      Object.send(:register_session, task_id, pid, log_file: log_file, agent_name: agent_name)
    rescue StandardError
      # Non-critical — waybar integration is optional
    end
  end

  session
end

.reset!Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Reset internal state (for testing).



318
319
320
321
# File 'lib/brainiac/plugins/basecamp/session_registry.rb', line 318

def reset!
  @sessions = {}
  @suppress_global_forward = false
end

.sessions_for_epic(epic_id) ⇒ Array<Hash>

Get all sessions belonging to an epic.

Parameters:

  • epic_id (String)

    Epic ID (e.g. "epic-45920028")

Returns:

  • (Array<Hash>)

    Session records for this epic



113
114
115
# File 'lib/brainiac/plugins/basecamp/session_registry.rb', line 113

def sessions_for_epic(epic_id)
  sessions.values.select { |s| s["epic_id"] == epic_id.to_s }
end

.statusHash

Summary of current session state (for API/diagnostics).

Returns:

  • (Hash)


290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
# File 'lib/brainiac/plugins/basecamp/session_registry.rb', line 290

def status
  active_count = sessions.values.count { |s| s["status"] == "active" && pid_alive?(s["pid"]) }
  dead_count = sessions.values.count { |s| s["status"] == "dead" || (s["status"] == "active" && !pid_alive?(s["pid"])) }

  {
    "total" => sessions.size,
    "active" => active_count,
    "dead" => dead_count,
    "sessions" => sessions.values.map do |s|
      {
        "task_id" => s["task_id"],
        "pid" => s["pid"],
        "agent_name" => s["agent_name"],
        "epic_id" => s["epic_id"],
        "card_number" => s["card_number"],
        "status" => s["status"] == "active" && pid_alive?(s["pid"]) ? "active" : "dead",
        "started_at" => s["started_at"]
      }
    end
  }
end

.sweep!(max_age: 3600) ⇒ Integer

Sweep dead sessions from memory (cleanup stale entries older than threshold). Keeps dead sessions on disk for diagnostics but removes from active tracking.

Parameters:

  • max_age (Integer) (defaults to: 3600)

    Maximum age in seconds for dead sessions (default: 1 hour)

Returns:

  • (Integer)

    Number of sessions swept



235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
# File 'lib/brainiac/plugins/basecamp/session_registry.rb', line 235

def sweep!(max_age: 3600)
  now = Time.now
  swept = 0

  sessions.delete_if do |_task_id, session|
    next false unless session["status"] == "dead"

    ended_at = session["ended_at"]
    # Orphaned entries (dead with no ended_at) are corrupt/abnormal — remove immediately
    if ended_at.nil?
      swept += 1
      next true
    end

    age = now - Time.parse(ended_at)
    if age > max_age
      swept += 1
      true
    else
      false
    end
  end

  persist! if swept.positive?
  swept
end

.track_global_implementation_session(card_key, pid, log_file: nil, agent_name: nil) ⇒ Object

Mirror Fizzy's real implementation-agent spawn into this registry. Fizzy invokes the global register_session("card-", pid) immediately after run_agent returns; Basecamp installs a small observer around that method so it can attach epic metadata without duplicating Fizzy dispatch.



161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/brainiac/plugins/basecamp/session_registry.rb', line 161

def track_global_implementation_session(card_key, pid, log_file: nil, agent_name: nil)
  match = /\Acard-(\d+)\z/.match(card_key.to_s)
  return unless match

  card_number = match[1].to_i
  epic = Orchestrator.find_epic_for_card(card_number)
  return unless epic

  register_session(
    implementation_session_id(card_number), pid,
    log_file: log_file,
    agent_name: agent_name,
    epic_id: epic["id"],
    card_number: card_number
  )
end