Module: Omakase::Executor::Subprocess
- Defined in:
- lib/omakase/executor.rb
Overview
Generated code runs in a child process so a timeout, a crash, or a runaway loop cannot take the parent with it. The child is a copy of this process — it can still reach ActiveRecord, ENV, and the disk. That is isolation of fate, not of capability. Untrusted input still belongs to :predict.
Ivars written in the child are marshalled back one at a time, so a generation's second tool call sees what the first one set. Methods the model defined on the object die with the child.
Class Method Summary collapse
- .call(agent, code, timeout: TIMEOUT) ⇒ Object
-
.carry(result) ⇒ Object
Only an Answer can fail here — an observation is a String.
- .clock ⇒ Object
- .collect(reader, agent, pid, deadline) ⇒ Object
- .fate(status) ⇒ Object
- .marshalable?(value) ⇒ Boolean
-
.note_dropped(result, dropped) ⇒ Object
A dropped ivar turns the answer into an observation: silent state loss would leave the next tool call reasoning about a value that is gone.
- .pack(agent, result) ⇒ Object
-
.read_exactly(io, n, deadline) ⇒ Object
select is exact for a pipe, so readpartial cannot block past the deadline.
-
.read_packet(io, deadline) ⇒ Object
Length-prefixed, so a leftover write-end cannot hang the parent.
-
.reap(pid) ⇒ Object
ECHILD: the host reaps children itself, with a CHLD trap.
-
.stop(pid) ⇒ Object
The child led its own group unless the deadline beat it to setsid.
- .unpack(agent, payload) ⇒ Object
Class Method Details
.call(agent, code, timeout: TIMEOUT) ⇒ Object
83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 |
# File 'lib/omakase/executor.rb', line 83 def call(agent, code, timeout: TIMEOUT) IO.pipe(binmode: true) do |reader, writer| pid = fork do reader.close # Own process group, so a timeout can kill grandchildren too. Process.setsid # Parent owns the deadline; Timeout here would race it. payload = pack(agent, Executor.call(agent, code, timeout: nil)) writer.write([payload.bytesize].pack("N"), payload) ensure exit! 0 end writer.close collect(reader, agent, pid, clock + timeout) end end |
.carry(result) ⇒ Object
Only an Answer can fail here — an observation is a String.
134 135 136 137 138 |
# File 'lib/omakase/executor.rb', line 134 def carry(result) return result if marshalable?(result) "cannot return #{result.value.class} across the process boundary" end |
.clock ⇒ Object
169 |
# File 'lib/omakase/executor.rb', line 169 def clock = Process.clock_gettime(Process::CLOCK_MONOTONIC) |
.collect(reader, agent, pid, deadline) ⇒ Object
100 101 102 103 104 105 106 107 108 109 |
# File 'lib/omakase/executor.rb', line 100 def collect(reader, agent, pid, deadline) payload = read_packet(reader, deadline) stop(pid) if payload == :timeout status = reap(pid) case payload when :timeout then "execution timed out" when :eof then "child process #{fate(status)}" else unpack(agent, payload) end end |
.fate(status) ⇒ Object
171 172 173 174 175 176 |
# File 'lib/omakase/executor.rb', line 171 def fate(status) return "was killed" if status.nil? || status.signaled? return "ended without an answer" if status.success? "exited #{status.exitstatus}" end |
.marshalable?(value) ⇒ Boolean
117 118 119 120 121 122 |
# File 'lib/omakase/executor.rb', line 117 def marshalable?(value) Marshal.dump(value) true rescue TypeError false end |
.note_dropped(result, dropped) ⇒ Object
A dropped ivar turns the answer into an observation: silent state loss would leave the next tool call reasoning about a value that is gone.
126 127 128 129 130 131 |
# File 'lib/omakase/executor.rb', line 126 def note_dropped(result, dropped) return result if dropped.empty? prior = result.is_a?(Answer) ? [result.printed, "finish #{result.value.inspect}"].reject(&:empty?).join("\n") : result Executor.observation([prior, "cannot keep #{dropped.join(", ")} across the process boundary"]) end |
.pack(agent, result) ⇒ Object
111 112 113 114 115 |
# File 'lib/omakase/executor.rb', line 111 def pack(agent, result) kept, dropped = agent.marshal_dump.partition { |_, value| marshalable?(value) } result = note_dropped(result, dropped.map(&:first)) Marshal.dump({result: carry(result), state: kept.to_h}) end |
.read_exactly(io, n, deadline) ⇒ Object
select is exact for a pipe, so readpartial cannot block past the deadline.
157 158 159 160 161 162 163 164 165 166 167 |
# File 'lib/omakase/executor.rb', line 157 def read_exactly(io, n, deadline) buf = "".b while buf.bytesize < n return :timeout unless IO.select([io], nil, nil, [deadline - clock, 0].max) buf << io.readpartial(n - buf.bytesize) end buf rescue EOFError :eof end |
.read_packet(io, deadline) ⇒ Object
Length-prefixed, so a leftover write-end cannot hang the parent.
149 150 151 152 153 154 |
# File 'lib/omakase/executor.rb', line 149 def read_packet(io, deadline) header = read_exactly(io, 4, deadline) return header if header.is_a?(Symbol) read_exactly(io, header.unpack1("N"), deadline) end |
.reap(pid) ⇒ Object
ECHILD: the host reaps children itself, with a CHLD trap.
186 187 188 189 190 |
# File 'lib/omakase/executor.rb', line 186 def reap(pid) Process.wait2(pid).last rescue Errno::ECHILD nil end |
.stop(pid) ⇒ Object
The child led its own group unless the deadline beat it to setsid.
179 180 181 182 183 |
# File 'lib/omakase/executor.rb', line 179 def stop(pid) Process.kill("KILL", (Process.getpgid(pid) == pid) ? -pid : pid) rescue Errno::ESRCH nil end |
.unpack(agent, payload) ⇒ Object
140 141 142 143 144 145 146 |
# File 'lib/omakase/executor.rb', line 140 def unpack(agent, payload) packet = Marshal.load(payload) agent.marshal_load(packet[:state]) packet[:result] rescue ArgumentError, TypeError => e "#{e.message}: a class defined in generated code does not survive the process boundary" end |