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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +592 -0
- data/GUIDE.md +222 -68
- data/README.md +78 -48
- data/TECHNICAL.md +235 -98
- data/agents/gpt.md +60 -0
- data/docs/agents.md +119 -0
- data/docs/dispatch-telemetry.md +184 -50
- data/docs/events.md +20 -7
- data/docs/lanes.md +202 -0
- data/docs/mcp.md +102 -0
- data/docs/pi-rpc.md +81 -7
- data/docs/upgrading-to-0.29.md +87 -0
- data/guides/01_dispatch.md +101 -34
- data/guides/04_monitoring.md +223 -25
- data/guides/05_naming.md +0 -1
- data/guides/06_claude.md +96 -0
- data/lib/harnex/adapters/base.rb +4 -8
- data/lib/harnex/adapters/pi.rb +92 -12
- data/lib/harnex/adapters.rb +1 -16
- data/lib/harnex/artifact_report.rb +206 -12
- data/lib/harnex/cli.rb +68 -50
- data/lib/harnex/commands/ask.rb +278 -0
- data/lib/harnex/commands/delegate.rb +202 -0
- data/lib/harnex/commands/doctor.rb +34 -36
- data/lib/harnex/commands/events.rb +3 -3
- data/lib/harnex/commands/history.rb +2 -8
- data/lib/harnex/commands/land.rb +150 -0
- data/lib/harnex/commands/lanes.rb +84 -0
- data/lib/harnex/commands/logs.rb +3 -3
- data/lib/harnex/commands/mcp.rb +208 -0
- data/lib/harnex/commands/orchestration.rb +88 -9
- data/lib/harnex/commands/pane.rb +3 -3
- data/lib/harnex/commands/preflight.rb +566 -0
- data/lib/harnex/commands/reply.rb +125 -0
- data/lib/harnex/commands/result.rb +108 -0
- data/lib/harnex/commands/run.rb +248 -64
- data/lib/harnex/commands/send.rb +121 -16
- data/lib/harnex/commands/status.rb +32 -4
- data/lib/harnex/commands/stop.rb +14 -4
- data/lib/harnex/commands/telemetry.rb +19 -1
- data/lib/harnex/commands/wait.rb +421 -56
- data/lib/harnex/commands/watch.rb +134 -21
- data/lib/harnex/context_file.rb +85 -0
- data/lib/harnex/core.rb +100 -15
- data/lib/harnex/dispatch_history.rb +157 -10
- data/lib/harnex/lanes.rb +513 -0
- data/lib/harnex/mcp.rb +362 -0
- data/lib/harnex/orchestration.rb +128 -5
- data/lib/harnex/owned_process_group.rb +50 -0
- data/lib/harnex/owner_stream.rb +399 -0
- data/lib/harnex/pricing.rb +4 -9
- data/lib/harnex/runtime/activity_tracker.rb +107 -0
- data/lib/harnex/runtime/api_server.rb +11 -1
- data/lib/harnex/runtime/completion_notifier.rb +90 -11
- data/lib/harnex/runtime/inbox.rb +26 -7
- data/lib/harnex/runtime/message.rb +5 -2
- data/lib/harnex/runtime/runtime_budget.rb +96 -0
- data/lib/harnex/runtime/session.rb +684 -294
- data/lib/harnex/runtime/stop_request.rb +39 -0
- data/lib/harnex/stall.rb +46 -0
- data/lib/harnex/telemetry_reconciler.rb +42 -20
- data/lib/harnex/terminal_status.rb +5 -5
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +16 -0
- data/recipes/01_fire_and_watch.md +14 -14
- data/recipes/02_chain_implement.md +35 -31
- data/recipes/03_buddy.md +1 -1
- metadata +34 -12
- data/docs/codex-appserver.md +0 -175
- data/lib/harnex/adapters/codex.rb +0 -160
- data/lib/harnex/adapters/codex_appserver.rb +0 -407
- 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
|
data/lib/harnex/orchestration.rb
CHANGED
|
@@ -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
|
-
|
|
85
|
-
|
|
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
|