Class: KairosMcp::Daemon::Heartbeat

Inherits:
Object
  • Object
show all
Defined in:
lib/kairos_mcp/daemon/heartbeat.rb

Overview

Heartbeat — P2.8 liveness beacon.

Design (v0.2 P2.8):

* At most once per `interval` seconds, the daemon writes a small JSON
  file at .kairos/run/heartbeat.json with current liveness fields.
* External monitors (health checks, attach clients, ops scripts)
  stat+read this file to answer "is the daemon alive, and when did
  it last complete a cycle?"

Atomicity:

Writes go tmp → rename. A torn write leaves either the previous
heartbeat or the new one intact — never a half-written JSON.

Decoupling:

`emit(daemon)` duck-types the daemon. Required: `#status_snapshot`
(for pid + tick_count). Optional: `#active_mandate_id`,
`#queue_depth`, `#last_cycle_at`. Missing optionals default to
nil / 0 — this keeps Heartbeat testable without the full daemon.

Constant Summary collapse

DEFAULT_PATH =
'.kairos/run/heartbeat.json'
DEFAULT_INTERVAL =

seconds

10

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(path: DEFAULT_PATH, clock: nil) ⇒ Heartbeat

Returns a new instance of Heartbeat.

Parameters:

  • path (String) (defaults to: DEFAULT_PATH)

    absolute path to heartbeat.json

  • clock (#call, nil) (defaults to: nil)

    returns current Time (UTC-ish)



35
36
37
38
# File 'lib/kairos_mcp/daemon/heartbeat.rb', line 35

def initialize(path: DEFAULT_PATH, clock: nil)
  @path = path
  @clock = clock || -> { Time.now.utc }
end

Instance Attribute Details

#pathObject (readonly)

Returns the value of attribute path.



31
32
33
# File 'lib/kairos_mcp/daemon/heartbeat.rb', line 31

def path
  @path
end

Instance Method Details

#emit(daemon) ⇒ Object

Emit a heartbeat right now. Returns the Time it was emitted at.



41
42
43
44
45
46
# File 'lib/kairos_mcp/daemon/heartbeat.rb', line 41

def emit(daemon)
  now = @clock.call
  payload = build_payload(daemon, now)
  write_atomic(payload)
  now
end

#emit_if_due(daemon, last_emit_at, interval: DEFAULT_INTERVAL) ⇒ Object

Rate-limited variant. If ‘interval` seconds have not yet elapsed since `last_emit_at`, do nothing and return `last_emit_at`. Otherwise emit and return the new emit time.

‘last_emit_at` may be nil (first call) — in that case we emit.



53
54
55
56
57
58
59
60
# File 'lib/kairos_mcp/daemon/heartbeat.rb', line 53

def emit_if_due(daemon, last_emit_at, interval: DEFAULT_INTERVAL)
  now = @clock.call
  return last_emit_at if last_emit_at && (now - last_emit_at) < interval

  payload = build_payload(daemon, now)
  write_atomic(payload)
  now
end

#readObject

Parse and return the current heartbeat.json, or nil if absent / unparseable. Never raises.



64
65
66
67
68
69
70
# File 'lib/kairos_mcp/daemon/heartbeat.rb', line 64

def read
  return nil unless File.exist?(@path)

  JSON.parse(File.read(@path))
rescue StandardError
  nil
end