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.
- checksums.yaml +4 -4
- data/.gitattributes +1 -0
- data/.reek.yml +47 -0
- data/README.md +60 -26
- data/anima-core.gemspec +4 -1
- data/app/channels/session_channel.rb +29 -10
- data/app/decorators/tool_call_decorator.rb +7 -3
- data/app/decorators/tool_decorator.rb +57 -0
- data/app/decorators/tool_response_decorator.rb +12 -4
- data/app/decorators/web_get_tool_decorator.rb +102 -0
- data/app/jobs/agent_request_job.rb +90 -23
- data/app/jobs/mneme_job.rb +51 -0
- data/app/jobs/passive_recall_job.rb +29 -0
- data/app/models/concerns/event/broadcasting.rb +18 -0
- data/app/models/event.rb +10 -0
- data/app/models/goal.rb +27 -0
- data/app/models/goal_pinned_event.rb +11 -0
- data/app/models/pinned_event.rb +41 -0
- data/app/models/session.rb +335 -6
- data/app/models/snapshot.rb +76 -0
- data/config/initializers/event_subscribers.rb +14 -3
- data/config/initializers/fts5_schema_dump.rb +21 -0
- data/db/migrate/20260321080000_create_mneme_schema.rb +32 -0
- data/db/migrate/20260321120000_create_pinned_events.rb +27 -0
- data/db/migrate/20260321140000_create_events_fts_index.rb +77 -0
- data/db/migrate/20260321140100_add_recalled_event_ids_to_sessions.rb +10 -0
- data/lib/agent_loop.rb +63 -20
- data/lib/analytical_brain/runner.rb +158 -65
- data/lib/analytical_brain/tools/assign_nickname.rb +76 -0
- data/lib/analytical_brain/tools/finish_goal.rb +6 -1
- data/lib/anima/cli.rb +2 -1
- data/lib/anima/installer.rb +11 -12
- data/lib/anima/settings.rb +41 -0
- data/lib/anima/version.rb +1 -1
- data/lib/events/bounce_back.rb +37 -0
- data/lib/events/subscribers/agent_dispatcher.rb +29 -0
- data/lib/events/subscribers/persister.rb +17 -0
- data/lib/events/subscribers/subagent_message_router.rb +102 -0
- data/lib/events/subscribers/transient_broadcaster.rb +36 -0
- data/lib/llm/client.rb +16 -8
- data/lib/mneme/compressed_viewport.rb +200 -0
- data/lib/mneme/l2_runner.rb +138 -0
- data/lib/mneme/passive_recall.rb +69 -0
- data/lib/mneme/runner.rb +254 -0
- data/lib/mneme/search.rb +150 -0
- data/lib/mneme/tools/attach_events_to_goals.rb +107 -0
- data/lib/mneme/tools/everything_ok.rb +24 -0
- data/lib/mneme/tools/save_snapshot.rb +68 -0
- data/lib/mneme.rb +29 -0
- data/lib/providers/anthropic.rb +57 -13
- data/lib/shell_session.rb +188 -59
- data/lib/tasks/fts5.rake +6 -0
- data/lib/tools/remember.rb +179 -0
- data/lib/tools/spawn_specialist.rb +21 -9
- data/lib/tools/spawn_subagent.rb +22 -11
- data/lib/tools/subagent_prompts.rb +20 -3
- data/lib/tools/web_get.rb +15 -6
- data/lib/tui/app.rb +222 -125
- data/lib/tui/decorators/base_decorator.rb +165 -0
- data/lib/tui/decorators/bash_decorator.rb +20 -0
- data/lib/tui/decorators/edit_decorator.rb +19 -0
- data/lib/tui/decorators/read_decorator.rb +24 -0
- data/lib/tui/decorators/think_decorator.rb +36 -0
- data/lib/tui/decorators/web_get_decorator.rb +19 -0
- data/lib/tui/decorators/write_decorator.rb +19 -0
- data/lib/tui/flash.rb +139 -0
- data/lib/tui/formatting.rb +28 -0
- data/lib/tui/height_map.rb +93 -0
- data/lib/tui/message_store.rb +25 -1
- data/lib/tui/performance_logger.rb +90 -0
- data/lib/tui/screens/chat.rb +358 -133
- data/templates/config.toml +40 -0
- metadata +83 -4
- data/CHANGELOG.md +0 -80
- data/Gemfile +0 -17
- 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
|
data/lib/providers/anthropic.rb
CHANGED
|
@@ -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
|
-
|
|
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 =>
|
|
73
|
-
raise TransientError, "#{
|
|
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 =>
|
|
97
|
-
raise TransientError, "#{
|
|
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.
|
|
102
|
-
"/v1/
|
|
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 "
|
|
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"}
|
|
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
|
|
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(:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
update_pwd
|
|
183
|
-
stderr = drain_stderr
|
|
238
|
+
stdout, exit_code = read_until_marker(marker, deadline: deadline)
|
|
184
239
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
291
|
+
[lines.join("\n"), exit_code]
|
|
223
292
|
end
|
|
224
293
|
|
|
225
|
-
|
|
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 =
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
|
287
|
-
|
|
288
|
-
|
|
399
|
+
def monotonic_now
|
|
400
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
401
|
+
end
|
|
289
402
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
456
|
+
rescue Errno::ECHILD, Errno::ESRCH
|
|
457
|
+
# Already reaped
|
|
329
458
|
end
|
|
330
|
-
|
|
331
|
-
|
|
459
|
+
|
|
460
|
+
@pid = nil
|
|
332
461
|
end
|
|
333
462
|
end
|
|
334
463
|
end
|
data/lib/tasks/fts5.rake
ADDED
|
@@ -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
|