Class: Space::Architect::Harness::ClaudeCodeHarness

Inherits:
Object
  • Object
show all
Defined in:
lib/space_architect/harness.rb

Constant Summary collapse

ALLOWED_TOOLS =
"Read,Edit,Write,Grep,Glob,Bash,WebSearch,WebFetch"
DISALLOWED_TOOLS =
[
  "Bash(git commit:*)", "Bash(git push:*)", "Bash(git reset:*)",
  "Bash(git merge:*)",  "Bash(git rebase:*)", "Bash(git checkout:*)",
  "Bash(git branch:*)"
].join(",")
ACCEPTED_LEVELS =

claude-code's --effort accepts low/medium/high/xhigh/max; it has no off level (stripped — omit --effort) and no minimal level (clamped to low).

%w[low medium high xhigh max].freeze
CLAMP_MAP =
{ "minimal" => "low" }.freeze
TIMEOUT_EXIT_CODE =
124
LIVENESS_DELAY_SECONDS =

The liveness fiber's total wait budget before it gives up on the run log's stream-json init event. Injectable via the run(liveness_delay:) kwarg so tests need not sleep seconds.

5.0
LIVENESS_BUDGET_FACTOR =

The liveness fiber's actual deadline is liveness_delay * LIVENESS_BUDGET_FACTOR — a healthy child whose first write lands just after one delay window is still alive, not dead, so the budget spans several delay-lengths, not one.

3
LIVENESS_POLL_INTERVAL =

How often the liveness fiber re-checks the run log for growth while waiting.

0.05

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(model:, max_turns:, bin: nil, effort: nil, allowed_tools: ALLOWED_TOOLS, disallowed_tools: DISALLOWED_TOOLS) ⇒ ClaudeCodeHarness

Returns a new instance of ClaudeCodeHarness.



106
107
108
109
110
111
112
113
114
# File 'lib/space_architect/harness.rb', line 106

def initialize(model:, max_turns:, bin: nil, effort: nil,
               allowed_tools: ALLOWED_TOOLS, disallowed_tools: DISALLOWED_TOOLS)
  @model            = model
  @max_turns        = max_turns
  @bin              = bin || ENV.fetch("ARCHITECT_CLAUDE_BIN", "claude")
  @effort           = effort
  @allowed_tools    = allowed_tools
  @disallowed_tools = disallowed_tools
end

Class Method Details

.translate_thinking(level, force: false) ⇒ Object



96
97
98
99
100
101
102
103
104
# File 'lib/space_architect/harness.rb', line 96

def self.translate_thinking(level, force: false)
  return [nil, nil] if level.nil?
  return [level, "thinking: force --effort=#{level} (unmodified, may be rejected)"] if force
  return [level, nil] if ACCEPTED_LEVELS.include?(level)
  return [nil, "thinking: off → claude-code (no --effort; claude-code has no off level)"] if level == "off"

  clamped = CLAMP_MAP.fetch(level)
  [clamped, "thinking: #{level} → claude-code #{clamped} (clamped; claude-code has no #{level} level)"]
end

Instance Method Details

#builder_argsObject

The builder flag set independent of prompt delivery, --model, and the --output-format/--verbose pair — a sandboxed dispatch --as-job executor supplies -p/the prompt/--model/--output-format itself (see the space-server's SandboxArgv#build), so JobsClient spec composition reuses this verbatim instead of duplicating a second flag list (DRY).



214
215
216
217
218
219
220
221
222
223
224
# File 'lib/space_architect/harness.rb', line 214

def builder_args
  args = [
    "--permission-mode", "acceptEdits",
    "--allowedTools", @allowed_tools,
    "--include-partial-messages",
    "--max-turns", @max_turns.to_s
  ]
  args += ["--disallowedTools", @disallowed_tools] unless @disallowed_tools.to_s.empty?
  args += ["--effort", @effort] if @effort
  args
end

#run(prompt_path:, run_log_path:, chdir:, push_url: nil, push_token: nil, push_client: nil, timeout: nil, liveness_delay: LIVENESS_DELAY_SECONDS, err: $stderr) ⇒ Object



131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
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
# File 'lib/space_architect/harness.rb', line 131

def run(prompt_path:, run_log_path:, chdir:, push_url: nil, push_token: nil, push_client: nil, timeout: nil,
        liveness_delay: LIVENESS_DELAY_SECONDS, err: $stderr)
  prompt_path  = Pathname.new(prompt_path)
  run_log_path = Pathname.new(run_log_path)

  File.open(prompt_path, "r") do |prompt_io|
    File.open(run_log_path, "w") do |log|
      r, w = IO.pipe
      Sync do
        child = Async::Process::Child.new(*argv, chdir: chdir.to_s, in: prompt_io, out: w, err: log)
        w.close
        tasks = start_tee(r, log, push_url: push_url, push_token: push_token, push_client: push_client, err: err)
        timed_out    = false
        timeout_task = nil

        # Async::Task#with_timeout cannot do TERM→grace→KILL because
        # Async::Process::Child#wait_thread's ensure goes straight to KILL.
        # Instead: a concurrent fiber fires after the deadline and escalates.
        # transient: true so the reactor doesn't wait for it when main work finishes.
        if timeout && timeout > 0
          timeout_task = Async(transient: true) do
            sleep timeout
            timed_out = true
            Process.kill("TERM", -child.pid) rescue nil
            sleep 0.5
            Process.kill("KILL", -child.pid) rescue nil
          end
        end

        # Liveness self-check: read the run log's stream-json init event and print ONE
        # line naming the streamed model + true elapsed time. A single point-sample right
        # at liveness_delay would report a healthy child dead if its first write landed a
        # moment later, so this polls (like Research::Mux#wait_for_file) to a deadline of
        # several delay-lengths, emitting as soon as the log holds a parseable init event —
        # a bounded wait, not an unbounded one, and not satisfied by mere non-emptiness (the
        # child's stderr is teed into the same run log, so one early stderr byte must not
        # count). transient: true so it never keeps the reactor alive; best-effort so it
        # never raises into the run path. run_detached gets no such fiber.
        liveness_task = nil
        if liveness_delay && liveness_delay > 0
          liveness_task = Async(transient: true) do
            start    = Time.now
            deadline = start + (liveness_delay * LIVENESS_BUDGET_FACTOR)
            until init_event_ready?(run_log_path) || Time.now >= deadline
              sleep LIVENESS_POLL_INTERVAL
            end
            emit_liveness(run_log_path, Time.now - start, err)
          end
        end

        status = child.wait
        timeout_task&.stop
        liveness_task&.stop

        tasks.each(&:wait)
        timed_out ? TIMEOUT_EXIT_CODE : status.exitstatus
      end
    end
  end
end

#run_detached(prompt_path:, run_log_path:, chdir:) ⇒ Object



192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/space_architect/harness.rb', line 192

def run_detached(prompt_path:, run_log_path:, chdir:)
  prompt_path  = Pathname.new(prompt_path)
  run_log_path = Pathname.new(run_log_path)

  prompt_io = File.open(prompt_path, "r")
  log       = File.open(run_log_path, "w")
  begin
    pid = Process.spawn(*argv, chdir: chdir.to_s, pgroup: true,
                        in: prompt_io, out: log, err: log)
    Process.detach(pid)
  ensure
    prompt_io.close
    log.close
  end
  pid
end