Module: Clacky::Agent::TimeMachine
- Included in:
- Clacky::Agent
- Defined in:
- lib/clacky/agent/time_machine.rb
Overview
Time Machine module for task history management with undo/redo support.
Snapshots capture the BEFORE state of each file the moment a task first touches it (via record_file_before_change). task-N/ therefore holds “what every file looked like just before task N changed it” — including an .absent marker for files that did not yet exist. Restoring to task T replays the earliest BEFORE recorded in any task after T, which equals the on-disk state at the end of task T.
Constant Summary collapse
- ABSENT_MARKER =
Marker file written alongside a snapshot path when the original file did not exist before the task changed it. Restoring such an entry deletes the file instead of copying content back.
".clacky-absent"
Class Method Summary collapse
-
.delete_session_snapshots(session_id) ⇒ Object
Remove all snapshots for a session.
-
.session_dir(session_id) ⇒ Object
Snapshot directory for a single session.
-
.snapshots_root ⇒ Object
Root directory holding per-session file snapshots.
Instance Method Summary collapse
-
#active_messages(force_reasoning_content_pad: false) ⇒ Object
Filter messages to only the active task’s ancestor chain.
-
#get_child_tasks(task_id) ⇒ Object
Get children of a task (for branch detection).
-
#get_task_history(limit: 10) ⇒ Array<Hash>
Get task history with summaries for UI display.
-
#record_file_before_change(file_path) ⇒ Object
Record a file’s BEFORE state for the current task, the first time the task touches it.
-
#restore_to_task_state(task_id) ⇒ Object
Restore files to the on-disk state at the END of the given task.
-
#start_new_task ⇒ Object
Start a new task and establish parent relationship Made public for testing.
-
#switch_to_task(target_task_id) ⇒ Object
Switch to specific task (for redo or branch switching).
-
#undo_last_task ⇒ Object
Undo to parent task.
Class Method Details
.delete_session_snapshots(session_id) ⇒ Object
Remove all snapshots for a session. Safe to call when none exist.
33 34 35 36 37 |
# File 'lib/clacky/agent/time_machine.rb', line 33 def self.delete_session_snapshots(session_id) return if session_id.to_s.empty? FileUtils.rm_rf(session_dir(session_id)) end |
.session_dir(session_id) ⇒ Object
Snapshot directory for a single session.
28 29 30 |
# File 'lib/clacky/agent/time_machine.rb', line 28 def self.session_dir(session_id) File.join(snapshots_root, session_id.to_s) end |
.snapshots_root ⇒ Object
Root directory holding per-session file snapshots.
23 24 25 |
# File 'lib/clacky/agent/time_machine.rb', line 23 def self.snapshots_root File.join(Dir.home, ".clacky", "snapshots") end |
Instance Method Details
#active_messages(force_reasoning_content_pad: false) ⇒ Object
Filter messages to only the active task’s ancestor chain. After an undo (and especially after sending a NEW message post-undo, which forks a fresh task off the undone point) the history still holds the abandoned/sibling-branch turns. We must send the LLM only the turns on the path from the root to the active task — never undone siblings. Returns API-ready array (strips internal fields + repairs orphaned tool_calls), so this stays consistent with the normal to_api path. Made public for testing
272 273 274 275 276 277 |
# File 'lib/clacky/agent/time_machine.rb', line 272 def (force_reasoning_content_pad: false) @history.to_api( force_reasoning_content_pad: force_reasoning_content_pad, task_chain: active_task_chain ) end |
#get_child_tasks(task_id) ⇒ Object
Get children of a task (for branch detection)
330 331 332 |
# File 'lib/clacky/agent/time_machine.rb', line 330 def get_child_tasks(task_id) @task_parents.select { |_, parent| parent == task_id }.keys end |
#get_task_history(limit: 10) ⇒ Array<Hash>
Get task history with summaries for UI display
337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 |
# File 'lib/clacky/agent/time_machine.rb', line 337 def get_task_history(limit: 10) return [] if @current_task_id == 0 chain = active_task_chain tasks = [] (1..@current_task_id).to_a.reverse.take(limit).reverse.each do |task_id| # Find first user message for this task first_user_msg = @history.to_a.find do |msg| msg[:task_id] == task_id && msg[:role] == "user" end summary = if first_user_msg content = (first_user_msg[:content]) # Truncate to 60 characters (including "...") content.length > 60 ? "#{content[0...57]}..." : content else "Task #{task_id}" end # Status relative to the ACTIVE task chain (not a linear id compare), # so undone/abandoned branches are flagged distinctly from the path # the user is currently on. status = if task_id == @active_task_id :current elsif chain.include?(task_id) :past else :undone end # Check if task has branches (multiple children) children = get_child_tasks(task_id) has_branches = children.length > 1 tasks << { task_id: task_id, summary: summary, status: status, has_branches: has_branches } end tasks end |
#record_file_before_change(file_path) ⇒ Object
Record a file’s BEFORE state for the current task, the first time the task touches it. Call this immediately before a tool mutates the file. Subsequent calls within the same task are no-ops so the earliest state (the true “before this task” snapshot) is preserved. Made public for testing
76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 |
# File 'lib/clacky/agent/time_machine.rb', line 76 def record_file_before_change(file_path) return if @current_task_id.to_i <= 0 full_path = File.(file_path.to_s, @working_dir) rel = snapshot_relative_path(full_path) before_dir = File.join(TimeMachine.session_dir(@session_id), "task-#{@current_task_id}", "before") snapshot_file = File.join(before_dir, rel) marker_file = "#{snapshot_file}.#{ABSENT_MARKER}" # Already recorded for this task — keep the earliest capture. return if File.exist?(snapshot_file) || File.exist?(marker_file) # A fresh change to the latest task invalidates its stale AFTER checkpoint. @latest_after_dirty = true FileUtils.mkdir_p(File.dirname(snapshot_file)) if File.exist?(full_path) FileUtils.cp(full_path, snapshot_file) else # File did not exist before this task — mark it so a restore deletes it. FileUtils.touch(marker_file) end rescue StandardError # Snapshotting must never break the actual file operation. end |
#restore_to_task_state(task_id) ⇒ Object
Restore files to the on-disk state at the END of the given task.
History is a TREE (undo + a new message forks a sibling branch), so a linear “replay every task after T” model is wrong: a sibling branch’s files would leak in or get wrongly deleted. Instead we reconstruct T’s end state from the task tree:
* Each task owns an AFTER snapshot = the content of the files it
touched, as they looked when that task finished.
* To rebuild "end of task T", walk T's ancestor chain (T -> root).
For every file ever touched in the whole session, the winning
content is the closest ancestor (starting at T) whose AFTER holds
that file. If no ancestor on the chain ever touched it, the file
did not exist at T and is removed.
Made public for testing
156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 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 222 223 224 225 226 227 228 229 |
# File 'lib/clacky/agent/time_machine.rb', line 156 def restore_to_task_state(task_id) # Freeze the task we're leaving so a later forward switch can return. checkpoint_latest_task_after session_root = TimeMachine.session_dir(@session_id) # Ancestor chain from the target task up to (and excluding) root 0, # ordered nearest-first so the closest writer of each file wins. ancestors = [] tid = task_id until tid.nil? || tid <= 0 || ancestors.include?(tid) ancestors << tid tid = @task_parents[tid] end # Every file ever touched by any task in this session. all_rels = Set.new Dir.glob(File.join(session_root, "task-*", "before", "**", "*"), File::FNM_DOTMATCH).each do |path| next if File.directory?(path) rel = path.sub(%r{\A.*/before/}, "") rel = rel.sub(/\.#{Regexp.escape(ABSENT_MARKER)}\z/, "") all_rels << rel end all_rels.each do |rel| action = :delete source = nil matched = false # Closest ancestor (starting at the target) that produced this file. ancestors.each do |aid| after_dir = File.join(session_root, "task-#{aid}", "after") content_path = File.join(after_dir, rel) absent_path = "#{content_path}.#{ABSENT_MARKER}" if File.exist?(content_path) action = :restore source = content_path matched = true break elsif File.exist?(absent_path) action = :delete matched = true break end end # No task on the chain produced this file. Restore the session's # INITIAL content for it — captured as the earliest BEFORE recorded # for this file by any task (BEFORE = state just before that task # ran; the smallest task id therefore holds the pre-session state). # No BEFORE at all => the file never existed initially, so delete. unless matched initial = earliest_before_snapshot(session_root, rel) if initial action = :restore source = initial else action = :delete end end target = File.join(@working_dir, rel) if action == :delete FileUtils.rm_f(target) else FileUtils.mkdir_p(File.dirname(target)) FileUtils.cp(source, target) end end rescue StandardError raise end |
#start_new_task ⇒ Object
Start a new task and establish parent relationship Made public for testing
48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
# File 'lib/clacky/agent/time_machine.rb', line 48 def start_new_task # Before the currently-active task stops being the latest, freeze its # end-of-task disk state into an AFTER snapshot. Without this, a task # that later gets superseded by a sibling branch would have no record # of its result, making a forward switch back to it impossible. checkpoint_latest_task_after parent_id = @active_task_id @current_task_id += 1 @active_task_id = @current_task_id @task_parents[@current_task_id] = parent_id # Claim ownership of this task for the current thread. # If a stale thread (e.g. a slow subagent) wakes up later it will see # @task_thread != Thread.current via check_stale! and self-terminate # before it can write to history. @task_thread = Thread.current @latest_after_dirty = true @current_task_id end |
#switch_to_task(target_task_id) ⇒ Object
Switch to specific task (for redo or branch switching)
314 315 316 317 318 319 320 321 322 323 324 325 326 327 |
# File 'lib/clacky/agent/time_machine.rb', line 314 def switch_to_task(target_task_id) if target_task_id > @current_task_id || target_task_id < 1 return { success: false, message: "Invalid task ID: #{target_task_id}" } end restore_to_task_state(target_task_id) @active_task_id = target_task_id { success: true, message: "⏩ Switched to task #{target_task_id}", task_id: target_task_id } end |
#undo_last_task ⇒ Object
Undo to parent task. Task 0 represents the original pre-task state, which is reachable from task 1 thanks to its BEFORE snapshots.
297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 |
# File 'lib/clacky/agent/time_machine.rb', line 297 def undo_last_task return { success: false, message: "Already at root task" } if @active_task_id == 0 parent_id = @task_parents[@active_task_id] return { success: false, message: "Already at root task" } if parent_id.nil? restore_to_task_state(parent_id) @active_task_id = parent_id { success: true, message: "⏪ Undone to task #{parent_id}", task_id: parent_id } end |