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