harnex 0.11.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 +609 -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 +107 -21
  15. data/guides/04_monitoring.md +236 -17
  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 +259 -64
  38. data/lib/harnex/commands/send.rb +121 -16
  39. data/lib/harnex/commands/status.rb +48 -6
  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 +115 -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 +216 -0
  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 +789 -293
  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 +17 -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 +35 -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
@@ -0,0 +1,216 @@
1
+ require "time"
2
+
3
+ module Harnex
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
10
+ WORK_STATES = {
11
+ "completed" => "completed", "rejected" => "failed",
12
+ "failed" => "failed", "error" => "failed"
13
+ }.freeze
14
+
15
+ def initialize(repo_root:, id:, session_id:, receipt_path:, hook_command: nil, owner_id: nil,
16
+ started_at: Time.now, clock: nil, event_sink: nil, spawn: nil,
17
+ detach: nil, atomic_write: nil, remove: nil, stderr: $stderr)
18
+ @repo_root = Harnex.canonical_repo_root(repo_root)
19
+ @id = Harnex.normalize_id(id)
20
+ @session_id = session_id.to_s
21
+ @receipt_path = receipt_path.to_s
22
+ @owner_id = owner_id.to_s.strip
23
+ @owner_id = nil if @owner_id.empty?
24
+ @hook_command = hook_command.to_s
25
+ @hook_command = nil if @hook_command.empty?
26
+ @started_at = started_at
27
+ @turn_started_at = started_at
28
+ @clock = clock || -> { Time.now }
29
+ @event_sink = event_sink
30
+ @spawn = spawn || method(:spawn_hook)
31
+ @detach = detach || ->(pid) { Process.detach(pid) }
32
+ @atomic_write = atomic_write || Harnex.method(:atomic_write_json)
33
+ @remove = remove || ->(path) { FileUtils.rm_f(path) }
34
+ @stderr = stderr
35
+ @mutex = Mutex.new
36
+ @registered = false
37
+ @notified_turn = 0
38
+ @results = []
39
+ end
40
+
41
+ # Registration is the stale-marker boundary for a reused repo/id pair.
42
+ def register!
43
+ @mutex.synchronize do
44
+ return true if @registered
45
+
46
+ Harnex.completion_marker_paths(@repo_root, @id).each do |path|
47
+ @remove.call(path)
48
+ rescue StandardError => e
49
+ report_error("completion marker cleanup failed", "cleanup", e, path: path)
50
+ end
51
+ register_owned_delivery!
52
+ @registered = true
53
+ end
54
+ true
55
+ end
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.
60
+ def notify(outcome:, work_state:, outcome_class:, artifact_report_status:,
61
+ end_sha:, terminal_signal:, turn: 1, follow_ups: nil,
62
+ final_message: nil, final_message_path: nil, turn_details: nil)
63
+ outcome = outcome.to_s
64
+ work_state = work_state.to_s
65
+ turn = [turn.to_i, 1].max
66
+ expected = WORK_STATES[outcome]
67
+ raise ArgumentError, "unsupported completion outcome #{outcome.inspect}" unless expected
68
+ raise ArgumentError, "#{outcome.inspect} completion requires work_state #{expected.inspect}" unless work_state == expected
69
+ first = nil
70
+ return false unless @mutex.synchronize do
71
+ next false unless @registered && turn > @notified_turn
72
+
73
+ first = @notified_turn.zero?
74
+ @notified_turn = turn
75
+ true
76
+ end
77
+
78
+ now = @clock.call
79
+ path = Harnex.completion_marker_path(@repo_root, @id, outcome)
80
+ payload = {
81
+ schema_version: 1, id: @id, session_id: @session_id,
82
+ repo_root: @repo_root, outcome: outcome, work_state: work_state,
83
+ outcome_class: optional_string(outcome_class),
84
+ artifact_report_status: optional_string(artifact_report_status),
85
+ receipt_path: @receipt_path, end_sha: optional_string(end_sha),
86
+ elapsed_s: elapsed_seconds(now, first ? @started_at : @turn_started_at),
87
+ terminal_signal: terminal_signal.to_s, notified_at: now.utc.iso8601
88
+ }
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,
106
+ marker_path: path, marker_written: marker_written,
107
+ hook_configured: !@hook_command.nil?, hook_launched: hook_launched)
108
+ true
109
+ end
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
+
127
+ private
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
+
157
+ def optional_string(value)
158
+ text = value.to_s
159
+ text.empty? ? nil : text
160
+ end
161
+
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
165
+ rescue StandardError
166
+ 0
167
+ end
168
+
169
+ def write_marker(path, payload)
170
+ @atomic_write.call(path, payload)
171
+ true
172
+ rescue StandardError => e
173
+ report_error("completion marker write failed", "marker", e, path: path)
174
+ false
175
+ end
176
+
177
+ def launch_hook(payload)
178
+ return false unless @hook_command
179
+
180
+ env = {
181
+ "HARNEX_ID" => @id,
182
+ "HARNEX_OUTCOME" => payload.fetch(:outcome),
183
+ "HARNEX_WORK_STATE" => payload.fetch(:work_state),
184
+ "HARNEX_RECEIPT_PATH" => @receipt_path,
185
+ "HARNEX_END_SHA" => payload[:end_sha].to_s,
186
+ "HARNEX_ELAPSED_S" => payload.fetch(:elapsed_s).to_s
187
+ }
188
+ pid = @spawn.call(env, @hook_command, {
189
+ chdir: @repo_root, in: File::NULL, out: @stderr, err: @stderr
190
+ })
191
+ @detach.call(pid)
192
+ true
193
+ rescue StandardError => e
194
+ report_error("completion hook launch failed", "hook", e)
195
+ false
196
+ end
197
+
198
+ def spawn_hook(env, command, options)
199
+ Process.spawn(env, "/bin/sh", "-c", command, **options)
200
+ end
201
+
202
+ # Do not include exception messages: injected spawn failures can echo CMD.
203
+ def report_error(message, component, error, path: nil)
204
+ @stderr.puts("harnex: #{message} (#{error.class})") rescue nil
205
+ payload = { component: component, error_class: error.class.name }
206
+ payload[:path] = path if path
207
+ emit("completion_notification_error", **payload)
208
+ end
209
+
210
+ def emit(type, **payload)
211
+ @event_sink&.call(type, payload)
212
+ rescue StandardError => e
213
+ @stderr.puts("harnex: completion diagnostic event failed (#{e.class})") rescue nil
214
+ end
215
+ end
216
+ end
@@ -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 deliver_now(msg)
121
- result = @session.inject_via_adapter(
122
- text: msg.text,
123
- submit: msg.submit,
124
- enter_only: msg.enter_only,
125
- force: msg.force
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, keyword_init: true) do
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