From 34d7bfa984a992eb1c5f6c214702f282d0494952 Mon Sep 17 00:00:00 2001 From: Minerva Sky <258328972+minerva-sky@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:06:51 -0400 Subject: [PATCH] Guard ExecutionJournal#record against JSON::GeneratorError A journal write runs from after_task_success, so a payload the json gem refuses to serialize (a BINARY-encoded non-ASCII string, NaN, and on newer json versions a payload with both :k and "k") raised after the task had already succeeded. Rescue GeneratorError and write a fallback line that keeps event, at, task_id and description so replay still counts the task, plus payload_error and a 500-char scrubbed inspect of what could not be written. Follows the lean in #76: parsing stays strict, the writer alone must never fail a finished task. Co-Authored-By: Claude Fable 5.1 --- lib/agentic/execution_journal.rb | 15 ++++++++++++++- spec/agentic/execution_journal_spec.rb | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/lib/agentic/execution_journal.rb b/lib/agentic/execution_journal.rb index 5e96540..89c9ad9 100644 --- a/lib/agentic/execution_journal.rb +++ b/lib/agentic/execution_journal.rb @@ -133,7 +133,20 @@ def lifecycle_hooks(hooks = {}) # @param payload [Hash] Event data (must be JSON-serializable) # @return [void] def record(event, payload = {}) - line = JSON.generate({event: event, at: Time.now.utc.iso8601(3)}.merge(payload)) + at = Time.now.utc.iso8601(3) + line = begin + JSON.generate({event: event, at: at}.merge(payload)) + rescue JSON::GeneratorError => e + # A journal write is a side effect of a task that already + # finished; it must never be the thing that fails it. Keep the + # fields replay keys on (task_id, description) so the task still + # counts, and record what could not be serialized. + JSON.generate( + {event: event, at: at} + .merge(payload.slice(:task_id, :description).select { |_, v| v.is_a?(String) }) + .merge(payload_error: e.class.name, payload_inspect: payload.inspect[0, 500].scrub("?")) + ) + end @mutex.synchronize do File.open(@path, "a") do |file| diff --git a/spec/agentic/execution_journal_spec.rb b/spec/agentic/execution_journal_spec.rb index 9f564c0..104a50c 100644 --- a/spec/agentic/execution_journal_spec.rb +++ b/spec/agentic/execution_journal_spec.rb @@ -22,6 +22,30 @@ expect(lines).to all(have_key("at")) end + it "still lands a line when the payload cannot be serialized" do + binary = (+"caf\xE9").force_encoding(Encoding::BINARY) + journal.record(:task_succeeded, task_id: "t-bin", description: "Fetch page", output: {body: binary}) + + lines = File.readlines(path) + expect(lines.size).to eq(1) + entry = JSON.parse(lines.first) + expect(entry).to include("event" => "task_succeeded", "task_id" => "t-bin", "description" => "Fetch page") + expect(entry).to have_key("at") + expect(entry["payload_error"]).to eq("JSON::GeneratorError") + expect(entry["payload_inspect"]).to include("t-bin") + + state = described_class.replay(path: path, mode: :strict) + expect(state.completed_task_ids).to eq(["t-bin"]) + end + + it "survives a payload with a symbol and a string spelling of the same key" do + journal.record(:task_succeeded, task_id: "t-dup", output: {:k => 1, "k" => 2}) + + expect(File.readlines(path).size).to eq(1) + state = described_class.replay(path: path) + expect(state.completed_task_ids).to eq(["t-dup"]) + end + it "is safe under concurrent writers" do threads = 8.times.map do |i| Thread.new do