harnex 0.12.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +592 -0
  3. data/GUIDE.md +222 -68
  4. data/README.md +78 -48
  5. data/TECHNICAL.md +235 -98
  6. data/agents/gpt.md +60 -0
  7. data/docs/agents.md +119 -0
  8. data/docs/dispatch-telemetry.md +184 -50
  9. data/docs/events.md +20 -7
  10. data/docs/lanes.md +202 -0
  11. data/docs/mcp.md +102 -0
  12. data/docs/pi-rpc.md +81 -7
  13. data/docs/upgrading-to-0.29.md +87 -0
  14. data/guides/01_dispatch.md +101 -34
  15. data/guides/04_monitoring.md +223 -25
  16. data/guides/05_naming.md +0 -1
  17. data/guides/06_claude.md +96 -0
  18. data/lib/harnex/adapters/base.rb +4 -8
  19. data/lib/harnex/adapters/pi.rb +92 -12
  20. data/lib/harnex/adapters.rb +1 -16
  21. data/lib/harnex/artifact_report.rb +206 -12
  22. data/lib/harnex/cli.rb +68 -50
  23. data/lib/harnex/commands/ask.rb +278 -0
  24. data/lib/harnex/commands/delegate.rb +202 -0
  25. data/lib/harnex/commands/doctor.rb +34 -36
  26. data/lib/harnex/commands/events.rb +3 -3
  27. data/lib/harnex/commands/history.rb +2 -8
  28. data/lib/harnex/commands/land.rb +150 -0
  29. data/lib/harnex/commands/lanes.rb +84 -0
  30. data/lib/harnex/commands/logs.rb +3 -3
  31. data/lib/harnex/commands/mcp.rb +208 -0
  32. data/lib/harnex/commands/orchestration.rb +88 -9
  33. data/lib/harnex/commands/pane.rb +3 -3
  34. data/lib/harnex/commands/preflight.rb +566 -0
  35. data/lib/harnex/commands/reply.rb +125 -0
  36. data/lib/harnex/commands/result.rb +108 -0
  37. data/lib/harnex/commands/run.rb +248 -64
  38. data/lib/harnex/commands/send.rb +121 -16
  39. data/lib/harnex/commands/status.rb +32 -4
  40. data/lib/harnex/commands/stop.rb +14 -4
  41. data/lib/harnex/commands/telemetry.rb +19 -1
  42. data/lib/harnex/commands/wait.rb +421 -56
  43. data/lib/harnex/commands/watch.rb +134 -21
  44. data/lib/harnex/context_file.rb +85 -0
  45. data/lib/harnex/core.rb +100 -15
  46. data/lib/harnex/dispatch_history.rb +157 -10
  47. data/lib/harnex/lanes.rb +513 -0
  48. data/lib/harnex/mcp.rb +362 -0
  49. data/lib/harnex/orchestration.rb +128 -5
  50. data/lib/harnex/owned_process_group.rb +50 -0
  51. data/lib/harnex/owner_stream.rb +399 -0
  52. data/lib/harnex/pricing.rb +4 -9
  53. data/lib/harnex/runtime/activity_tracker.rb +107 -0
  54. data/lib/harnex/runtime/api_server.rb +11 -1
  55. data/lib/harnex/runtime/completion_notifier.rb +90 -11
  56. data/lib/harnex/runtime/inbox.rb +26 -7
  57. data/lib/harnex/runtime/message.rb +5 -2
  58. data/lib/harnex/runtime/runtime_budget.rb +96 -0
  59. data/lib/harnex/runtime/session.rb +684 -294
  60. data/lib/harnex/runtime/stop_request.rb +39 -0
  61. data/lib/harnex/stall.rb +46 -0
  62. data/lib/harnex/telemetry_reconciler.rb +42 -20
  63. data/lib/harnex/terminal_status.rb +5 -5
  64. data/lib/harnex/version.rb +2 -2
  65. data/lib/harnex.rb +16 -0
  66. data/recipes/01_fire_and_watch.md +14 -14
  67. data/recipes/02_chain_implement.md +35 -31
  68. data/recipes/03_buddy.md +1 -1
  69. metadata +34 -12
  70. data/docs/codex-appserver.md +0 -175
  71. data/lib/harnex/adapters/codex.rb +0 -160
  72. data/lib/harnex/adapters/codex_appserver.rb +0 -407
  73. data/lib/harnex/codex/app_server/client.rb +0 -347
data/lib/harnex/mcp.rb ADDED
@@ -0,0 +1,362 @@
1
+ require "json"
2
+ require "open3"
3
+ require "securerandom"
4
+
5
+ module Harnex
6
+ # `harnex mcp` (#87): a stdio MCP server (newline-delimited JSON-RPC 2.0)
7
+ # that exposes the lane verbs as native tools for Claude Code, Pi and any
8
+ # MCP client. Each tool runs the matching CLI verb and returns its text.
9
+ # Blocking calls send progress notifications while the worker runs, which
10
+ # keeps client idle/request timers alive (Claude Code: 30m idle on stdio;
11
+ # Pi: 60s per request, both reset by progress).
12
+ module MCP
13
+ PROTOCOL_VERSIONS = %w[2025-11-25 2025-06-18 2025-03-26 2024-11-05].freeze
14
+ PROGRESS_INTERVAL = 15
15
+ DEFAULT_WAIT_MINUTES = 25
16
+
17
+ INSTRUCTIONS = <<~TEXT.freeze
18
+ Harnex hands work to Pi workers (GPT models by default); each runs in a visible tmux window.
19
+ ask: a read-only answer from another model. delegate: a worker does a task; give it a worktree for
20
+ anything that edits, review the diff, then land (land merges and removes the worktree and branch; use it
21
+ instead of merging lane branches by hand). For parallel work call delegate with wait=false for each
22
+ task, then result. reply: a follow-up to the same worker, which keeps its context. Briefs must be
23
+ self-contained: the worker has not seen this conversation. Every result ends with a "── NAME:" footer
24
+ naming the lane's state and next steps.
25
+ TEXT
26
+
27
+ MODEL = { "type" => "string", "description" => "Pi model as provider/model (default: Pi's own default model)" }.freeze
28
+ EFFORT = { "type" => "string", "enum" => %w[off minimal low medium high xhigh max], "description" => "Reasoning effort" }.freeze
29
+ WAIT_MINUTES = { "type" => "number", "description" => "How long to wait before returning \"still working\" (default #{DEFAULT_WAIT_MINUTES}); the worker keeps going, collect it later with result" }.freeze
30
+ CWD = { "type" => "string", "description" => "Directory to work in (default: the server's working directory, usually the project root)" }.freeze
31
+
32
+ TOOLS = [
33
+ {
34
+ "name" => "ask",
35
+ "title" => "Ask a GPT worker",
36
+ "description" => "Ask a Pi worker (GPT by default) a question and get its answer. Read-only by default: it reads the " \
37
+ "repository but cannot run commands or change anything. Use for second opinions, explanations " \
38
+ "and code review; to run tests, scripts or benchmarks, use delegate.",
39
+ "inputSchema" => { "type" => "object", "required" => ["question"], "properties" => {
40
+ "question" => { "type" => "string", "description" => "Self-contained question; the worker has not seen this conversation" },
41
+ "model" => MODEL, "effort" => EFFORT,
42
+ "fallback_model" => { "type" => "string", "description" => "Ask this model instead if the first is rate limited" },
43
+ "write" => { "type" => "boolean", "description" => "Let the worker edit files and run commands (default false)" },
44
+ "cwd" => CWD, "wait_minutes" => WAIT_MINUTES
45
+ } },
46
+ "annotations" => { "readOnlyHint" => false, "openWorldHint" => true }
47
+ },
48
+ {
49
+ "name" => "delegate",
50
+ "title" => "Delegate a task to a GPT worker",
51
+ "description" => "Hand a task to a Pi worker (GPT by default), which edits files, runs commands and replies with " \
52
+ "a summary. Pass worktree=NAME for anything that edits: the worker gets its own git worktree " \
53
+ "on branch harnex/NAME and commits there; then review the diff and call land. For parallel " \
54
+ "work set wait=false for each task and collect them with result. Returns the worker's reply " \
55
+ "and a footer with commits, cost and next steps.",
56
+ "inputSchema" => { "type" => "object", "required" => ["task"], "properties" => {
57
+ "task" => { "type" => "string", "description" => "Self-contained brief: files, expected behavior, how to verify (e.g. the test command)" },
58
+ "worktree" => { "type" => "string", "description" => "Lane name; work in a new git worktree on branch harnex/NAME (recommended for edits)" },
59
+ "name" => { "type" => "string", "description" => "Lane name when not using a worktree (default: from the task)" },
60
+ "wait" => { "type" => "boolean", "description" => "Wait for the result (default true); false starts the lane and returns at once" },
61
+ "model" => MODEL, "effort" => EFFORT, "cwd" => CWD, "wait_minutes" => WAIT_MINUTES
62
+ } },
63
+ "annotations" => { "readOnlyHint" => false, "destructiveHint" => false, "openWorldHint" => true }
64
+ },
65
+ {
66
+ "name" => "result",
67
+ "title" => "Collect lane results",
68
+ "description" => "Wait for lanes to finish and return each result. With no names: every lane you started in " \
69
+ "this repo whose result you have not collected (e.g. from delegate with wait=false).",
70
+ "inputSchema" => { "type" => "object", "properties" => {
71
+ "names" => { "type" => "array", "items" => { "type" => "string" }, "description" => "Lane names (default: all your uncollected lanes)" },
72
+ "cwd" => CWD, "wait_minutes" => WAIT_MINUTES
73
+ } },
74
+ "annotations" => { "readOnlyHint" => true, "openWorldHint" => true }
75
+ },
76
+ {
77
+ "name" => "reply",
78
+ "title" => "Follow up with a worker",
79
+ "description" => "Send a lane's worker more work or a correction. The worker keeps its context: a busy worker " \
80
+ "gets it mid-task, a finished lane resumes its session. Returns the new result.",
81
+ "inputSchema" => { "type" => "object", "required" => %w[name message], "properties" => {
82
+ "name" => { "type" => "string", "description" => "Lane name" },
83
+ "message" => { "type" => "string" },
84
+ "model" => MODEL,
85
+ "wait" => { "type" => "boolean", "description" => "Wait for the result (default true)" },
86
+ "cwd" => CWD, "wait_minutes" => WAIT_MINUTES
87
+ } },
88
+ "annotations" => { "readOnlyHint" => false, "openWorldHint" => true }
89
+ },
90
+ {
91
+ "name" => "land",
92
+ "title" => "Land worktree lanes",
93
+ "description" => "Merge worktree lanes (branch harnex/NAME) into the current branch of the main checkout and " \
94
+ "remove their worktrees and branches. Without names: every finished worktree lane you " \
95
+ "started in this repo. On a merge conflict the merge is left in progress: resolve it, git " \
96
+ "commit, then call land again to clean up.",
97
+ "inputSchema" => { "type" => "object", "properties" => {
98
+ "names" => { "type" => "array", "items" => { "type" => "string" }, "description" => "Lane names, landed in this order (default: all your finished worktree lanes)" },
99
+ "cwd" => CWD
100
+ } },
101
+ "annotations" => { "readOnlyHint" => false, "destructiveHint" => false, "openWorldHint" => false }
102
+ },
103
+ {
104
+ "name" => "discard",
105
+ "title" => "Discard lanes",
106
+ "description" => "Stop lanes and delete their worktrees and branches without merging.",
107
+ "inputSchema" => { "type" => "object", "required" => ["names"], "properties" => {
108
+ "names" => { "type" => "array", "items" => { "type" => "string" } }, "cwd" => CWD
109
+ } },
110
+ "annotations" => { "readOnlyHint" => false, "destructiveHint" => true, "openWorldHint" => false }
111
+ },
112
+ {
113
+ "name" => "lanes",
114
+ "title" => "List lanes",
115
+ "description" => "List this repo's open lanes: state, age, model, branch and the next step for each.",
116
+ "inputSchema" => { "type" => "object", "properties" => {
117
+ "all" => { "type" => "boolean", "description" => "Lanes of every repo" }, "cwd" => CWD
118
+ } },
119
+ "annotations" => { "readOnlyHint" => true, "openWorldHint" => false }
120
+ }
121
+ ].freeze
122
+
123
+ class Server
124
+ def initialize(input: $stdin, output: $stdout, harnex_command: nil, progress_interval: PROGRESS_INTERVAL)
125
+ @input = input
126
+ @output = output
127
+ @harnex_command = harnex_command || [Gem.ruby, File.expand_path("../../bin/harnex", __dir__)]
128
+ @progress_interval = progress_interval
129
+ @write_lock = Mutex.new
130
+ @calls = {}
131
+ @calls_lock = Mutex.new
132
+ @threads = []
133
+ end
134
+
135
+ def run
136
+ @input.each_line do |line|
137
+ next if line.strip.empty?
138
+
139
+ message = begin
140
+ JSON.parse(line)
141
+ rescue JSON::ParserError
142
+ write("jsonrpc" => "2.0", "id" => nil, "error" => { "code" => -32_700, "message" => "parse error" })
143
+ next
144
+ end
145
+ handle(message)
146
+ end
147
+ @threads.each(&:join)
148
+ 0
149
+ end
150
+
151
+ def handle(message)
152
+ id = message["id"]
153
+ case message["method"]
154
+ when "initialize" then respond(id, initialize_result(message["params"] || {}))
155
+ when "ping" then respond(id, {})
156
+ when "tools/list" then respond(id, "tools" => TOOLS)
157
+ when "tools/call" then @threads << Thread.new { call_tool(id, message["params"] || {}) }
158
+ when "notifications/cancelled" then cancel(message.dig("params", "requestId"))
159
+ when nil, /\Anotifications\// then nil
160
+ else
161
+ write("jsonrpc" => "2.0", "id" => id, "error" => { "code" => -32_601, "message" => "method not found: #{message['method']}" }) unless id.nil?
162
+ end
163
+ end
164
+
165
+ private
166
+
167
+ def initialize_result(params)
168
+ requested = params["protocolVersion"].to_s
169
+ {
170
+ "protocolVersion" => PROTOCOL_VERSIONS.include?(requested) ? requested : PROTOCOL_VERSIONS[1],
171
+ "capabilities" => { "tools" => { "listChanged" => false } },
172
+ "serverInfo" => { "name" => "harnex", "title" => "Harnex (Pi/GPT workers)", "version" => Harnex::VERSION },
173
+ "instructions" => INSTRUCTIONS
174
+ }
175
+ end
176
+
177
+ def call_tool(id, params)
178
+ name = params["name"].to_s
179
+ args = params["arguments"].is_a?(Hash) ? params["arguments"] : {}
180
+ args = name_lanes(name, args)
181
+ argv = argv_for(name, args)
182
+ token = params.dig("_meta", "progressToken")
183
+ text, exit_code = run_cli(id, argv, args["cwd"], token, progress_source(name, args))
184
+ respond(id, "content" => [{ "type" => "text", "text" => text }], "isError" => error_exit?(exit_code))
185
+ rescue ArgumentError => e
186
+ respond(id, "content" => [{ "type" => "text", "text" => e.message }], "isError" => true)
187
+ rescue StandardError => e
188
+ respond(id, "content" => [{ "type" => "text", "text" => "harnex mcp: #{e.class}: #{e.message}" }], "isError" => true)
189
+ end
190
+
191
+ # Worker failures are results (the text says FAILED); only a call that
192
+ # could not run (usage error, launch failure) is a tool error.
193
+ def error_exit?(exit_code)
194
+ exit_code == 2 || exit_code.nil?
195
+ end
196
+
197
+ def argv_for(name, args)
198
+ wait = ["--timeout", ((args["wait_minutes"] || DEFAULT_WAIT_MINUTES).to_f * 60).round.to_s]
199
+ case name
200
+ when "ask"
201
+ argv = ["ask"] + wait + opt(args, "id") + opt(args, "model") + opt(args, "effort") + opt(args, "fallback_model", "--fallback-model")
202
+ argv << "--write" if args["write"] == true
203
+ argv + ["--", required(args, "question")]
204
+ when "delegate"
205
+ argv = ["delegate"] + wait + opt(args, "worktree") + opt(args, "name") + opt(args, "model") + opt(args, "effort")
206
+ argv << "--no-wait" if args["wait"] == false
207
+ argv + ["--", required(args, "task")]
208
+ when "result"
209
+ ["result"] + wait + Array(args["names"] || args["name"]).map(&:to_s)
210
+ when "reply"
211
+ argv = ["reply"] + wait + opt(args, "model")
212
+ argv << "--no-wait" if args["wait"] == false
213
+ argv + [required(args, "name"), "--", required(args, "message")]
214
+ when "land", "discard"
215
+ names = Array(args["names"] || args["name"] || args["lanes"]).map(&:to_s).reject(&:empty?)
216
+ raise ArgumentError, "discard: names is required" if names.empty? && name == "discard"
217
+
218
+ [name] + names
219
+ when "lanes"
220
+ ["lanes"] + (args["all"] == true ? ["--all"] : [])
221
+ else
222
+ raise ArgumentError, "unknown tool #{name.inspect}"
223
+ end
224
+ end
225
+
226
+ def opt(args, key, flag = "--#{key}")
227
+ value = args[key]
228
+ value.nil? || value.to_s.strip.empty? ? [] : [flag, value.to_s]
229
+ end
230
+
231
+ # Agents reach for the argument names of their other tools (the Agent
232
+ # tool's `prompt`); accept the common synonyms instead of failing.
233
+ ALIASES = {
234
+ "task" => %w[prompt brief message instructions],
235
+ "question" => %w[prompt query message],
236
+ "message" => %w[prompt text task],
237
+ "name" => %w[lane id worktree]
238
+ }.freeze
239
+
240
+ def required(args, key)
241
+ value = ([key] + ALIASES.fetch(key, [])).map { |k| args[k] }.find { |v| !v.to_s.strip.empty? }.to_s
242
+ raise ArgumentError, "#{key} is required" if value.strip.empty?
243
+
244
+ value
245
+ end
246
+
247
+ # Name lanes up front so progress can follow them while the call runs.
248
+ def name_lanes(tool, args)
249
+ args = args.dup
250
+ case tool
251
+ when "delegate"
252
+ if args["worktree"].to_s.empty? && args["name"].to_s.empty?
253
+ root = Lanes.main_root(work_dir(args)) || work_dir(args)
254
+ args["name"] = Lanes.generate_name(root, args["task"] || args["prompt"] || args["brief"])
255
+ end
256
+ when "ask"
257
+ args["id"] ||= "pi-ask-#{SecureRandom.hex(2)}"
258
+ end
259
+ args
260
+ end
261
+
262
+ def work_dir(args)
263
+ args["cwd"].to_s.strip.empty? ? Dir.pwd : File.expand_path(args["cwd"])
264
+ end
265
+
266
+ # Returns a lambda giving the current progress message.
267
+ def progress_source(tool, args)
268
+ names = case tool
269
+ when "delegate" then [args["worktree"] || args["name"]]
270
+ when "ask" then [args["id"]]
271
+ when "reply" then [args["name"]]
272
+ when "result" then Array(args["names"])
273
+ else []
274
+ end.compact.map(&:to_s).reject(&:empty?)
275
+ dir = work_dir(args)
276
+ lambda do |elapsed|
277
+ lanes = names.empty? && tool == "result" ? Lanes.pending(from: dir) : names.filter_map { |n| lane_or_nil(n, dir) }
278
+ lines = lanes.map { |lane| Lanes.progress_line(lane) }
279
+ lines.empty? ? "harnex #{tool}: working (#{elapsed / 60}m#{format('%02d', elapsed % 60)}s)" : lines.join("; ")
280
+ end
281
+ end
282
+
283
+ def lane_or_nil(name, dir)
284
+ Lanes.resolve(name, from: dir)
285
+ rescue OptionParser::InvalidArgument
286
+ nil
287
+ end
288
+
289
+ # Runs the CLI verb in its own process group (so a cancel stops only the
290
+ # wait; the worker keeps running in tmux) and reports progress while it
291
+ # runs.
292
+ def run_cli(id, argv, cwd, token, label)
293
+ dir = cwd.to_s.strip.empty? ? Dir.pwd : File.expand_path(cwd)
294
+ raise ArgumentError, "cwd #{dir} is not a directory" unless File.directory?(dir)
295
+
296
+ Open3.popen3(*(@harnex_command + argv), chdir: dir, pgroup: true) do |stdin, stdout, stderr, wait|
297
+ stdin.close
298
+ track(id, wait.pid)
299
+ out = Thread.new { stdout.read }
300
+ err = Thread.new { stderr.read }
301
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
302
+ ticks = 0
303
+ until wait.join(@progress_interval)
304
+ ticks += 1
305
+ elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round
306
+ progress(token, ticks, progress_text(label, elapsed)) if token
307
+ end
308
+ cancelled = cancelled?(id)
309
+ untrack(id)
310
+ status = wait.value
311
+ text = out.value.to_s.rstrip
312
+ errors = err.value.to_s.strip
313
+ text = [text, errors].reject(&:empty?).join("\n") if !status.success? || text.empty?
314
+ return [cancelled ? "cancelled; the worker keeps running (see the lanes tool)" : text, status.exitstatus]
315
+ end
316
+ end
317
+
318
+ def progress_text(source, elapsed)
319
+ source.call(elapsed)
320
+ rescue StandardError
321
+ "harnex: working (#{elapsed}s)"
322
+ end
323
+
324
+ def progress(token, value, message)
325
+ write("jsonrpc" => "2.0", "method" => "notifications/progress",
326
+ "params" => { "progressToken" => token, "progress" => value, "message" => message })
327
+ end
328
+
329
+ def track(id, pid)
330
+ @calls_lock.synchronize { @calls[id] = { pid: pid, cancelled: false } }
331
+ end
332
+
333
+ def untrack(id)
334
+ @calls_lock.synchronize { @calls.delete(id) }
335
+ end
336
+
337
+ def cancelled?(id)
338
+ @calls_lock.synchronize { @calls.dig(id, :cancelled) } == true
339
+ end
340
+
341
+ def cancel(request_id)
342
+ call = @calls_lock.synchronize { @calls[request_id]&.tap { |c| c[:cancelled] = true } }
343
+ Process.kill("-TERM", call[:pid]) if call
344
+ rescue Errno::ESRCH
345
+ nil
346
+ end
347
+
348
+ def respond(id, result)
349
+ return if id.nil?
350
+
351
+ write("jsonrpc" => "2.0", "id" => id, "result" => result)
352
+ end
353
+
354
+ def write(message)
355
+ @write_lock.synchronize do
356
+ @output.write(JSON.generate(message) + "\n")
357
+ @output.flush
358
+ end
359
+ end
360
+ end
361
+ end
362
+ end
@@ -17,6 +17,14 @@ module Harnex
17
17
  terminal_tokens window_tokens terminal_percent peak_tokens peak_percent
18
18
  ].freeze
19
19
  STATUSES = %w[observed estimated unsupported missing zero].freeze
20
+ BUDGET_UNITS = %w[attempts families].freeze
21
+ # No-work and process failures. Reconstructable proof defects
22
+ # (report_missing/invalid/rejected), caller stops, and unknown terminals
23
+ # are not fatal unless a caller names them (#57). provider_capacity was
24
+ # task_failed before #82 and stays fatal; pass fatal classes to exempt it.
25
+ DEFAULT_FATAL_CLASSES = %w[
26
+ completed_no_activity task_failed provider_capacity boot_failure permission_prompt timeout process_failed
27
+ ].freeze
20
28
 
21
29
  module_function
22
30
 
@@ -73,20 +81,37 @@ module Harnex
73
81
  sample
74
82
  end
75
83
 
76
- def report(dispatch_path:, samples_path: nil, run_id:)
84
+ def report(dispatch_path:, samples_path: nil, run_id: nil, queue_id: nil, budget: nil, include_preflight: false)
77
85
  build_report(
78
86
  dispatch_rows: load_jsonl(dispatch_path),
79
87
  sample_rows: samples_path ? load_jsonl(samples_path) : [],
80
- run_id: run_id
88
+ run_id: run_id,
89
+ queue_id: queue_id,
90
+ budget: budget,
91
+ include_preflight: include_preflight
81
92
  )
82
93
  end
83
94
 
84
- def build_report(dispatch_rows:, sample_rows:, run_id:)
85
- selected_dispatches = dispatch_rows.select { |row| dispatch_run_id(row) == run_id }
95
+ # Rows are selected by orchestration run id, by queue id, or by both.
96
+ # budget: { limit: Integer, unit: "attempts"|"families", fatal_classes: [...] }
97
+ def build_report(dispatch_rows:, sample_rows:, run_id: nil, queue_id: nil, budget: nil, include_preflight: false)
98
+ raise ArgumentError, "run_id or queue_id is required" if run_id.nil? && queue_id.nil?
99
+
100
+ # `harnex preflight` rows prove plumbing, not work: they stay out of
101
+ # throughput, cost and failure-budget arithmetic unless asked for (#56).
102
+ selected_dispatches = dispatch_rows.select do |row|
103
+ row.is_a?(Hash) &&
104
+ # Start rows share the run id with their end row; count each dispatch once.
105
+ !Harnex::DispatchHistory.start_record?(row) &&
106
+ (include_preflight || !preflight_row?(row)) &&
107
+ (run_id.nil? || dispatch_run_id(row) == run_id) &&
108
+ (queue_id.nil? || dispatch_queue_id(row) == queue_id)
109
+ end
86
110
  samples = sample_rows.select do |row|
87
111
  row.is_a?(Hash) &&
88
112
  row["schema"].to_s == SAMPLE_SCHEMA &&
89
- row["orchestration_run_id"].to_s == run_id.to_s
113
+ (run_id.nil? || row["orchestration_run_id"].to_s == run_id.to_s) &&
114
+ (queue_id.nil? || row["queue_id"].to_s == queue_id.to_s)
90
115
  end
91
116
  primary_dispatches, worker_dispatches = selected_dispatches.partition do |row|
92
117
  row.dig("orchestration", "role").to_s == "primary"
@@ -97,6 +122,7 @@ module Harnex
97
122
  generations = build_generations(primary_dispatches, samples)
98
123
  accepted_work = accepted_work_keys(worker_dispatches)
99
124
  outcome_counts = deduped_outcome_counts(worker_dispatches)
125
+ class_rollup = outcome_class_rollup(worker_dispatches)
100
126
  primary_usage = summarize_usage(primary_sources)
101
127
  worker_usage = summarize_usage(worker_dispatches.map { |row| row["usage"] }.compact)
102
128
  primary_tool_calls = sum_numeric(primary_dispatches.map { |row| row.dig("actual", "tool_calls") }) +
@@ -131,6 +157,7 @@ module Harnex
131
157
  "usage" => worker_usage,
132
158
  "active_s" => sum_numeric(worker_dispatches.map { |row| row.dig("actual", "duration_s") }),
133
159
  "outcomes" => outcome_counts,
160
+ "outcome_classes" => class_rollup.reject { |key, _value| key == "rows" },
134
161
  "accepted_work_ids" => accepted_work.sort
135
162
  },
136
163
  "ratios" => {
@@ -143,9 +170,105 @@ module Harnex
143
170
  "worker_usage_status" => worker_usage["status"],
144
171
  "primary_context_status" => summarize_context(primary_contexts)["status"]
145
172
  }
173
+ }.tap do |report|
174
+ report["failure_budget"] = evaluate_failure_budget(class_rollup, budget) if budget
175
+ end
176
+ end
177
+
178
+ # Counts outcome.class per raw attempt and per retry family. A family is
179
+ # the set of attempts joined by parent attempt/dispatch identity (or a
180
+ # reused dispatch id), so correlated retries count as one root cause.
181
+ def outcome_class_rollup(rows)
182
+ classes = rows.map { |row| outcome_class(row) }
183
+ families = attempt_families(rows)
184
+ attempts = Hash.new(0)
185
+ family_sets = Hash.new { |hash, key| hash[key] = [] }
186
+ classes.each_with_index do |klass, index|
187
+ attempts[klass] += 1
188
+ family_sets[klass] |= [families[index]]
189
+ end
190
+ {
191
+ "attempts" => attempts.sort.to_h,
192
+ "families" => family_sets.transform_values(&:length).sort.to_h,
193
+ "family_count" => families.uniq.length,
194
+ "rows" => classes.zip(families)
195
+ }
196
+ end
197
+
198
+ def evaluate_failure_budget(rollup, budget)
199
+ limit = Integer(budget.fetch(:limit))
200
+ unit = budget.fetch(:unit, "attempts").to_s
201
+ raise ArgumentError, "budget unit must be one of #{BUDGET_UNITS.join(', ')}" unless BUDGET_UNITS.include?(unit)
202
+
203
+ fatal = Array(budget[:fatal_classes] || DEFAULT_FATAL_CLASSES).map(&:to_s).uniq
204
+ fatal_rows = rollup.fetch("rows").select { |klass, _family| fatal.include?(klass) }
205
+ consumed = unit == "families" ? fatal_rows.map(&:last).uniq.length : fatal_rows.length
206
+ {
207
+ "limit" => limit,
208
+ "unit" => unit,
209
+ "fatal_classes" => fatal,
210
+ "consumed" => consumed,
211
+ "fatal_attempts" => fatal_rows.length,
212
+ "fatal_families" => fatal_rows.map(&:last).uniq.length,
213
+ # A budget of N is exhausted by the Nth fatal failure.
214
+ "breached" => consumed >= limit
146
215
  }
147
216
  end
148
217
 
218
+ def outcome_class(row)
219
+ klass = string_value(row.dig("outcome", "class"))
220
+ Harnex::Session::OUTCOME_CLASSES.include?(klass) ? klass : "unknown"
221
+ end
222
+
223
+ def attempt_families(rows)
224
+ parent = (0...rows.length).to_a
225
+ find = lambda do |index|
226
+ index = parent[index] = parent[parent[index]] while parent[index] != index
227
+ index
228
+ end
229
+ # The earliest row stays the root, so a family is named by its first dispatch.
230
+ union = lambda do |a, b|
231
+ roots = [find.call(a), find.call(b)].sort
232
+ parent[roots.last] = roots.first
233
+ end
234
+ by_attempt = {}
235
+ by_dispatch = {}
236
+ rows.each_with_index do |row, index|
237
+ attempt_id = string_value(row.dig("attempt", "id"))
238
+ dispatch_id = string_value(row.dig("attempt", "run_id")) || string_value(row.dig("meta", "id"))
239
+ by_attempt[attempt_id] ||= index if attempt_id
240
+ next unless dispatch_id
241
+
242
+ if by_dispatch.key?(dispatch_id)
243
+ union.call(by_dispatch[dispatch_id], index)
244
+ else
245
+ by_dispatch[dispatch_id] = index
246
+ end
247
+ end
248
+ # parent_dispatch_id is auto-filled from the spawner's HARNEX_ID, which
249
+ # names who launched a worker, not what it retries: only a non-initial
250
+ # attempt (retry/fix/fallback/...) joins its parent dispatch's family.
251
+ rows.each_with_index do |row, index|
252
+ parent_index = by_attempt[string_value(row.dig("attempt", "parent_attempt_id"))]
253
+ if parent_index.nil? && ![nil, "initial"].include?(string_value(row.dig("attempt", "kind")))
254
+ parent_index = by_dispatch[string_value(row.dig("attempt", "parent_dispatch_id"))]
255
+ end
256
+ union.call(parent_index, index) if parent_index
257
+ end
258
+ rows.each_index.map do |index|
259
+ root = rows[find.call(index)]
260
+ string_value(root.dig("attempt", "run_id")) || string_value(root.dig("meta", "id")) || "row-#{find.call(index)}"
261
+ end
262
+ end
263
+
264
+ def preflight_row?(row)
265
+ [row.dig("meta", "phase"), row.dig("queue", "phase")].include?("preflight")
266
+ end
267
+
268
+ def dispatch_queue_id(row)
269
+ row.dig("queue", "queue_id") || row.dig("orchestration", "queue_id")
270
+ end
271
+
149
272
  def load_jsonl(path)
150
273
  return [] unless path && File.file?(path)
151
274
 
@@ -0,0 +1,50 @@
1
+ module Harnex
2
+ # Authority over a group created with spawn(pgroup: true), never inferred from
3
+ # an injected PID. Keep this separate from root-child reaping: a tool may
4
+ # outlive its parent and still need KILL after the TERM grace period.
5
+ class OwnedProcessGroup
6
+ def initialize(pgid)
7
+ raise ArgumentError, "owned process group must be positive" unless pgid.is_a?(Integer) && pgid.positive?
8
+
9
+ @pgid = pgid
10
+ @mutex = Mutex.new
11
+ end
12
+
13
+ def terminate(term_grace_seconds:, kill_grace_seconds:)
14
+ @mutex.synchronize do
15
+ return true unless @pgid
16
+ # Defensive guard even though spawn(pgroup: true) isolates the child.
17
+ return false if @pgid == Process.getpgrp || @pgid == Process.pid
18
+
19
+ return true unless signal("TERM")
20
+ return true if wait_until_gone(term_grace_seconds)
21
+ return true unless signal("KILL")
22
+
23
+ wait_until_gone(kill_grace_seconds)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def signal(name)
30
+ Process.kill(name, -@pgid)
31
+ true
32
+ rescue Errno::ESRCH
33
+ # Do not later signal a recycled group ID after observing it gone.
34
+ @pgid = nil
35
+ false
36
+ end
37
+
38
+ def wait_until_gone(seconds)
39
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds.to_f
40
+ loop do
41
+ return true unless signal(0)
42
+
43
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
44
+ return false if remaining <= 0
45
+
46
+ sleep([0.01, remaining].min)
47
+ end
48
+ end
49
+ end
50
+ end