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.
-
#preview_restore_to_task(task_id) ⇒ Array<Hash>
Preview the file-level effect of restore_to_task_state(task_id) without touching disk.
-
#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.
-
#set_current_task_title(title) ⇒ Object
Update the title of the currently-active task.
-
#start_new_task(title: nil) ⇒ 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).
-
#task_change_count(task_id) ⇒ Object
Cheap version of task_diff_files: just count how many distinct files this task touched, so the timeline can grey out no-op tasks without paying for a full diff walk per row.
-
#task_diff_files(task_id) ⇒ Array<Hash>
File-level summary of changes a task introduced.
-
#task_file_diff(task_id, rel_path) ⇒ Hash?
Unified diff of a single file for a task.
-
#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
347 348 349 350 351 352 |
# File 'lib/clacky/agent/time_machine.rb', line 347 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)
405 406 407 |
# File 'lib/clacky/agent/time_machine.rb', line 405 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
549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 |
# File 'lib/clacky/agent/time_machine.rb', line 549 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| = (@task_meta || {})[task_id] || {} summary = if [:title] && ![:title].to_s.empty? [:title] else # Best-effort fallback: scan @history for the task's first real # user message. Returns nothing for tasks that have already been # compressed out — the UI then shows "Task N". first = @history.to_a.find do |msg| msg[:role] == "user" && msg[:task_id] == task_id && !msg[:system_injected] end if first text = (first[:content]).to_s.gsub(/\s+/, " ").strip text.length > 60 ? "#{text[0...57]}..." : text else "Task #{task_id}" end 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, started_at: [:started_at], ended_at: [:ended_at], status: status, has_branches: has_branches, change_count: task_change_count(task_id), } end tasks end |
#preview_restore_to_task(task_id) ⇒ Array<Hash>
Preview the file-level effect of restore_to_task_state(task_id) without touching disk. Compares the resolved restore plan against the current working-dir state and returns only files that would actually change.
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 297 |
# File 'lib/clacky/agent/time_machine.rb', line 271 def preview_restore_to_task(task_id) return [] unless task_id.is_a?(Integer) && task_id >= 0 checkpoint_latest_task_after plan = build_restore_plan(task_id) changes = [] plan.each do |rel, decision| target = File.join(@working_dir, rel) target_exists = File.exist?(target) if decision[:action] == :delete changes << { path: rel, action: "delete" } if target_exists else src = decision[:source] next unless src && File.exist?(src) if !target_exists changes << { path: rel, action: "create" } elsif !files_equal?(src, target) changes << { path: rel, action: "modify" } end end end changes.sort_by { |c| c[:path] } 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
108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 |
# File 'lib/clacky/agent/time_machine.rb', line 108 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
188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 |
# File 'lib/clacky/agent/time_machine.rb', line 188 def restore_to_task_state(task_id) # Freeze the task we're leaving so a later forward switch can return. checkpoint_latest_task_after plan = build_restore_plan(task_id) plan.each do |rel, decision| target = File.join(@working_dir, rel) if decision[:action] == :delete FileUtils.rm_f(target) else FileUtils.mkdir_p(File.dirname(target)) FileUtils.cp(decision[:source], target) end end rescue StandardError raise end |
#set_current_task_title(title) ⇒ Object
Update the title of the currently-active task. Used by callers that only learn the user-facing label after start_new_task has run.
90 91 92 93 94 |
# File 'lib/clacky/agent/time_machine.rb', line 90 def set_current_task_title(title) return if @active_task_id.to_i <= 0 @task_meta[@active_task_id] ||= { started_at: Time.now.to_f, ended_at: nil } @task_meta[@active_task_id][:title] = truncate_task_title(title) end |
#start_new_task(title: nil) ⇒ Object
Start a new task and establish parent relationship Made public for testing
54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 |
# File 'lib/clacky/agent/time_machine.rb', line 54 def start_new_task(title: nil) # 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 # Close out the task we're leaving. if @active_task_id.to_i > 0 && @task_meta[@active_task_id] @task_meta[@active_task_id][:ended_at] ||= Time.now.to_f end parent_id = @active_task_id @current_task_id += 1 @active_task_id = @current_task_id @task_parents[@current_task_id] = parent_id @task_meta[@current_task_id] = { title: title ? truncate_task_title(title) : nil, started_at: Time.now.to_f, ended_at: nil, } # 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)
389 390 391 392 393 394 395 396 397 398 399 400 401 402 |
# File 'lib/clacky/agent/time_machine.rb', line 389 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 |
#task_change_count(task_id) ⇒ Object
Cheap version of task_diff_files: just count how many distinct files this task touched, so the timeline can grey out no-op tasks without paying for a full diff walk per row.
412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 |
# File 'lib/clacky/agent/time_machine.rb', line 412 def task_change_count(task_id) return 0 unless task_id.is_a?(Integer) && task_id > 0 session_root = TimeMachine.session_dir(@session_id) before_dir = File.join(session_root, "task-#{task_id}", "before") after_dir = File.join(session_root, "task-#{task_id}", "after") return 0 unless Dir.exist?(before_dir) return 0 if task_id == @current_task_id && @latest_after_dirty == true && !Dir.exist?(after_dir) rels = Set.new [before_dir, after_dir].each do |root| next unless Dir.exist?(root) Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).each do |path| next if File.directory?(path) rel = path.sub(root + "/", "").sub(/\.#{Regexp.escape(ABSENT_MARKER)}\z/, "") rels << rel end end rels.size end |
#task_diff_files(task_id) ⇒ Array<Hash>
File-level summary of changes a task introduced. Diff is task-N/before vs task-N/after (after is captured by checkpoint_latest_task_after when the task stops being the latest, so this method has no useful answer for the currently-active task — callers get an empty list back).
438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 |
# File 'lib/clacky/agent/time_machine.rb', line 438 def task_diff_files(task_id) return [] unless task_id.is_a?(Integer) && task_id > 0 session_root = TimeMachine.session_dir(@session_id) before_dir = File.join(session_root, "task-#{task_id}", "before") after_dir = File.join(session_root, "task-#{task_id}", "after") return [] unless Dir.exist?(before_dir) return [] if task_id == @current_task_id && @latest_after_dirty == true && !Dir.exist?(after_dir) rels = Set.new [before_dir, after_dir].each do |root| next unless Dir.exist?(root) Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).each do |path| next if File.directory?(path) rel = path.sub(root + "/", "").sub(/\.#{Regexp.escape(ABSENT_MARKER)}\z/, "") rels << rel end end rels.sort.map do |rel| before_file, before_absent = snapshot_paths(before_dir, rel) after_file, after_absent = snapshot_paths(after_dir, rel) status = if before_absent && after_file "added" elsif before_file && after_absent "deleted" elsif before_file && after_file "modified" elsif before_file && !File.exist?(after_dir) # No AFTER captured (e.g. the very latest task) — still surface # what was touched as "modified" so the UI can list the file. "modified" else "modified" end binary = looks_binary?(before_file) || looks_binary?(after_file) { path: rel, status: status, binary: binary } end end |
#task_file_diff(task_id, rel_path) ⇒ Hash?
Unified diff of a single file for a task. Returns nil if either side is missing or binary. text format = “@@ … @@” patch (3-context), ready for the UI to render with a diff renderer.
484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 |
# File 'lib/clacky/agent/time_machine.rb', line 484 def task_file_diff(task_id, rel_path) return nil unless task_id.is_a?(Integer) && task_id > 0 return nil if rel_path.to_s.include?("..") session_root = TimeMachine.session_dir(@session_id) before_dir = File.join(session_root, "task-#{task_id}", "before") after_dir = File.join(session_root, "task-#{task_id}", "after") before_file, before_absent = snapshot_paths(before_dir, rel_path) after_file, after_absent = snapshot_paths(after_dir, rel_path) before_text = before_absent ? "" : (before_file ? read_text_safe(before_file) : nil) after_text = after_absent ? "" : (after_file ? read_text_safe(after_file) : nil) if before_text.nil? && after_text.nil? return nil end # Detect binary on either side: bail out, the UI will render a stub. if (before_file && looks_binary?(before_file)) || (after_file && looks_binary?(after_file)) return { path: rel_path, before: nil, after: nil, patch: nil, binary: true } end require "diffy" unless defined?(Diffy) raw = Diffy::Diff.new(before_text || "", after_text || "", context: 3, include_diff_info: true).to_s(:text) # Strip Diffy's "--- /tmp/diffy.../before" header pair: it leaks # tempfile paths and adds noise the UI doesn't need. patch = raw.sub(/\A(?:---[^\n]*\n[^\n]*\n)/, "") { path: rel_path, before: before_text, after: after_text, patch: patch, binary: false } 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.
372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 |
# File 'lib/clacky/agent/time_machine.rb', line 372 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 |