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
|
@@ -2,21 +2,29 @@ require "time"
|
|
|
2
2
|
|
|
3
3
|
module Harnex
|
|
4
4
|
class CompletionNotifier
|
|
5
|
+
class RegistrationError < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Per-turn results kept in the owner record, so a watcher that was away
|
|
8
|
+
# while several turns ended still receives each one.
|
|
9
|
+
MAX_RESULTS = 20
|
|
5
10
|
WORK_STATES = {
|
|
6
11
|
"completed" => "completed", "rejected" => "failed",
|
|
7
12
|
"failed" => "failed", "error" => "failed"
|
|
8
13
|
}.freeze
|
|
9
14
|
|
|
10
|
-
def initialize(repo_root:, id:, session_id:, receipt_path:, hook_command: nil,
|
|
15
|
+
def initialize(repo_root:, id:, session_id:, receipt_path:, hook_command: nil, owner_id: nil,
|
|
11
16
|
started_at: Time.now, clock: nil, event_sink: nil, spawn: nil,
|
|
12
17
|
detach: nil, atomic_write: nil, remove: nil, stderr: $stderr)
|
|
13
18
|
@repo_root = Harnex.canonical_repo_root(repo_root)
|
|
14
19
|
@id = Harnex.normalize_id(id)
|
|
15
20
|
@session_id = session_id.to_s
|
|
16
21
|
@receipt_path = receipt_path.to_s
|
|
22
|
+
@owner_id = owner_id.to_s.strip
|
|
23
|
+
@owner_id = nil if @owner_id.empty?
|
|
17
24
|
@hook_command = hook_command.to_s
|
|
18
25
|
@hook_command = nil if @hook_command.empty?
|
|
19
26
|
@started_at = started_at
|
|
27
|
+
@turn_started_at = started_at
|
|
20
28
|
@clock = clock || -> { Time.now }
|
|
21
29
|
@event_sink = event_sink
|
|
22
30
|
@spawn = spawn || method(:spawn_hook)
|
|
@@ -26,7 +34,8 @@ module Harnex
|
|
|
26
34
|
@stderr = stderr
|
|
27
35
|
@mutex = Mutex.new
|
|
28
36
|
@registered = false
|
|
29
|
-
@
|
|
37
|
+
@notified_turn = 0
|
|
38
|
+
@results = []
|
|
30
39
|
end
|
|
31
40
|
|
|
32
41
|
# Registration is the stale-marker boundary for a reused repo/id pair.
|
|
@@ -39,20 +48,31 @@ module Harnex
|
|
|
39
48
|
rescue StandardError => e
|
|
40
49
|
report_error("completion marker cleanup failed", "cleanup", e, path: path)
|
|
41
50
|
end
|
|
51
|
+
register_owned_delivery!
|
|
42
52
|
@registered = true
|
|
43
53
|
end
|
|
44
54
|
true
|
|
45
55
|
end
|
|
46
56
|
|
|
57
|
+
# One result per turn (#81). The owner record is rewritten for every turn
|
|
58
|
+
# so `watch --owner` reports a nudged turn too; the marker files and the
|
|
59
|
+
# --on-done hook keep their documented once-per-session contract.
|
|
47
60
|
def notify(outcome:, work_state:, outcome_class:, artifact_report_status:,
|
|
48
|
-
end_sha:, terminal_signal:
|
|
61
|
+
end_sha:, terminal_signal:, turn: 1, follow_ups: nil,
|
|
62
|
+
final_message: nil, final_message_path: nil, turn_details: nil)
|
|
49
63
|
outcome = outcome.to_s
|
|
50
64
|
work_state = work_state.to_s
|
|
65
|
+
turn = [turn.to_i, 1].max
|
|
51
66
|
expected = WORK_STATES[outcome]
|
|
52
67
|
raise ArgumentError, "unsupported completion outcome #{outcome.inspect}" unless expected
|
|
53
68
|
raise ArgumentError, "#{outcome.inspect} completion requires work_state #{expected.inspect}" unless work_state == expected
|
|
69
|
+
first = nil
|
|
54
70
|
return false unless @mutex.synchronize do
|
|
55
|
-
@registered &&
|
|
71
|
+
next false unless @registered && turn > @notified_turn
|
|
72
|
+
|
|
73
|
+
first = @notified_turn.zero?
|
|
74
|
+
@notified_turn = turn
|
|
75
|
+
true
|
|
56
76
|
end
|
|
57
77
|
|
|
58
78
|
now = @clock.call
|
|
@@ -63,26 +83,85 @@ module Harnex
|
|
|
63
83
|
outcome_class: optional_string(outcome_class),
|
|
64
84
|
artifact_report_status: optional_string(artifact_report_status),
|
|
65
85
|
receipt_path: @receipt_path, end_sha: optional_string(end_sha),
|
|
66
|
-
elapsed_s: elapsed_seconds(now
|
|
67
|
-
notified_at: now.utc.iso8601
|
|
86
|
+
elapsed_s: elapsed_seconds(now, first ? @started_at : @turn_started_at),
|
|
87
|
+
terminal_signal: terminal_signal.to_s, notified_at: now.utc.iso8601
|
|
68
88
|
}
|
|
69
|
-
marker_written = write_marker(path, payload)
|
|
70
|
-
|
|
71
|
-
|
|
89
|
+
marker_written = first ? write_marker(path, payload) : false
|
|
90
|
+
if @owner_id
|
|
91
|
+
result = payload.slice(:outcome, :work_state, :outcome_class, :artifact_report_status,
|
|
92
|
+
:end_sha, :elapsed_s, :terminal_signal, :notified_at).merge(turn: turn)
|
|
93
|
+
result[:follow_ups] = follow_ups if follow_ups
|
|
94
|
+
result[:final_message] = final_message if final_message
|
|
95
|
+
result[:final_message_path] = final_message_path if final_message_path
|
|
96
|
+
result.merge!(turn_details.compact) if turn_details.is_a?(Hash)
|
|
97
|
+
results = @mutex.synchronize do
|
|
98
|
+
@results << result
|
|
99
|
+
@results.shift while @results.length > MAX_RESULTS
|
|
100
|
+
@results.dup
|
|
101
|
+
end
|
|
102
|
+
write_owned_delivery(payload.merge(result).merge(results: results))
|
|
103
|
+
end
|
|
104
|
+
hook_launched = first ? launch_hook(payload) : false
|
|
105
|
+
emit("completion_notification", outcome: outcome, work_state: work_state, turn: turn,
|
|
72
106
|
marker_path: path, marker_written: marker_written,
|
|
73
107
|
hook_configured: !@hook_command.nil?, hook_launched: hook_launched)
|
|
74
108
|
true
|
|
75
109
|
end
|
|
76
110
|
|
|
111
|
+
# A message injected after a published result starts the next turn: the
|
|
112
|
+
# owner record returns to running so the stream watches it again.
|
|
113
|
+
def turn_started(turn)
|
|
114
|
+
@turn_started_at = @clock.call
|
|
115
|
+
return false unless @owner_id && @mutex.synchronize { @registered }
|
|
116
|
+
|
|
117
|
+
path = Harnex.owned_notification_path(@owner_id, @session_id)
|
|
118
|
+
results = @mutex.synchronize { @results.dup }
|
|
119
|
+
@atomic_write.call(path, owned_identity.merge(work_state: "running", turn: [turn.to_i, 1].max,
|
|
120
|
+
turn_started_at: @turn_started_at.utc.iso8601, results: results))
|
|
121
|
+
true
|
|
122
|
+
rescue StandardError => e
|
|
123
|
+
report_error("owned notification turn update failed", "delivery", e)
|
|
124
|
+
false
|
|
125
|
+
end
|
|
126
|
+
|
|
77
127
|
private
|
|
78
128
|
|
|
129
|
+
def owned_identity
|
|
130
|
+
{
|
|
131
|
+
schema_version: 1, owner_id: @owner_id, id: @id, session_id: @session_id,
|
|
132
|
+
repo_root: @repo_root, receipt_path: @receipt_path,
|
|
133
|
+
runner_pid: Process.pid, started_at: @started_at.utc.iso8601
|
|
134
|
+
}
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def register_owned_delivery!
|
|
138
|
+
return unless @owner_id
|
|
139
|
+
|
|
140
|
+
path = Harnex.owned_notification_path(@owner_id, @session_id)
|
|
141
|
+
FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
|
|
142
|
+
@atomic_write.call(path, owned_identity.merge(work_state: "running"))
|
|
143
|
+
rescue StandardError => e
|
|
144
|
+
report_error("owned notification registration failed", "registration", e)
|
|
145
|
+
raise RegistrationError, "cannot register owned completion delivery (#{e.class})"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def write_owned_delivery(payload)
|
|
149
|
+
path = Harnex.owned_notification_path(@owner_id, @session_id)
|
|
150
|
+
@atomic_write.call(path, owned_identity.merge(payload))
|
|
151
|
+
rescue StandardError => e
|
|
152
|
+
# The running record remains: its owner can still reconcile with native
|
|
153
|
+
# watch/receipt evidence, independently of this push write or the hook.
|
|
154
|
+
report_error("owned notification write failed", "delivery", e)
|
|
155
|
+
end
|
|
156
|
+
|
|
79
157
|
def optional_string(value)
|
|
80
158
|
text = value.to_s
|
|
81
159
|
text.empty? ? nil : text
|
|
82
160
|
end
|
|
83
161
|
|
|
84
|
-
|
|
85
|
-
|
|
162
|
+
# A later turn's elapsed time runs from that turn's start.
|
|
163
|
+
def elapsed_seconds(now, since = @started_at)
|
|
164
|
+
[(now - since).to_i, 0].max
|
|
86
165
|
rescue StandardError
|
|
87
166
|
0
|
|
88
167
|
end
|
data/lib/harnex/runtime/inbox.rb
CHANGED
|
@@ -48,6 +48,19 @@ module Harnex
|
|
|
48
48
|
return deliver_now(msg)
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
# A busy agent that can queue into its running turn (Pi followUp, #79)
|
|
52
|
+
# takes the message now: long turns outlive any inbox TTL.
|
|
53
|
+
if @mutex.synchronize { @queue.empty? } && session_accepts_follow_up?
|
|
54
|
+
begin
|
|
55
|
+
result = deliver_now(msg, follow_up: true)
|
|
56
|
+
return result if msg.status == :delivered
|
|
57
|
+
rescue StandardError
|
|
58
|
+
# Fall through to the normal paths
|
|
59
|
+
end
|
|
60
|
+
msg.status = :queued
|
|
61
|
+
msg.error = nil
|
|
62
|
+
end
|
|
63
|
+
|
|
51
64
|
# Fast path: prompt ready and queue empty — deliver immediately.
|
|
52
65
|
# Check under lock, then release before calling deliver_now to
|
|
53
66
|
# avoid recursive locking (deliver_now also acquires @mutex).
|
|
@@ -117,15 +130,20 @@ module Harnex
|
|
|
117
130
|
|
|
118
131
|
private
|
|
119
132
|
|
|
120
|
-
def
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
133
|
+
def session_accepts_follow_up?
|
|
134
|
+
@session.respond_to?(:accepts_follow_up?) && @session.accepts_follow_up?
|
|
135
|
+
rescue StandardError
|
|
136
|
+
false
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def deliver_now(msg, follow_up: false)
|
|
140
|
+
options = { text: msg.text, submit: msg.submit, enter_only: msg.enter_only, force: msg.force }
|
|
141
|
+
options[:follow_up] = true if follow_up
|
|
142
|
+
result = @session.inject_via_adapter(**options)
|
|
127
143
|
msg.status = :delivered
|
|
128
144
|
msg.delivered_at = Time.now
|
|
145
|
+
msg.after_seq = result[:after_seq] if result.is_a?(Hash)
|
|
146
|
+
msg.delivery = result[:delivery] if result.is_a?(Hash)
|
|
129
147
|
@mutex.synchronize do
|
|
130
148
|
@delivered_total += 1
|
|
131
149
|
@messages[msg.id] = msg
|
|
@@ -187,6 +205,7 @@ module Harnex
|
|
|
187
205
|
def expire_stale_messages_locked(now = Time.now)
|
|
188
206
|
while (msg = @queue.first) && stale_message?(msg, now)
|
|
189
207
|
msg.status = :expired
|
|
208
|
+
msg.error = "expired undelivered after #{@ttl.round}s; agent busy; use --force to inject mid-turn"
|
|
190
209
|
@queue.shift
|
|
191
210
|
@expired_total += 1
|
|
192
211
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
module Harnex
|
|
2
|
-
Message = Struct.new(:id, :text, :submit, :enter_only, :force, :queued_at, :status, :delivered_at, :error,
|
|
2
|
+
Message = Struct.new(:id, :text, :submit, :enter_only, :force, :queued_at, :status, :delivered_at, :error,
|
|
3
|
+
:after_seq, :delivery, keyword_init: true) do
|
|
3
4
|
def to_h
|
|
4
5
|
{
|
|
5
6
|
id: id,
|
|
@@ -7,7 +8,9 @@ module Harnex
|
|
|
7
8
|
queued_at: queued_at&.iso8601,
|
|
8
9
|
delivered_at: delivered_at&.iso8601,
|
|
9
10
|
text_preview: preview_text,
|
|
10
|
-
error: error
|
|
11
|
+
error: error,
|
|
12
|
+
after_seq: after_seq,
|
|
13
|
+
delivery: delivery
|
|
11
14
|
}
|
|
12
15
|
end
|
|
13
16
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
3
|
+
module Harnex
|
|
4
|
+
# One fixed session deadline. Neither traffic, retries nor prompts can re-arm
|
|
5
|
+
# it. The callback should only schedule stop work, never wait for RPC or I/O.
|
|
6
|
+
class RuntimeBudget
|
|
7
|
+
def initialize(seconds:, monotonic_clock: nil, wall_clock: nil)
|
|
8
|
+
@seconds = Float(seconds)
|
|
9
|
+
raise ArgumentError, "runtime budget must be finite and positive" unless @seconds.finite? && @seconds.positive?
|
|
10
|
+
|
|
11
|
+
@clock = monotonic_clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
12
|
+
@wall_clock = wall_clock || -> { Time.now }
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
@condition = ConditionVariable.new
|
|
15
|
+
@expired = @cancelled = false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def start(&on_expire)
|
|
19
|
+
raise ArgumentError, "runtime budget requires a callback" unless on_expire
|
|
20
|
+
|
|
21
|
+
@mutex.synchronize do
|
|
22
|
+
return false if @deadline || @cancelled
|
|
23
|
+
|
|
24
|
+
@started_at = @wall_clock.call
|
|
25
|
+
@deadline = @clock.call + @seconds
|
|
26
|
+
@on_expire = on_expire
|
|
27
|
+
@thread = Thread.new do
|
|
28
|
+
@mutex.synchronize do
|
|
29
|
+
until @cancelled || @expired
|
|
30
|
+
remaining = @deadline - @clock.call
|
|
31
|
+
break unless remaining.positive?
|
|
32
|
+
|
|
33
|
+
@condition.wait(@mutex, [remaining, 60.0].min)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
check!
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Also usable at a dispatch boundary so an expired timer can never admit a
|
|
43
|
+
# new prompt merely because its timer thread has not been scheduled yet.
|
|
44
|
+
def check!
|
|
45
|
+
callback = @mutex.synchronize do
|
|
46
|
+
return false if @expired || @cancelled || !deadline_reached?
|
|
47
|
+
|
|
48
|
+
@expired = true
|
|
49
|
+
@condition.broadcast
|
|
50
|
+
@on_expire
|
|
51
|
+
end
|
|
52
|
+
callback.call
|
|
53
|
+
true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def expired?
|
|
57
|
+
@mutex.synchronize { @expired || (!@cancelled && deadline_reached?) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def cancel
|
|
61
|
+
thread = @mutex.synchronize do
|
|
62
|
+
@cancelled = true
|
|
63
|
+
@condition.broadcast
|
|
64
|
+
@thread
|
|
65
|
+
end
|
|
66
|
+
thread&.join(0.2) unless thread == Thread.current
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def snapshot
|
|
70
|
+
@mutex.synchronize do
|
|
71
|
+
state = if @expired || (!@cancelled && deadline_reached?)
|
|
72
|
+
"expired"
|
|
73
|
+
elsif @cancelled
|
|
74
|
+
"cancelled"
|
|
75
|
+
elsif @deadline
|
|
76
|
+
"running"
|
|
77
|
+
else
|
|
78
|
+
"unarmed"
|
|
79
|
+
end
|
|
80
|
+
{
|
|
81
|
+
limit_s: @seconds,
|
|
82
|
+
state: state,
|
|
83
|
+
started_at: @started_at&.getutc&.iso8601(3),
|
|
84
|
+
deadline_at: @started_at ? (@started_at + @seconds).getutc.iso8601(3) : nil,
|
|
85
|
+
remaining_s: @deadline && state == "running" ? [@deadline - @clock.call, 0.0].max.round(3) : nil
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def deadline_reached?
|
|
93
|
+
!!@deadline && @clock.call >= @deadline
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|