Class: Octo::SessionManager

Inherits:
Object
  • Object
show all
Defined in:
lib/octo/session_manager.rb

Constant Summary collapse

SESSIONS_DIR =
File.join(Dir.home, ".octo", "sessions")

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(sessions_dir: nil) ⇒ SessionManager

Returns a new instance of SessionManager.



19
20
21
22
# File 'lib/octo/session_manager.rb', line 19

def initialize(sessions_dir: nil)
  @sessions_dir = sessions_dir || SESSIONS_DIR
  ensure_sessions_dir
end

Class Method Details

.generate_idObject

Generate a new unique session ID (16-char hex string). This is the single authoritative source for session IDs — all components (Agent, SessionRegistry) should receive an ID generated here rather than creating their own.



15
16
17
# File 'lib/octo/session_manager.rb', line 15

def self.generate_id
  SecureRandom.hex(8)
end

Instance Method Details

#all_sessions(current_dir: nil, limit: nil) ⇒ Object

All sessions from disk, newest-first (sorted by created_at). Optional filters:

current_dir: (String) if given, sessions matching working_dir come first
limit:       (Integer) max number of sessions to return


152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/octo/session_manager.rb', line 152

def all_sessions(current_dir: nil, limit: nil)
  sessions = Dir.glob(File.join(@sessions_dir, "*.json")).filter_map do |filepath|
    load_session_file(filepath)
  end.sort_by { |s| s[:created_at] || "" }.reverse

  if current_dir
    current_sessions = sessions.select { |s| s[:working_dir] == current_dir }
    other_sessions   = sessions.reject { |s| s[:working_dir] == current_dir }
    sessions = current_sessions + other_sessions
  end

  limit ? sessions.first(limit) : sessions
end

#append_message_log(session_id, messages) ⇒ Object

── Incremental message log (.jsonl) for crash recovery ──────────────────

During an agent run, messages are appended to a per-session .jsonl file after each iteration (think + act + observe). On normal completion the .jsonl is deleted because the full session is saved to .json. If the server crashes (kill -9, OOM, etc.) the .jsonl survives and is merged back into the session on next startup.

Each line is a standalone JSON object: { “t”: unix_timestamp, “msg”: message_hash } This makes the file resilient to partial writes — we can parse every complete line and drop the trailing incomplete one.



211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
# File 'lib/octo/session_manager.rb', line 211

def append_message_log(session_id, messages)
  return if messages.nil? || messages.empty?

  path = jsonl_path(session_id)
  File.open(path, "a") do |f|
    messages.each do |msg|
      f.puts JSON.generate({ t: Time.now.to_f, msg: msg })
    end
  end
rescue => e
  Octo::Logger.warn("session_manager.append_message_log_failed",
    session_id: session_id,
    error: e.message
  )
end

#chunks_for_current(session_id, created_at) ⇒ Array<Hash>

Discover all chunk MD files on disk for a given session. Returns them sorted by chunk index ascending (oldest first).

Parameters:

  • session_id (String)

    full session id (or at least first 8 chars)

  • created_at (String)

    ISO-8601 timestamp used in the base filename

Returns:

  • (Array<Hash>)

    each with :index, :path, :basename, :topics



102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/octo/session_manager.rb', line 102

def chunks_for_current(session_id, created_at)
  return [] unless session_id && created_at

  base = chunk_base_name(session_id, created_at)
  pattern = File.join(@sessions_dir, "#{base}-chunk-*.md")

  Dir.glob(pattern).filter_map do |path|
    basename = File.basename(path)
    # Extract integer index from "<base>-chunk-<N>.md"
    m = basename.match(/-chunk-(\d+)\.md\z/)
    next nil unless m

    {
      index: m[1].to_i,
      path: path,
      basename: basename,
      topics: read_chunk_topics(path)
    }
  end.sort_by { |c| c[:index] }
end

#cleanup(days: 90) ⇒ Object

Delete sessions not accessed within the given number of days (default: 90). Returns count of deleted sessions.



173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/octo/session_manager.rb', line 173

def cleanup(days: 90)
  cutoff = Time.now - (days * 24 * 60 * 60)
  deleted = 0
  Dir.glob(File.join(@sessions_dir, "*.json")).each do |filepath|
    session = load_session_file(filepath)
    next unless session
    if Time.parse(session[:updated_at]) < cutoff
      delete_session_with_chunks(filepath)
      deleted += 1
    end
  end
  deleted
end

#cleanup_by_count(keep:) ⇒ Object

Keep only the most recent N sessions by created_at; delete the rest. Returns count of deleted sessions.



189
190
191
192
193
194
195
196
197
# File 'lib/octo/session_manager.rb', line 189

def cleanup_by_count(keep:)
  sessions = all_sessions # already sorted newest-first
  return 0 if sessions.size <= keep

  sessions[keep..].each do |session|
    filepath = File.join(@sessions_dir, generate_filename(session[:session_id], session[:created_at]))
    delete_session_with_chunks(filepath) if File.exist?(filepath)
  end.size
end

#delete(session_id) ⇒ Object

Physical delete — removes disk file + associated chunk files. Returns true if found and deleted, false if not found.



56
57
58
59
60
61
62
63
# File 'lib/octo/session_manager.rb', line 56

def delete(session_id)
  session = all_sessions.find { |s| s[:session_id].to_s.start_with?(session_id.to_s) }
  return false unless session

  filepath = File.join(@sessions_dir, generate_filename(session[:session_id], session[:created_at]))
  delete_session_with_chunks(filepath)
  true
end

#delete_message_log(session_id) ⇒ Object



247
248
249
250
251
252
253
254
255
# File 'lib/octo/session_manager.rb', line 247

def delete_message_log(session_id)
  path = jsonl_path(session_id)
  File.delete(path) if File.exist?(path)
rescue => e
  Octo::Logger.warn("session_manager.delete_message_log_failed",
    session_id: session_id,
    error: e.message
  )
end

#delete_session_with_chunks(json_filepath) ⇒ Object

Delete a session JSON file and all its associated chunk MD files.



373
374
375
376
377
# File 'lib/octo/session_manager.rb', line 373

def delete_session_with_chunks(json_filepath)
  File.delete(json_filepath) if File.exist?(json_filepath)
  base = File.basename(json_filepath, ".json")
  Dir.glob(File.join(@sessions_dir, "#{base}-chunk-*.md")).each { |f| File.delete(f) }
end

#ensure_sessions_dirObject



319
320
321
# File 'lib/octo/session_manager.rb', line 319

def ensure_sessions_dir
  FileUtils.mkdir_p(@sessions_dir) unless Dir.exist?(@sessions_dir)
end

#files_for(session_id) ⇒ Object

Return the on-disk files associated with a session: the main JSON file and any “base-chunk-*.md” archive files. Used by the export / download endpoint so the UI can bundle everything a user may need for debugging. Returns nil if the session is not found, or a Hash:

{
  session:   Hash,        # the loaded session metadata
  json_path: String,      # absolute path to session.json
  chunks:    [String]     # sorted absolute paths to chunk *.md files
}


74
75
76
77
78
79
80
81
82
83
84
85
# File 'lib/octo/session_manager.rb', line 74

def files_for(session_id)
  session = all_sessions.find { |s| s[:session_id].to_s.start_with?(session_id.to_s) }
  return nil unless session

  json_path = File.join(@sessions_dir, generate_filename(session[:session_id], session[:created_at]))
  return nil unless File.exist?(json_path)

  base   = File.basename(json_path, ".json")
  chunks = Dir.glob(File.join(@sessions_dir, "#{base}-chunk-*.md")).sort

  { session: session, json_path: json_path, chunks: chunks }
end

#generate_filename(session_id, created_at) ⇒ Object



323
324
325
# File 'lib/octo/session_manager.rb', line 323

def generate_filename(session_id, created_at)
  "#{chunk_base_name(session_id, created_at)}.json"
end

#last_saved_pathObject

Path of the last saved session file.



45
46
47
# File 'lib/octo/session_manager.rb', line 45

def last_saved_path
  @last_saved_path
end

#latest_for_directory(working_dir) ⇒ Object

Return the most recent session for a given working directory, or nil.



167
168
169
# File 'lib/octo/session_manager.rb', line 167

def latest_for_directory(working_dir)
  all_sessions(current_dir: working_dir).first
end

#load(session_id) ⇒ Object

Load a specific session by ID. Returns nil if not found.



50
51
52
# File 'lib/octo/session_manager.rb', line 50

def load(session_id)
  all_sessions.find { |s| s[:session_id].to_s.start_with?(session_id.to_s) }
end

#load_session_file(filepath) ⇒ Object



379
380
381
382
383
# File 'lib/octo/session_manager.rb', line 379

def load_session_file(filepath)
  JSON.parse(File.read(filepath), symbolize_names: true)
rescue JSON::ParserError, Errno::ENOENT
  nil
end

#next_chunk_index(session_id, created_at) ⇒ Object

Next unused chunk index for a session, derived from disk. This is the ONLY correct way to compute the next chunk index —counting compressed_summary messages in history caps at 1 after the second compression (rebuild keeps only the latest summary) and in-memory counters reset on process restart.



128
129
130
131
# File 'lib/octo/session_manager.rb', line 128

def next_chunk_index(session_id, created_at)
  existing = chunks_for_current(session_id, created_at)
  (existing.map { |c| c[:index] }.max || 0) + 1
end

#read_message_log(session_id) ⇒ Object



227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
# File 'lib/octo/session_manager.rb', line 227

def read_message_log(session_id)
  path = jsonl_path(session_id)
  return [] unless File.exist?(path)

  File.readlines(path).filter_map do |line|
    next if line.strip.empty?

    parsed = JSON.parse(line, symbolize_names: true)
    parsed[:msg]
  rescue JSON::ParserError
    nil
  end
rescue => e
  Octo::Logger.warn("session_manager.read_message_log_failed",
    session_id: session_id,
    error: e.message
  )
  []
end

#recover_jsonl_sessionsObject

Scan for orphaned .jsonl files and merge their messages back into the corresponding session .json files. Called once at server startup.



259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
# File 'lib/octo/session_manager.rb', line 259

def recover_jsonl_sessions
  jsonl_files = Dir.glob(File.join(@sessions_dir, "*.jsonl"))
  return 0 if jsonl_files.empty?

  recovered = 0
  jsonl_files.each do |path|
    session_id = File.basename(path, ".jsonl")
    session = load(session_id)
    unless session
      Octo::Logger.warn("session_manager.recover_jsonl_no_session",
        session_id: session_id,
        path: path
      )
      next
    end

    log_messages = read_message_log(session_id)
    next if log_messages.empty?

    existing = session[:messages] || []
    # Avoid duplicates: if the last N messages of existing already match
    # the first N of log_messages, skip the overlap.
    merged = merge_without_duplicates(existing, log_messages)
    session[:messages] = merged
    session[:updated_at] = Time.now.iso8601

    save(session)
    delete_message_log(session_id)
    recovered += 1

    Octo::Logger.info("session_manager.recovered_from_jsonl",
      session_id: session_id,
      messages_recovered: log_messages.size,
      total_messages: merged.size
    )
  end
  recovered
end

#save(session_data) ⇒ Object

Save a session. Returns the file path.



25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# File 'lib/octo/session_manager.rb', line 25

def save(session_data)
  filename = generate_filename(session_data[:session_id], session_data[:created_at])
  filepath = File.join(@sessions_dir, filename)

  File.write(filepath, JSON.pretty_generate(session_data))
  FileUtils.chmod(0o600, filepath)

  @last_saved_path = filepath

  # Keep only the most recent 200 sessions (best-effort, never block save)
  begin
    cleanup_by_count(keep: 200)
  rescue Exception # rubocop:disable Lint/RescueException
    # Cleanup is non-critical; swallow all errors (including AgentInterrupted)
  end

  filepath
end

#write_chunk(session_id, created_at, chunk_index, md_content) ⇒ Object

Write a chunk MD file to disk. Returns the absolute path. Caller is responsible for generating the MD content — this method only handles filesystem concerns (path assembly, write, chmod).



136
137
138
139
140
141
142
143
144
145
146
# File 'lib/octo/session_manager.rb', line 136

def write_chunk(session_id, created_at, chunk_index, md_content)
  return nil unless session_id && created_at

  base = chunk_base_name(session_id, created_at)
  chunk_path = File.join(@sessions_dir, "#{base}-chunk-#{chunk_index}.md")

  File.write(chunk_path, md_content)
  FileUtils.chmod(0o600, chunk_path)

  chunk_path
end