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

Instance Method Summary collapse

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_rootObject

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

Parameters:

  • force_reasoning_content_pad (Boolean) (defaults to: false)

    forwarded to MessageHistory, enables one-shot pad-and-retry for thinking-mode providers that require reasoning_content on every assistant message.



272
273
274
275
276
277
# File 'lib/clacky/agent/time_machine.rb', line 272

def active_messages(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

Parameters:

  • limit (Integer) (defaults to: 10)

    Maximum number of recent tasks to return

Returns:

  • (Array<Hash>)

    Task history with metadata



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 = extract_message_text(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.expand_path(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

Parameters:

  • task_id (Integer)

    Target task ID



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_taskObject

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_taskObject

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