anima-core 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +1 -0
  3. data/.reek.yml +47 -0
  4. data/README.md +60 -26
  5. data/anima-core.gemspec +4 -1
  6. data/app/channels/session_channel.rb +29 -10
  7. data/app/decorators/tool_call_decorator.rb +7 -3
  8. data/app/decorators/tool_decorator.rb +57 -0
  9. data/app/decorators/tool_response_decorator.rb +12 -4
  10. data/app/decorators/web_get_tool_decorator.rb +102 -0
  11. data/app/jobs/agent_request_job.rb +90 -23
  12. data/app/jobs/mneme_job.rb +51 -0
  13. data/app/jobs/passive_recall_job.rb +29 -0
  14. data/app/models/concerns/event/broadcasting.rb +18 -0
  15. data/app/models/event.rb +10 -0
  16. data/app/models/goal.rb +27 -0
  17. data/app/models/goal_pinned_event.rb +11 -0
  18. data/app/models/pinned_event.rb +41 -0
  19. data/app/models/session.rb +335 -6
  20. data/app/models/snapshot.rb +76 -0
  21. data/config/initializers/event_subscribers.rb +14 -3
  22. data/config/initializers/fts5_schema_dump.rb +21 -0
  23. data/db/migrate/20260321080000_create_mneme_schema.rb +32 -0
  24. data/db/migrate/20260321120000_create_pinned_events.rb +27 -0
  25. data/db/migrate/20260321140000_create_events_fts_index.rb +77 -0
  26. data/db/migrate/20260321140100_add_recalled_event_ids_to_sessions.rb +10 -0
  27. data/lib/agent_loop.rb +63 -20
  28. data/lib/analytical_brain/runner.rb +158 -65
  29. data/lib/analytical_brain/tools/assign_nickname.rb +76 -0
  30. data/lib/analytical_brain/tools/finish_goal.rb +6 -1
  31. data/lib/anima/cli.rb +2 -1
  32. data/lib/anima/installer.rb +11 -12
  33. data/lib/anima/settings.rb +41 -0
  34. data/lib/anima/version.rb +1 -1
  35. data/lib/events/bounce_back.rb +37 -0
  36. data/lib/events/subscribers/agent_dispatcher.rb +29 -0
  37. data/lib/events/subscribers/persister.rb +17 -0
  38. data/lib/events/subscribers/subagent_message_router.rb +102 -0
  39. data/lib/events/subscribers/transient_broadcaster.rb +36 -0
  40. data/lib/llm/client.rb +16 -8
  41. data/lib/mneme/compressed_viewport.rb +200 -0
  42. data/lib/mneme/l2_runner.rb +138 -0
  43. data/lib/mneme/passive_recall.rb +69 -0
  44. data/lib/mneme/runner.rb +254 -0
  45. data/lib/mneme/search.rb +150 -0
  46. data/lib/mneme/tools/attach_events_to_goals.rb +107 -0
  47. data/lib/mneme/tools/everything_ok.rb +24 -0
  48. data/lib/mneme/tools/save_snapshot.rb +68 -0
  49. data/lib/mneme.rb +29 -0
  50. data/lib/providers/anthropic.rb +57 -13
  51. data/lib/shell_session.rb +188 -59
  52. data/lib/tasks/fts5.rake +6 -0
  53. data/lib/tools/remember.rb +179 -0
  54. data/lib/tools/spawn_specialist.rb +21 -9
  55. data/lib/tools/spawn_subagent.rb +22 -11
  56. data/lib/tools/subagent_prompts.rb +20 -3
  57. data/lib/tools/web_get.rb +15 -6
  58. data/lib/tui/app.rb +222 -125
  59. data/lib/tui/decorators/base_decorator.rb +165 -0
  60. data/lib/tui/decorators/bash_decorator.rb +20 -0
  61. data/lib/tui/decorators/edit_decorator.rb +19 -0
  62. data/lib/tui/decorators/read_decorator.rb +24 -0
  63. data/lib/tui/decorators/think_decorator.rb +36 -0
  64. data/lib/tui/decorators/web_get_decorator.rb +19 -0
  65. data/lib/tui/decorators/write_decorator.rb +19 -0
  66. data/lib/tui/flash.rb +139 -0
  67. data/lib/tui/formatting.rb +28 -0
  68. data/lib/tui/height_map.rb +93 -0
  69. data/lib/tui/message_store.rb +25 -1
  70. data/lib/tui/performance_logger.rb +90 -0
  71. data/lib/tui/screens/chat.rb +358 -133
  72. data/templates/config.toml +40 -0
  73. metadata +83 -4
  74. data/CHANGELOG.md +0 -80
  75. data/Gemfile +0 -17
  76. data/lib/tools/return_result.rb +0 -81
data/lib/mneme.rb ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Mneme — the memory department. Watches for viewport eviction and creates
4
+ # summaries before context is lost. Named after the Greek Titaness of memory.
5
+ #
6
+ # Mneme is the third event bus department alongside Nous (main agent) and
7
+ # the Analytical Brain. It operates as a phantom LLM loop: observes the
8
+ # main session, creates snapshots, but leaves no trace of its own reasoning.
9
+ module Mneme
10
+ # Dev-only logger that writes to log/mneme.log.
11
+ # In non-development environments returns a null logger so
12
+ # call sites don't need conditionals.
13
+ #
14
+ # @return [Logger]
15
+ def self.logger
16
+ @logger ||= build_logger
17
+ end
18
+
19
+ def self.build_logger
20
+ return Logger.new(File::NULL) unless Rails.env.development?
21
+
22
+ Logger.new(Rails.root.join("log", "mneme.log")).tap do |log|
23
+ log.formatter = proc { |severity, time, _progname, msg|
24
+ "[#{time.strftime("%H:%M:%S.%L")}] #{severity} #{msg}\n"
25
+ }
26
+ end
27
+ end
28
+ private_class_method :build_logger
29
+ end
@@ -13,6 +13,10 @@ module Providers
13
13
  API_VERSION = "2023-06-01"
14
14
  REQUIRED_BETA = "oauth-2025-04-20"
15
15
 
16
+ # Anthropic requires this exact string as the first system block for OAuth
17
+ # subscription tokens on Sonnet/Opus. Without it, /v1/messages returns 400.
18
+ OAUTH_PASSPHRASE = "You are Claude Code, Anthropic's official CLI for Claude."
19
+
16
20
  class Error < StandardError; end
17
21
  class AuthenticationError < Error; end
18
22
  class TokenFormatError < Error; end
@@ -25,11 +29,13 @@ module Providers
25
29
  class << self
26
30
  def fetch_token
27
31
  token = CredentialStore.read("anthropic", "subscription_token")
28
- raise AuthenticationError, <<~MSG.strip if token.blank?
32
+ return token if token.present?
33
+ return "sk-ant-oat01-#{"0" * 68}" if ENV["CI"]
34
+
35
+ raise AuthenticationError, <<~MSG.strip
29
36
  No Anthropic subscription token found in credentials.
30
37
  Use the TUI token setup (Ctrl+a → a) to configure your token.
31
38
  MSG
32
- token
33
39
  end
34
40
 
35
41
  def validate_token_format!(token)
@@ -46,6 +52,13 @@ module Providers
46
52
  true
47
53
  end
48
54
 
55
+ # Validate a token against the live Anthropic API.
56
+ # Delegates to {#validate_credentials!} on a throwaway instance.
57
+ #
58
+ # @param token [String] Anthropic API token to validate
59
+ # @return [true] when the API accepts the token
60
+ # @raise [TransientError] on network failures or server errors (retryable)
61
+ # @raise [AuthenticationError] on 401/403 (permanent)
49
62
  def validate_token_api!(token)
50
63
  provider = new(token)
51
64
  provider.validate_credentials!
@@ -58,7 +71,18 @@ module Providers
58
71
  @token = token || self.class.fetch_token
59
72
  end
60
73
 
74
+ # Send a message to the Anthropic API and return the parsed response.
75
+ #
76
+ # @param model [String] Anthropic model identifier
77
+ # @param messages [Array<Hash>] conversation messages
78
+ # @param max_tokens [Integer] maximum tokens in the response
79
+ # @param options [Hash] additional parameters (e.g. +system:+, +tools:+)
80
+ # @return [Hash] parsed API response
81
+ # @raise [TransientError] on network failures or server errors (retryable)
82
+ # @raise [AuthenticationError] on 401/403 (permanent)
83
+ # @raise [Error] on other API errors
61
84
  def create_message(model:, messages:, max_tokens:, **options)
85
+ wrap_system_prompt!(options)
62
86
  body = {model: model, messages: messages, max_tokens: max_tokens}.merge(options)
63
87
 
64
88
  response = self.class.post(
@@ -69,8 +93,8 @@ module Providers
69
93
  )
70
94
 
71
95
  handle_response(response)
72
- rescue Errno::ECONNRESET, Net::ReadTimeout, Net::OpenTimeout, SocketError, EOFError => e
73
- raise TransientError, "#{e.class}: #{e.message}"
96
+ rescue Errno::ECONNRESET, Net::ReadTimeout, Net::OpenTimeout, SocketError, EOFError => network_error
97
+ raise TransientError, "#{network_error.class}: #{network_error.message}"
74
98
  end
75
99
 
76
100
  # Count tokens in a message payload without creating a message.
@@ -82,6 +106,7 @@ module Providers
82
106
  # @return [Integer] estimated input token count
83
107
  # @raise [Error] on API errors
84
108
  def count_tokens(model:, messages:, **options)
109
+ wrap_system_prompt!(options)
85
110
  body = {model: model, messages: messages}.merge(options)
86
111
 
87
112
  response = self.class.post(
@@ -93,18 +118,22 @@ module Providers
93
118
 
94
119
  result = handle_response(response)
95
120
  result["input_tokens"]
96
- rescue Errno::ECONNRESET, Net::ReadTimeout, Net::OpenTimeout, SocketError, EOFError => e
97
- raise TransientError, "#{e.class}: #{e.message}"
121
+ rescue Errno::ECONNRESET, Net::ReadTimeout, Net::OpenTimeout, SocketError, EOFError => network_error
122
+ raise TransientError, "#{network_error.class}: #{network_error.message}"
98
123
  end
99
124
 
125
+ # Verify the token is accepted by Anthropic using the free models endpoint.
126
+ # Returns +true+ on success; raises typed exceptions on failure so callers
127
+ # can distinguish permanent auth problems from transient outages.
128
+ #
129
+ # @return [true] when the API accepts the token
130
+ # @raise [AuthenticationError] on 401 (invalid token) or 403 (restricted credential)
131
+ # @raise [RateLimitError] on 429
132
+ # @raise [ServerError] on 5xx
133
+ # @raise [TransientError] on network-level failures
100
134
  def validate_credentials!
101
- response = self.class.post(
102
- "/v1/messages",
103
- body: {
104
- model: Anima::Settings.model,
105
- messages: [{role: "user", content: "Hi"}],
106
- max_tokens: 1
107
- }.to_json,
135
+ response = self.class.get(
136
+ "/v1/models",
108
137
  headers: request_headers,
109
138
  timeout: Anima::Settings.api_timeout
110
139
  )
@@ -121,10 +150,25 @@ module Providers
121
150
  else
122
151
  handle_response(response)
123
152
  end
153
+ rescue Errno::ECONNRESET, Net::ReadTimeout, Net::OpenTimeout, SocketError, EOFError => network_error
154
+ raise TransientError, "#{network_error.class}: #{network_error.message}"
124
155
  end
125
156
 
126
157
  private
127
158
 
159
+ # Wraps the system parameter in the array-of-blocks format required by
160
+ # Anthropic for OAuth tokens. The passphrase block is always present;
161
+ # the caller's prompt (if any) is appended as the second block.
162
+ #
163
+ # @param options [Hash] mutable options hash (modified in place)
164
+ # @return [void]
165
+ def wrap_system_prompt!(options)
166
+ prompt = options[:system]
167
+ blocks = [{type: "text", text: OAUTH_PASSPHRASE}]
168
+ blocks << {type: "text", text: prompt} if prompt
169
+ options[:system] = blocks
170
+ end
171
+
128
172
  def request_headers
129
173
  {
130
174
  "Authorization" => "Bearer #{token}",
data/lib/shell_session.rb CHANGED
@@ -3,12 +3,20 @@
3
3
  require "io/console"
4
4
  require "pty"
5
5
  require "securerandom"
6
- require "timeout"
6
+ require "shellwords"
7
7
 
8
8
  # Persistent shell session backed by a PTY with FIFO-based stderr separation.
9
9
  # Commands share working directory, environment variables, and shell history
10
10
  # within a conversation. Multiple tools share the same session.
11
11
  #
12
+ # Auto-recovers from timeouts and crashes: if the shell dies, the next command
13
+ # transparently respawns a fresh shell and restores the working directory.
14
+ #
15
+ # Uses IO.select-based deadlines instead of Timeout.timeout for all PTY reads.
16
+ # Timeout.timeout is unsafe with PTY I/O — it uses Thread.raise which can
17
+ # corrupt mutex state, leave resources inconsistent, and cause exceptions
18
+ # to fire outside handler blocks when nested.
19
+ #
12
20
  # @example
13
21
  # session = ShellSession.new(session_id: 42)
14
22
  # session.run("cd /tmp")
@@ -25,27 +33,37 @@ class ShellSession
25
33
  @mutex = Mutex.new
26
34
  @fifo_path = File.join(Dir.tmpdir, "anima-stderr-#{Process.pid}-#{SecureRandom.hex(8)}")
27
35
  @alive = false
36
+ @finalized = false
28
37
  @pwd = nil
38
+ @read_buffer = +""
29
39
  self.class.cleanup_orphans
30
40
  start
31
41
  self.class.register(self)
32
42
  end
33
43
 
34
- # Execute a command in the persistent shell.
44
+ # Execute a command in the persistent shell. Respawns the shell
45
+ # automatically if the previous session died (timeout, crash, etc.).
35
46
  #
36
47
  # @param command [String] bash command to execute
37
48
  # @return [Hash] with :stdout, :stderr, :exit_code keys on success
38
49
  # @return [Hash] with :error key on failure
39
50
  def run(command)
40
51
  @mutex.synchronize do
41
- return {error: "Shell session is not running"} unless @alive
52
+ return {error: "Shell session is not running"} if @finalized
53
+ restart unless @alive
42
54
  execute_in_pty(command)
43
55
  end
56
+ rescue => error # rubocop:disable Lint/RescueException -- LLM must always get a result hash, never a stack trace
57
+ {error: "#{error.class}: #{error.message}"}
44
58
  end
45
59
 
46
- # Clean up PTY, FIFO, and child process.
60
+ # Clean up PTY, FIFO, and child process. Permanent — the session
61
+ # will not auto-respawn after this call.
47
62
  def finalize
48
- @mutex.synchronize { shutdown }
63
+ @mutex.synchronize do
64
+ @finalized = true
65
+ teardown
66
+ end
49
67
  self.class.unregister(self)
50
68
  end
51
69
 
@@ -73,7 +91,7 @@ class ShellSession
73
91
  # Finalize all live sessions. Called automatically via at_exit.
74
92
  def cleanup_all
75
93
  @sessions_mutex.synchronize do
76
- @sessions.each { |session| session.send(:shutdown) }
94
+ @sessions.each { |session| session.send(:teardown) }
77
95
  @sessions.clear
78
96
  end
79
97
  end
@@ -116,15 +134,54 @@ class ShellSession
116
134
  @alive = true
117
135
  end
118
136
 
137
+ # Shuts down the current shell and spawns a fresh one, restoring the
138
+ # previous working directory. Called automatically when @alive is false.
139
+ def restart
140
+ saved_pwd = @pwd
141
+ teardown
142
+ @fifo_path = File.join(Dir.tmpdir, "anima-stderr-#{Process.pid}-#{SecureRandom.hex(8)}")
143
+ start
144
+ restore_working_directory(saved_pwd)
145
+ end
146
+
147
+ # Restores the shell's working directory after a respawn.
148
+ # Skips silently if the directory no longer exists.
149
+ #
150
+ # @param saved_pwd [String, nil] directory path to restore
151
+ # @return [void]
152
+ def restore_working_directory(saved_pwd)
153
+ return unless saved_pwd && File.directory?(saved_pwd)
154
+ execute_in_pty("cd #{Shellwords.shellescape(saved_pwd)}")
155
+ end
156
+
119
157
  def create_fifo
120
- File.mkfifo(@fifo_path)
158
+ File.mkfifo(@fifo_path, 0o600)
121
159
  rescue Errno::EEXIST
122
160
  # FIFO already exists — reuse it
123
161
  end
124
162
 
163
+ # Env vars that prevent interactive pagers and credential prompts from
164
+ # hanging the PTY. We need a PTY (not pipes) for pwd tracking via /proc
165
+ # and signal handling, but this makes programs think they're on a terminal
166
+ # and launch pagers. No single switch disables all pagers — each tool has
167
+ # its own env var — so we set a comprehensive list plus LESS flags as a
168
+ # safety net for direct `less` invocations.
169
+ SHELL_ENV = {
170
+ "TERM" => "dumb",
171
+ "PAGER" => "cat", # Default pager for most Unix tools
172
+ "LESS" => "-eFRX", # Safety net: make less auto-exit at EOF, no screen clear
173
+ "GIT_PAGER" => "cat", # Git checks this before PAGER
174
+ "MANPAGER" => "cat", # man pages
175
+ "SYSTEMD_PAGER" => "", # journalctl, systemctl (empty = disable)
176
+ "BAT_PAGER" => "cat", # bat (cat alternative)
177
+ "AWS_PAGER" => "", # AWS CLI v2 (empty = disable)
178
+ "PSQL_PAGER" => "cat", # PostgreSQL psql
179
+ "GIT_TERMINAL_PROMPT" => "0" # Fail immediately instead of prompting for credentials
180
+ }.freeze
181
+
125
182
  def spawn_shell
126
183
  @pty_stdout, @pty_stdin, @pid = PTY.spawn(
127
- {"TERM" => "dumb"},
184
+ SHELL_ENV,
128
185
  "bash", "--norc", "--noprofile"
129
186
  )
130
187
  # Disable terminal echo via termios before bash can echo our commands.
@@ -165,45 +222,57 @@ class ShellSession
165
222
  @pty_stdin.puts "PS1=''"
166
223
  @pty_stdin.puts "exec 2>#{@fifo_path}"
167
224
  @pty_stdin.puts "echo '#{marker}'"
168
- consume_until(marker)
225
+ unless consume_until(marker, deadline: monotonic_now + 10)
226
+ raise IOError, "Shell initialization timed out"
227
+ end
169
228
  end
170
229
 
171
230
  def execute_in_pty(command)
172
231
  clear_stderr
173
232
  marker = "__ANIMA_#{SecureRandom.hex(8)}__"
174
233
  timeout = Anima::Settings.command_timeout
234
+ deadline = monotonic_now + timeout
175
235
 
176
- Timeout.timeout(timeout) do
177
- # All on one line: run command, capture exit code, ensure newline
178
- # before marker so output without trailing newline doesn't merge.
179
- @pty_stdin.puts "#{command}; __anima_ec=$?; echo; echo '#{marker}' $__anima_ec"
236
+ @pty_stdin.puts "#{command}; __anima_ec=$?; echo; echo '#{marker}' $__anima_ec"
180
237
 
181
- stdout, exit_code = read_until_marker(marker)
182
- update_pwd
183
- stderr = drain_stderr
238
+ stdout, exit_code = read_until_marker(marker, deadline: deadline)
184
239
 
185
- {
186
- stdout: truncate(stdout),
187
- stderr: truncate(stderr),
188
- exit_code: exit_code
189
- }
240
+ if exit_code.nil?
241
+ recover_from_timeout
242
+ stderr = drain_stderr
243
+ parts = ["Command timed out after #{timeout} seconds."]
244
+ parts << "Partial stdout:\n#{truncate(stdout)}" unless stdout.empty?
245
+ parts << "stderr:\n#{truncate(stderr)}" unless stderr.empty?
246
+ return {error: parts.join("\n\n")}
190
247
  end
191
- rescue Timeout::Error
192
- recover_from_timeout
193
- {error: "Command timed out after #{timeout} seconds"}
194
- rescue Errno::EIO
248
+
249
+ update_pwd
250
+ stderr = drain_stderr
251
+
252
+ {
253
+ stdout: truncate(stdout),
254
+ stderr: truncate(stderr),
255
+ exit_code: exit_code
256
+ }
257
+ rescue Errno::EIO, IOError
195
258
  @alive = false
196
259
  {error: "Shell session terminated unexpectedly"}
197
- rescue => error
260
+ rescue => error # rubocop:disable Lint/RescueException -- LLM must always get a result hash, never a stack trace
198
261
  {error: "#{error.class}: #{error.message}"}
199
262
  end
200
263
 
201
- def read_until_marker(marker)
264
+ # Reads lines from the PTY until the marker appears.
265
+ #
266
+ # @param marker [String] unique marker to detect command completion
267
+ # @param deadline [Float] monotonic clock deadline
268
+ # @return [Array(String, Integer)] stdout and exit code on success
269
+ # @return [Array(String, nil)] partial stdout and nil exit code on timeout
270
+ def read_until_marker(marker, deadline:)
202
271
  lines = []
203
272
  exit_code = nil
204
273
 
205
274
  loop do
206
- line = @pty_stdout.gets
275
+ line = gets_with_deadline(deadline)
207
276
  break if line.nil?
208
277
 
209
278
  line = line.chomp.delete("\r")
@@ -219,26 +288,70 @@ class ShellSession
219
288
  # Strip trailing empty line added by our separator echo
220
289
  lines.pop if lines.last == ""
221
290
 
222
- [lines.join("\n"), exit_code || -1]
291
+ [lines.join("\n"), exit_code]
223
292
  end
224
293
 
225
- def consume_until(marker)
294
+ # Reads and discards PTY output until the marker appears or deadline expires.
295
+ #
296
+ # @param marker [String] unique marker to wait for
297
+ # @param deadline [Float] monotonic clock deadline
298
+ # @return [Boolean] true if marker was found, false if deadline expired
299
+ # @raise [Errno::EIO] when the PTY child process has exited
300
+ # @raise [IOError] when the PTY file descriptor is closed
301
+ def consume_until(marker, deadline:)
226
302
  loop do
227
- line = @pty_stdout.gets
228
- break if line.nil?
229
- break if line.chomp.delete("\r").include?(marker)
303
+ line = gets_with_deadline(deadline)
304
+ return false if line.nil?
305
+ return true if line.chomp.delete("\r").include?(marker)
306
+ end
307
+ end
308
+
309
+ # Reads a single line from the PTY, respecting a deadline.
310
+ # Caller must hold @mutex — @read_buffer is not independently synchronized.
311
+ #
312
+ # Uses IO.select for safe, non-interruptive timeout handling instead of
313
+ # Timeout.timeout (which uses Thread.raise that can corrupt mutex state
314
+ # and leave resources inconsistent).
315
+ #
316
+ # @param deadline [Float] monotonic clock deadline
317
+ # @return [String] line including trailing newline
318
+ # @return [nil] if deadline expired
319
+ # @raise [Errno::EIO] when the PTY child process exits (Linux)
320
+ # @raise [IOError] when the PTY file descriptor is closed
321
+ def gets_with_deadline(deadline)
322
+ loop do
323
+ if (idx = @read_buffer.index("\n"))
324
+ return @read_buffer.slice!(0..idx)
325
+ end
326
+
327
+ remaining = deadline - monotonic_now
328
+ return nil if remaining <= 0
329
+
330
+ ready = IO.select([@pty_stdout], nil, nil, remaining)
331
+ return nil unless ready
332
+
333
+ begin
334
+ @read_buffer << @pty_stdout.read_nonblock(4096)
335
+ rescue IO::WaitReadable
336
+ # Spurious wakeup from IO.select — retry
337
+ end
230
338
  end
231
339
  end
232
340
 
233
341
  # Sends Ctrl+C to interrupt the running command and drains leftover output.
234
- # If recovery fails, marks the session as dead.
342
+ # If recovery fails, marks the session as dead (will be respawned on next run).
343
+ #
344
+ # @return [void]
345
+ # @raise [Errno::EIO] when the PTY child process has exited
346
+ # @raise [IOError] when the PTY file descriptor is closed
235
347
  def recover_from_timeout
236
348
  @pty_stdin.write("\x03")
237
349
  sleep 0.1
238
350
  marker = "__ANIMA_RECOVER_#{SecureRandom.hex(8)}__"
239
351
  @pty_stdin.puts "echo '#{marker}'"
240
- Timeout.timeout(3) { consume_until(marker) }
241
- rescue Timeout::Error, Errno::EIO, IOError
352
+ recovered = consume_until(marker, deadline: monotonic_now + 3)
353
+ @alive = false unless recovered
354
+ rescue Errno::EIO, IOError
242
355
  @alive = false
243
356
  end
244
357
 
@@ -283,15 +396,26 @@ class ShellSession
283
396
  "\n\n[Truncated: output exceeded #{max_bytes} bytes]"
284
397
  end
285
398
 
286
- def shutdown
287
- return unless @alive
288
- @alive = false
399
+ def monotonic_now
400
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
401
+ end
289
402
 
290
- begin
291
- pgid = Process.getpgid(@pid)
292
- Process.kill("TERM", -pgid)
293
- rescue Errno::ESRCH, Errno::EPERM
294
- # Process group already gone
403
+ # Unconditionally cleans up all shell resources (PTY, FIFO, child process).
404
+ # Does NOT short-circuit when @alive is already false — this ensures leaked
405
+ # processes are reaped even after failed recovery marked the session dead.
406
+ #
407
+ # @return [void]
408
+ def teardown
409
+ @alive = false
410
+ @read_buffer = +""
411
+
412
+ if @pid
413
+ begin
414
+ pgid = Process.getpgid(@pid)
415
+ Process.kill("TERM", -pgid)
416
+ rescue Errno::ESRCH, Errno::EPERM
417
+ # Process group already gone
418
+ end
295
419
  end
296
420
 
297
421
  begin
@@ -307,28 +431,33 @@ class ShellSession
307
431
  end
308
432
 
309
433
  begin
434
+ @stderr_thread&.join(1)
310
435
  @stderr_thread&.kill
311
436
  rescue ThreadError
312
437
  # Thread already dead
313
438
  end
314
439
 
315
- File.delete(@fifo_path) if File.exist?(@fifo_path)
316
-
317
- begin
318
- # Non-blocking reap with SIGKILL fallback if process doesn't exit in time
319
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2
320
- loop do
321
- _, status = Process.wait2(@pid, Process::WNOHANG)
322
- break if status
323
- if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
324
- Process.kill("KILL", @pid)
325
- Process.wait(@pid)
326
- break
440
+ File.delete(@fifo_path) if @fifo_path && File.exist?(@fifo_path)
441
+
442
+ if @pid
443
+ begin
444
+ # Non-blocking reap with SIGKILL fallback if process doesn't exit in time
445
+ deadline = monotonic_now + 2
446
+ loop do
447
+ _, status = Process.wait2(@pid, Process::WNOHANG)
448
+ break if status
449
+ if monotonic_now > deadline
450
+ Process.kill("KILL", @pid)
451
+ Process.wait(@pid)
452
+ break
453
+ end
454
+ sleep 0.05
327
455
  end
328
- sleep 0.05
456
+ rescue Errno::ECHILD, Errno::ESRCH
457
+ # Already reaped
329
458
  end
330
- rescue Errno::ECHILD, Errno::ESRCH
331
- # Already reaped
459
+
460
+ @pid = nil
332
461
  end
333
462
  end
334
463
  end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # No custom Rake tasks needed — FTS5 virtual tables are handled by:
4
+ # - Migration: creates/drops the FTS5 table and triggers
5
+ # - Initializer (config/initializers/fts5_schema_dump.rb): skips virtual
6
+ # tables during schema dump since they can't be expressed in Ruby DSL