Class: KairosMcp::Daemon

Inherits:
Object
  • Object
show all
Defined in:
lib/kairos_mcp/daemon.rb,
lib/kairos_mcp/daemon/wal.rb,
lib/kairos_mcp/daemon/budget.rb,
lib/kairos_mcp/daemon/chronos.rb,
lib/kairos_mcp/daemon/planner.rb,
lib/kairos_mcp/daemon/pid_lock.rb,
lib/kairos_mcp/daemon/task_dag.rb,
lib/kairos_mcp/daemon/canonical.rb,
lib/kairos_mcp/daemon/heartbeat.rb,
lib/kairos_mcp/daemon/pdf_build.rb,
lib/kairos_mcp/daemon/credentials.rb,
lib/kairos_mcp/daemon/edit_kernel.rb,
lib/kairos_mcp/daemon/integration.rb,
lib/kairos_mcp/daemon/code_gen_act.rb,
lib/kairos_mcp/daemon/wal_recovery.rb,
lib/kairos_mcp/daemon/approval_gate.rb,
lib/kairos_mcp/daemon/attach_server.rb,
lib/kairos_mcp/daemon/daemon_policy.rb,
lib/kairos_mcp/daemon/active_observe.rb,
lib/kairos_mcp/daemon/signal_handler.rb,
lib/kairos_mcp/daemon/command_mailbox.rb,
lib/kairos_mcp/daemon/elevation_token.rb,
lib/kairos_mcp/daemon/mandate_factory.rb,
lib/kairos_mcp/daemon/proposal_routes.rb,
lib/kairos_mcp/daemon/policy_elevation.rb,
lib/kairos_mcp/daemon/restricted_shell.rb,
lib/kairos_mcp/daemon/scope_classifier.rb,
lib/kairos_mcp/daemon/daemon_llm_caller.rb,
lib/kairos_mcp/daemon/execution_context.rb,
lib/kairos_mcp/daemon/idempotency_check.rb,
lib/kairos_mcp/daemon/ooda_cycle_runner.rb,
lib/kairos_mcp/daemon/wal_phase_recorder.rb,
lib/kairos_mcp/daemon/llm_phase_functions.rb,
lib/kairos_mcp/daemon/code_gen_phase_handler.rb,
lib/kairos_mcp/daemon/restricted_shell/errors.rb,
lib/kairos_mcp/daemon/restricted_shell/runner.rb,
lib/kairos_mcp/daemon/idempotent_chain_recorder.rb,
lib/kairos_mcp/daemon/restricted_shell/argv_validators.rb,
lib/kairos_mcp/daemon/restricted_shell/binary_resolver.rb,
lib/kairos_mcp/daemon/restricted_shell/sandbox_context.rb,
lib/kairos_mcp/daemon/restricted_shell/sandbox_factory.rb

Overview

KairosChain long-running daemon (Phase 2 P2.1 skeleton).

Responsibilities (design v0.2 §3.1):

1. Acquire a single-instance PID lock (flock) at .kairos/run/daemon.pid.
2. Install signal handlers (TERM/INT/HUP/USR1).
3. Load config and initialize the logger.
4. Build Safety in :daemon mode (DaemonPolicy — see §3.1.x / CF-4).
5. Run a single-threaded event loop that:
     drain_mailbox → chronos.tick → mandate queue → OODA cycle → heartbeat → sleep
6. Handle graceful shutdown within `graceful_timeout` seconds.

For P2.1, Chronos and OODA integration points are stubs — the loop just logs a ‘no_mandates` heartbeat. Real integration lands in later P2 tickets.

Defined Under Namespace

Modules: Canonical, DaemonPolicy, EditKernel, ExecutionContext, IdempotencyCheck, Integration, LlmPhaseFunctions, MandateFactory, PidLock, Planner, PolicyElevation, ProposalRoutes, ScopeClassifier, SignalHandler, WalRecovery Classes: ActiveObserve, ApprovalGate, AttachServer, Budget, Chronos, CodeGenAct, CodeGenPhaseHandler, CommandMailbox, Credentials, DaemonLlmCaller, ElevationToken, Heartbeat, IdempotentChainRecorder, OodaCycleRunner, PdfBuild, RestrictedShell, TaskDag, WAL, WalPhaseRecorder

Constant Summary collapse

DEFAULT_CONFIG_PATH =
'.kairos/config/daemon.yml'
DEFAULT_PID_PATH =
'.kairos/run/daemon.pid'
DEFAULT_TICK_INTERVAL =

seconds (design §3.2)

10
DEFAULT_GRACEFUL_TIMEOUT =

seconds (design §3.4)

120
STATES =

Lifecycle states — mostly used by tests and status dumps.

%i[initialized starting running draining stopped error].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(config_path: nil, root: Dir.pwd, logger: nil, sleeper: nil, clock: nil) ⇒ Daemon

Returns a new instance of Daemon.

Parameters:

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

    path to daemon.yml (optional)

  • root (String) (defaults to: Dir.pwd)

    working directory to resolve relative paths against

  • logger (KairosMcp::Logger, nil) (defaults to: nil)

    override logger (tests)

  • sleeper (#call) (defaults to: nil)

    callable invoked with a Float seconds count (injected so tests don’t actually sleep)

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

    callable returning the current Time (tests)



44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/kairos_mcp/daemon.rb', line 44

def initialize(config_path: nil, root: Dir.pwd, logger: nil,
               sleeper: nil, clock: nil)
  @root = root
  @config_path = config_path || File.join(@root, DEFAULT_CONFIG_PATH)
  @config = load_config(@config_path)
  @logger = logger || KairosMcp.logger
  @mailbox = CommandMailbox.new
  @sleeper = sleeper || ->(s) { sleep(s) }
  @clock = clock || -> { Time.now.utc }

  @pid_file = nil
  @pid_path = resolve_path(@config['pid_path'] || DEFAULT_PID_PATH)
  @tick_interval = Float(@config['tick_interval'] || DEFAULT_TICK_INTERVAL)
  @graceful_timeout = Float(@config['graceful_timeout'] || DEFAULT_GRACEFUL_TIMEOUT)

  @shutdown_requested = false
  @shutdown_signal = nil
  @reload_requested = false
  @status_dump_requested = false
  @state = :initialized
  @tick_count = 0
  @started_at = nil
  @safety = nil
end

Instance Attribute Details

#configObject (readonly)

Returns the value of attribute config.



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

def config
  @config
end

#loggerObject (readonly)

Returns the value of attribute logger.



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

def logger
  @logger
end

#mailboxObject (readonly)

Returns the value of attribute mailbox.



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

def mailbox
  @mailbox
end

#started_atObject (readonly)

Returns the value of attribute started_at.



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

def started_at
  @started_at
end

#stateObject (readonly)

Returns the value of attribute state.



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

def state
  @state
end

#tick_countObject (readonly)

Returns the value of attribute tick_count.



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

def tick_count
  @tick_count
end

Instance Method Details

#chronos_tickObject

Chronos tick — real implementation lands in a later P2 ticket. For P2.1 this is a no-op logged at debug level.



271
272
273
# File 'lib/kairos_mcp/daemon.rb', line 271

def chronos_tick
  @logger.debug('daemon_chronos_stub', source: 'daemon')
end

#dispatch_command(cmd) ⇒ Object

—————————————————————— commands



225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
# File 'lib/kairos_mcp/daemon.rb', line 225

def dispatch_command(cmd)
  case cmd[:type]
  when :shutdown
    request_shutdown!('mailbox')
  when :reload
    handle_reload(cmd)
  when :status_dump
    handle_status_dump(cmd)
  else
    @logger.debug('daemon_command_unknown',
                  source: 'daemon',
                  details: { type: cmd[:type].to_s, id: cmd[:id] })
  end
rescue StandardError => e
  @logger.error('daemon_command_failed',
                source: 'daemon',
                details: { type: cmd[:type].to_s, error: e.message })
end

#event_loopObject

—————————————————————— event loop



150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/kairos_mcp/daemon.rb', line 150

def event_loop
  shutdown_deadline = nil

  until @state == :stopped
    if @shutdown_requested && shutdown_deadline.nil?
      shutdown_deadline = @clock.call + @graceful_timeout
      @logger.info('daemon_shutdown_begin',
                   source: 'daemon',
                   details: { signal: @shutdown_signal,
                              deadline: shutdown_deadline.iso8601 })
    end

    if shutdown_deadline && @clock.call >= shutdown_deadline
      @logger.warn('daemon_graceful_timeout_exceeded',
                   source: 'daemon',
                   details: { timeout: @graceful_timeout })
      break
    end

    tick_once

    # If shutdown was requested, there's nothing useful left to do once
    # the mailbox has been drained.
    break if @shutdown_requested && @mailbox.empty?

    @sleeper.call(@tick_interval)
  end
end

#handle_reload(_cmd) ⇒ Object

CF-7 fix: keep old config on reload failure instead of falling back to defaults.



245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
# File 'lib/kairos_mcp/daemon.rb', line 245

def handle_reload(_cmd)
  new_config = safe_load_config(@config_path)
  if new_config
    @config = new_config
    @tick_interval = Float(@config['tick_interval'] || DEFAULT_TICK_INTERVAL)
    @graceful_timeout = Float(@config['graceful_timeout'] || DEFAULT_GRACEFUL_TIMEOUT)
    @logger.info('daemon_config_reloaded',
                 source: 'daemon',
                 details: { tick_interval: @tick_interval })
  else
    @logger.error('daemon_config_reload_failed',
                  source: 'daemon',
                  details: { note: 'keeping previous config' })
  end
end

#handle_status_dump(_cmd) ⇒ Object



261
262
263
264
265
# File 'lib/kairos_mcp/daemon.rb', line 261

def handle_status_dump(_cmd)
  @logger.info('daemon_status',
               source: 'daemon',
               details: status_snapshot)
end

#request_reload!Object



136
137
138
# File 'lib/kairos_mcp/daemon.rb', line 136

def request_reload!
  @reload_requested = true
end

#request_shutdown!(signal = nil) ⇒ Object

Called from SignalHandler.handle — must be async-signal-safe. CF-2 fix: only set simple flags, no allocations in signal context.



131
132
133
134
# File 'lib/kairos_mcp/daemon.rb', line 131

def request_shutdown!(signal = nil)
  @shutdown_requested = true
  @shutdown_signal = signal
end

#request_status_dump!Object



140
141
142
# File 'lib/kairos_mcp/daemon.rb', line 140

def request_status_dump!
  @status_dump_requested = true
end

#runObject

Start the daemon: acquire lock, install signals, enter event loop. This is the method ‘bin/kairos-daemon` invokes.



73
74
75
76
77
78
# File 'lib/kairos_mcp/daemon.rb', line 73

def run
  start!
  event_loop
ensure
  stop!
end

#run_one_ooda_cycleObject

Mandate queue → OODA — stub for P2.1.



276
277
278
279
280
# File 'lib/kairos_mcp/daemon.rb', line 276

def run_one_ooda_cycle
  @logger.debug('daemon_ooda_stub',
                source: 'daemon',
                details: { note: 'no mandates' })
end

#shutdown_requested?Boolean

Returns:

  • (Boolean)


144
145
146
# File 'lib/kairos_mcp/daemon.rb', line 144

def shutdown_requested?
  @shutdown_requested
end

#start!Object

Initialize but do NOT enter the event loop — useful for tests that want to drive ‘tick_once` manually.



82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'lib/kairos_mcp/daemon.rb', line 82

def start!
  return if @state == :running

  @state = :starting
  @pid_file = PidLock.acquire!(@pid_path)
  SignalHandler.install(self)
  @safety = build_safety
  @started_at = @clock.call
  @state = :running
  @logger.info('daemon_started',
               source: 'daemon',
               details: {
                 pid: Process.pid,
                 pid_path: @pid_path,
                 tick_interval: @tick_interval,
                 graceful_timeout: @graceful_timeout
               })
end

#status_snapshotObject

—————————————————————— helpers



284
285
286
287
288
289
290
291
292
293
294
295
# File 'lib/kairos_mcp/daemon.rb', line 284

def status_snapshot
  {
    state: @state.to_s,
    pid: Process.pid,
    tick_count: @tick_count,
    mailbox_size: @mailbox.size,
    started_at: @started_at&.iso8601,
    shutdown_requested: @shutdown_requested,
    tick_interval: @tick_interval,
    graceful_timeout: @graceful_timeout
  }
end

#stop!Object

Release lock and uninstall signal handlers. Safe to call twice.



102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/kairos_mcp/daemon.rb', line 102

def stop!
  return if @state == :stopped

  previous_state = @state
  @state = :draining

  # Release PID lock FIRST so a restart isn't blocked by our cleanup.
  PidLock.release(@pid_file, @pid_path)
  @pid_file = nil

  SignalHandler.uninstall

  @state = :stopped
  # Avoid noise if we never successfully started.
  return if previous_state == :initialized

  @logger.info('daemon_stopped',
               source: 'daemon',
               details: { signal: @shutdown_signal, tick_count: @tick_count })
rescue StandardError => e
  @state = :error
  # Logging errors must not crash shutdown.
  warn "[kairos-daemon] stop! failed: #{e.class}: #{e.message}"
end

#tick_onceObject

One iteration of the event loop. Exposed for tests.



180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
# File 'lib/kairos_mcp/daemon.rb', line 180

def tick_once
  @tick_count += 1

  # CF-2 fix: translate signal flags into mailbox commands (no allocations in trap).
  if @reload_requested
    @reload_requested = false
    @mailbox.enqueue(:reload, signal: 'HUP')
  end
  if @status_dump_requested
    @status_dump_requested = false
    @mailbox.enqueue(:status_dump, signal: 'USR1')
  end

  # 1. Drain the command mailbox BEFORE doing work (design §CF-2).
  drained = @mailbox.drain
  drained.each { |cmd| dispatch_command(cmd) }

  # CF-5 fix: error boundary around each work phase.
  # Exceptions in one phase must not kill the daemon.
  begin
    # 2. Chronos tick (stub in P2.1).
    chronos_tick
  rescue StandardError => e
    @logger.error('daemon_chronos_tick_failed', source: 'daemon',
                  details: { error: "#{e.class}: #{e.message}" })
  end

  begin
    # 3. Mandate queue → OODA cycle (stub in P2.1).
    run_one_ooda_cycle
  rescue StandardError => e
    @logger.error('daemon_ooda_cycle_failed', source: 'daemon',
                  details: { error: "#{e.class}: #{e.message}" })
  end

  # 4. Heartbeat.
  @logger.debug('daemon_heartbeat',
                source: 'daemon',
                details: { tick: @tick_count,
                           mailbox_size: @mailbox.size,
                           state: @state.to_s })
end