From 1b2176dd32b3093774779ef185ae3129a33ae67e Mon Sep 17 00:00:00 2001 From: Wolfy-J Date: Sat, 8 Aug 2026 15:57:24 -0400 Subject: [PATCH] fix(runner): a terminal process result always persists as valid content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A process can exit with any value the runtime hands back — a raw error, userdata, a coroutine. Persisting such a value as node.result content binds SQL NULL, which poisons the whole completion batch and strands the run as runtime_owner_lost. Anything that is not plain data now persists as its string form. --- src/runner/workflow_state.lua | 17 +++++++++++- src/runner/workflow_state_test.lua | 42 ++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/runner/workflow_state.lua b/src/runner/workflow_state.lua index 63042b1..15201eb 100644 --- a/src/runner/workflow_state.lua +++ b/src/runner/workflow_state.lua @@ -1171,12 +1171,27 @@ function methods:handle_process_exit(pid, success, result) } }) + -- A process can die with any value the runtime hands back — a raw error, + -- userdata, a coroutine. The node.result row must always carry persistable + -- content: a value that binds SQL NULL poisons the whole completion batch + -- and strands the run, so anything that is not plain data persists as its + -- string form. + local result_content = result + local content_kind = type(result_content) + if content_kind ~= "string" and content_kind ~= "number" + and content_kind ~= "boolean" and content_kind ~= "table" then + result_content = result_content ~= nil and tostring(result_content) or nil + end + if result_content == nil then + result_content = success and "Completed" or "Failed" + end + table.insert(self.queued_commands, { type = consts.COMMAND_TYPES.CREATE_DATA, payload = { data_id = result_data_id, data_type = consts.DATA_TYPE.NODE_RESULT, - content = result or (success and "Completed" or "Failed"), + content = result_content, node_id = exited_node_id, discriminator = discriminator } diff --git a/src/runner/workflow_state_test.lua b/src/runner/workflow_state_test.lua index 251d418..4bfcd19 100644 --- a/src/runner/workflow_state_test.lua +++ b/src/runner/workflow_state_test.lua @@ -837,6 +837,48 @@ local function define_tests() test.is_nil(exit_info) end) + + -- A process can die with a raw error value (userdata, function — + -- anything the runtime hands back). The queued node.result must + -- always carry persistable, non-nil content: a row that binds SQL + -- NULL poisons the whole completion batch and strands the run. + it("persists a non-encodable terminal result as its string form", function() + local ws = workflow_state.new(test_ctx.dataflow_id) :: any + ws.nodes["node-1"] = { status = consts.STATUS.RUNNING, type = "test_node" } + ws:track_process("node-1", "pid-123") + + local weird = coroutine.create(function() end) + local exit_info = ws:handle_process_exit("pid-123", false, weird) :: any + test.not_nil(exit_info) + + local content = nil + for _, cmd in ipairs(ws.queued_commands) do + local p = (cmd :: any).payload or {} + if p.data_type == consts.DATA_TYPE.NODE_RESULT then content = p.content end + end + test.not_nil(content) + test.eq(type(content), "string") + end) + + it("persists a table result whose values cannot encode as its string form", function() + local ws = workflow_state.new(test_ctx.dataflow_id) :: any + ws.nodes["node-1"] = { status = consts.STATUS.RUNNING, type = "test_node" } + ws:track_process("node-1", "pid-123") + + local poison = { message = "boom", raw = coroutine.create(function() end) } + local exit_info = ws:handle_process_exit("pid-123", false, poison) :: any + test.not_nil(exit_info) + + local content = nil + for _, cmd in ipairs(ws.queued_commands) do + local p = (cmd :: any).payload or {} + if p.data_type == consts.DATA_TYPE.NODE_RESULT then content = p.content end + end + test.not_nil(content) + local encoded, encode_err = json.encode(content) + test.is_nil(encode_err) + test.not_nil(encoded) + end) end) describe("Yield Tracking", function()