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 =

How long the liveness fiber waits before reading the run log's stream-json init event. Injectable via the run(liveness_delay:) kwarg so tests need not sleep seconds.

5.0

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

#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



122
123
124
125
126
127
128
129
130
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
# File 'lib/space_architect/harness.rb', line 122

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: after a bounded delay, read the run log's stream-json
        # init event and print ONE line naming the streamed model + confirming growth.
        # 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
            sleep liveness_delay
            emit_liveness(run_log_path, liveness_delay, 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



174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
# File 'lib/space_architect/harness.rb', line 174

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