harnex 0.7.14 → 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e50f27f3d17436e058126d656fdaeb3dfd58cbfdb0e565b8f221ae6b579f3507
4
- data.tar.gz: 01f08ae4414ed2e6a5d4d4503888349c7e27b7aee348b805a435f393ba0f95ad
3
+ metadata.gz: 9142cc69cec0a0cf0dc3961ccc27dc3728baea445c7d83b4c2d69a34b7e6a499
4
+ data.tar.gz: b89c616cb2484d48e74ef88985a438f7ebb1c22b1af17dd44cbaab3ee0c020cd
5
5
  SHA512:
6
- metadata.gz: f62483a74ed5651eecc43c2e30f5020ddd077b0038a17522309af411335c86b94a04d45eec39a2d33defcbb0d781b7ac1c3085c34bd24d0ab1f9baaeb5ebbc32
7
- data.tar.gz: 896793c0dc92d82f62989105714be633fc3b4966752fa3049d7258529cc31fbad2ac0ef1ff36bec2c5b9ac1b2baf42ac325d96f3fe9228f8efbfd0b61bc0e906
6
+ metadata.gz: 220566d1c646ca49b7f41f8ed053b2909dda8468ab3dc877bfcfe5f47b52988492d54b9d2fcc42ff0e42154921fde97af0824688b27df69e94bd9fc49b77c2f8
7
+ data.tar.gz: e52eca128c1f078171ab991acd25bafa810e8341a7f6c8f199535cd03b7a881ee03f2c4193afb01ece92cc00d0a8025a6bdb564739aeb13cbe6a02a4f4d87d0a
data/CHANGELOG.md CHANGED
@@ -1,6 +1,49 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased]
3
+ ## [0.8.0] - 2026-08-02 | 08:46 PM | IST
4
+
5
+ Minor bump: two behavior changes below (`wait --until done` exit codes,
6
+ `--attempt-kind retry` now requiring `--parent-dispatch-id`).
7
+
8
+ ### Added
9
+
10
+ - Dispatch-start records (#62): every dispatch appends a durable
11
+ `dispatch_start` row to the repo dispatch stream at registration (id,
12
+ session_id, pid, host, started_at, meta, schema_version); the
13
+ `dispatch_end` row written at teardown completes it. End rows now carry
14
+ `record_type` and `session_id` so readers can pair them.
15
+ - `harnex history` now shows uncompleted dispatches as `running` (pid alive
16
+ on this host) or `interrupted` (pid gone, no end row) instead of hiding
17
+ them until teardown.
18
+ - Duplicate-dispatch guard (#62): `harnex run --attempt-kind retry` requires
19
+ `--parent-dispatch-id`, and retry/fix/superseding dispatches naming a
20
+ still-running parent in the same repo are refused with a clear error.
21
+ `--allow-live-parent` overrides for intentional parallelism; `review` is
22
+ exempt.
23
+
24
+ ### Changed
25
+
26
+ - `harnex wait --until done` now has a documented exit-code contract
27
+ (guides/04_monitoring.md): `0` accepted work, `1` failed, `2` completed but
28
+ proof rejected, `3` no such session, `124` timeout. The wrapped process's
29
+ exit code is reported as payload data instead of being passed through.
30
+ Payloads carry a `wait_result` field.
31
+ - `harnex wait --until done` re-checks liveness every poll (registry, then
32
+ uncompleted start row with alive pid) and blocks while the session's pid
33
+ is alive. Stale exit-status files and events from an earlier dispatch that
34
+ reused the same id are ignored while a live session is in view.
35
+ - `harnex status` no longer silently reports stale registry data as live:
36
+ rows are labelled `source: live|registry|dispatch_start` with
37
+ `degraded: true` when the live status API was unreachable. With `--id`, a
38
+ running session whose registry row is missing is still reported running
39
+ from its uncompleted start row.
40
+ - One canonical repo resolution for all dispatch-stream writers and readers:
41
+ history rows now key off the session's `repo_root` (matching the summary
42
+ path) instead of the launch cwd, and `repo_key` canonicalizes symlinked
43
+ paths via realpath so registries written from a symlinked cwd stay visible
44
+ to checkers resolving the physical path.
45
+ - Refreshed the codex app-server schema fixtures against codex-cli 0.145.0
46
+ (10 fixtures; upstream-additive changes only, no adapter impact).
4
47
 
5
48
  ## [0.7.14] - 2026-07-15 | 10:06 PM | IST
6
49
 
@@ -22,7 +22,53 @@ For unattended monitors on existing visible/detached sessions, prefer
22
22
  `task_failed` signal, or terminal exit, whichever comes first. Successful work
23
23
  exits `0`, failed work exits non-zero, and wall-clock caps exit `124`. For
24
24
  callers that need the lower-level primitive, `harnex wait --until done` exposes
25
- the same work fence. For structured sessions (Pi RPC and Codex app-server),
25
+ the same work fence.
26
+
27
+ ## Live-Run Visibility
28
+
29
+ Every dispatch appends a `dispatch_start` row to the repo's dispatch stream
30
+ (`.harnex/dispatch.jsonl`) at registration; the `dispatch_end` row at teardown
31
+ completes it. Between those two rows the run is visible to every documented
32
+ signal, from any cwd in the same repo:
33
+
34
+ - `harnex status --id X` reports `state=running` for a live session. When the
35
+ live HTTP status API is unreachable it still reports running from the
36
+ registry (or, failing that, from the uncompleted start row) and labels the
37
+ row `degraded: true` with `source` set to `registry` or `dispatch_start`.
38
+ - `harnex history` shows uncompleted dispatches as `running` (pid alive) or
39
+ `interrupted` (pid gone, no end row). Completed dispatches render one
40
+ `dispatch_end` row.
41
+ - `harnex wait --until done` blocks while the session's pid is alive, up to
42
+ `--timeout`. "No signal yet" from a live worker is never terminal.
43
+
44
+ A monitor consulting these signals can never classify a healthy mid-run
45
+ worker as dead. If `status` says running, do not dispatch a replacement.
46
+
47
+ ## `wait --until done` Exit-Code Contract
48
+
49
+ | Code | `wait_result` | Meaning |
50
+ | --- | --- | --- |
51
+ | `0` | `done` | Completed with accepted work |
52
+ | `1` | `failed` | Work failed, process failed, or killed |
53
+ | `2` | `rejected_proof` | Completed but proof rejected (`completed_no_activity`, `report_missing`, `report_invalid`, `report_rejected`) |
54
+ | `3` | `no_such_session` | No live, start, event, or terminal signal for the id |
55
+ | `124` | `timeout` | `--timeout` elapsed while the session was still running |
56
+
57
+ The JSON payload always carries `wait_result` plus the work-state fields
58
+ (`done`, `work_state`, `outcome_class`, `artifact_report_status`). The child
59
+ process's own exit code is reported as data (`exit_code`), never passed
60
+ through as wait's exit status. Treat `2` as a work-acceptance failure, `3` as
61
+ a coordination error (wrong id or wrong repo), and only `0` as success.
62
+
63
+ ## Duplicate-Dispatch Guard
64
+
65
+ `harnex run --attempt-kind retry` requires `--parent-dispatch-id`, and any
66
+ retry/fix/superseding dispatch whose named parent is still running in the
67
+ same repo is refused. Wait for the parent
68
+ (`harnex wait --id <parent> --until done`) or stop it first. Pass
69
+ `--allow-live-parent` only for intentional parallelism (e.g. isolated
70
+ worktrees). `--attempt-kind review` is exempt: a completed parent may still
71
+ sit at a live prompt while its work is reviewed. For structured sessions (Pi RPC and Codex app-server),
26
72
  `harnex wait --until task_complete` remains the exact accepted-turn fence.
27
73
  Codex acknowledgment-only auto-stop turns are typed
28
74
  `completed_no_activity` and fail this fence without transcript parsing.
@@ -1,5 +1,6 @@
1
1
  require "json"
2
2
  require "optparse"
3
+ require "set"
3
4
  require "time"
4
5
 
5
6
  module Harnex
@@ -74,13 +75,60 @@ module Harnex
74
75
  end
75
76
 
76
77
  def filtered_records
77
- records = load_records
78
+ records = derived_records
78
79
  records = records.select { |record| record["id"].to_s.include?(@options[:id]) } if @options[:id]
79
80
  records = records.select { |record| started_after?(record, @options[:since]) } if @options[:since]
80
81
  records = records.last(@options[:limit]) unless @options[:all]
81
82
  records
82
83
  end
83
84
 
85
+ # One row per dispatch: start rows completed by an end row are dropped
86
+ # (the end row carries the outcome); uncompleted start rows surface as
87
+ # running (pid alive on this host) or interrupted (no end row, pid gone).
88
+ def derived_records
89
+ raw = load_records
90
+ ended = Set.new
91
+ raw.each do |record|
92
+ next unless DispatchHistory.end_record?(record)
93
+
94
+ session_id = record["session_id"].to_s
95
+ ended << "sid:#{session_id}" unless session_id.empty?
96
+ ended << "leg:#{record['id']}|#{record['started_at']}"
97
+ end
98
+
99
+ raw.filter_map do |record|
100
+ next record unless DispatchHistory.start_record?(record)
101
+ next nil if start_completed?(record, ended)
102
+
103
+ derive_live_record(record)
104
+ end
105
+ end
106
+
107
+ def start_completed?(record, ended)
108
+ session_id = record["session_id"].to_s
109
+ return true if !session_id.empty? && ended.include?("sid:#{session_id}")
110
+
111
+ ended.include?("leg:#{record['id']}|#{record['started_at']}")
112
+ end
113
+
114
+ def derive_live_record(record)
115
+ alive = DispatchHistory.same_host?(record) &&
116
+ record["pid"] && Harnex.alive_pid?(record["pid"])
117
+ record.merge(
118
+ "status" => alive ? "running" : "interrupted",
119
+ "terminal_event" => nil,
120
+ "duration_s" => alive ? seconds_since(record["started_at"]) : nil,
121
+ "ended_at" => nil
122
+ )
123
+ end
124
+
125
+ def seconds_since(timestamp)
126
+ seconds = (Time.now - Time.iso8601(timestamp.to_s)).to_i
127
+ seconds.negative? ? 0 : seconds
128
+ rescue ArgumentError
129
+ nil
130
+ end
131
+
84
132
  def load_records
85
133
  path = DispatchHistory.path_for(Dir.pwd, global: @options[:global])
86
134
  return [] unless File.file?(path)
@@ -129,7 +177,9 @@ module Harnex
129
177
  end
130
178
 
131
179
  def format_duration(value)
132
- seconds = Integer(value || 0)
180
+ return "-" if value.nil?
181
+
182
+ seconds = Integer(value)
133
183
  hours = seconds / 3600
134
184
  minutes = (seconds % 3600) / 60
135
185
  rest = seconds % 60
@@ -33,8 +33,13 @@ module Harnex
33
33
  --id --description --detach --tmux --host --port --watch --watch-file
34
34
  --stall-after --max-resumes --preset --context --meta --summary-out
35
35
  --artifact-report --validation-report --cwd --root --timeout --inbox-ttl
36
- --require-artifact-report --require-attribution --auto-stop --fast --legacy-pty --help
36
+ --require-artifact-report --require-attribution --auto-stop --fast --legacy-pty
37
+ --allow-live-parent --help
37
38
  ].concat(TELEMETRY_FLAGS.keys).freeze
39
+
40
+ # Attempt kinds that redo the parent's work; dispatching one while the
41
+ # parent is still running duplicates work in the same checkout.
42
+ LIVE_PARENT_GUARDED_KINDS = %w[retry fix superseding].freeze
38
43
  VALUE_FLAGS = %w[
39
44
  --id --description --host --port --watch --watch-file --stall-after
40
45
  --max-resumes --preset --context --meta --summary-out --artifact-report
@@ -86,7 +91,13 @@ module Harnex
86
91
  --parent-attempt-id ID
87
92
  Parent attempt id for retry/fix/review joins
88
93
  --attempt-kind KIND
89
- initial, retry, fix, review, or superseding (default: initial)
94
+ initial, retry, fix, review, or superseding (default: initial).
95
+ retry requires --parent-dispatch-id so the
96
+ duplicate-dispatch guard can verify the parent
97
+ --allow-live-parent
98
+ Dispatch even though --parent-dispatch-id names a
99
+ session that is still running (intentional
100
+ parallelism, e.g. isolated worktrees)
90
101
  --orchestration-run-id ID
91
102
  Logical primary-orchestrator run id for queue rollups
92
103
  --orchestration-generation-id ID
@@ -164,6 +175,7 @@ module Harnex
164
175
  cwd: nil,
165
176
  root: nil,
166
177
  auto_stop: false,
178
+ allow_live_parent: false,
167
179
  detach: false,
168
180
  tmux: false,
169
181
  tmux_name: nil,
@@ -195,6 +207,7 @@ module Harnex
195
207
  @options[:artifact_report] = resolve_artifact_report(repo_root)
196
208
  @options[:id] ||= Harnex.generate_id(repo_root)
197
209
  validate_unique_id!(repo_root)
210
+ validate_live_parent_guard!(repo_root)
198
211
  effective_child_args = apply_context(apply_codex_service_tier(cli_name, child_args))
199
212
  adapter = Harnex.build_adapter(cli_name, effective_child_args, legacy_pty: @options[:legacy_pty])
200
213
  @options[:detach] = true if @options[:tmux]
@@ -258,6 +271,7 @@ module Harnex
258
271
  tmux_cmd += [flag, value] if flag && value
259
272
  end
260
273
  tmux_cmd << "--require-attribution" if @options[:require_attribution]
274
+ tmux_cmd << "--allow-live-parent" if @options[:allow_live_parent]
261
275
  tmux_cmd += ["--summary-out", @options[:summary_out]] if @options[:summary_out]
262
276
  tmux_cmd += ["--artifact-report", @options[:artifact_report]] if @options[:artifact_report]
263
277
  tmux_cmd << "--require-artifact-report" if @options[:require_artifact_report]
@@ -357,6 +371,33 @@ module Harnex
357
371
  "Use a different --id or stop the existing session first."
358
372
  end
359
373
 
374
+ # Duplicate-dispatch guard (issue #62): a retry/fix/superseding attempt
375
+ # whose parent dispatch is still running would duplicate work in the same
376
+ # checkout. Explicit --parent-dispatch-id only — the implicit HARNEX_ID
377
+ # lineage of a live spawner must not trip this.
378
+ def validate_live_parent_guard!(repo_root)
379
+ metadata = @options[:meta].is_a?(Hash) ? @options[:meta] : {}
380
+ kind = metadata["attempt_kind"].to_s
381
+ parent_id = metadata["parent_dispatch_id"].to_s.strip
382
+
383
+ if kind == "retry" && parent_id.empty?
384
+ raise "harnex run: --attempt-kind retry requires --parent-dispatch-id " \
385
+ "so the duplicate-dispatch guard can verify the parent is not still running."
386
+ end
387
+
388
+ return if @options[:allow_live_parent]
389
+ return if parent_id.empty?
390
+ return unless LIVE_PARENT_GUARDED_KINDS.include?(kind)
391
+
392
+ live = Harnex.active_sessions(repo_root, id: parent_id).first
393
+ return unless live
394
+
395
+ raise "harnex run: refusing #{kind} dispatch — parent dispatch #{parent_id.inspect} " \
396
+ "is still running (pid #{live['pid']}, started #{live['started_at']}). " \
397
+ "Wait for it (harnex wait --id #{parent_id} --until done), stop it, " \
398
+ "or pass --allow-live-parent for intentional parallelism."
399
+ end
400
+
360
401
  def build_session(adapter, repo_root)
361
402
  watch = Harnex.build_watch_config(@options[:watch], repo_root)
362
403
  Session.new(
@@ -541,6 +582,8 @@ module Harnex
541
582
  @options[:context] = required_option_value("--context", Regexp.last_match(1))
542
583
  when "--auto-stop"
543
584
  @options[:auto_stop] = true
585
+ when "--allow-live-parent"
586
+ @options[:allow_live_parent] = true
544
587
  when "--require-attribution"
545
588
  @options[:require_attribution] = true
546
589
  when "--fast"
@@ -646,7 +689,7 @@ module Harnex
646
689
  case arg
647
690
  when "--"
648
691
  return false
649
- when "-h", "--help", "--detach", "--tmux", "--auto-stop", "--require-artifact-report", "--require-attribution", "--fast", "--legacy-pty"
692
+ when "-h", "--help", "--detach", "--tmux", "--auto-stop", "--require-artifact-report", "--require-attribution", "--fast", "--legacy-pty", "--allow-live-parent"
650
693
  nil
651
694
  when /\A--tmux=/
652
695
  nil
@@ -97,14 +97,49 @@ module Harnex
97
97
  return live unless @options[:id]
98
98
  return [live.first] unless live.empty?
99
99
 
100
+ running = running_from_start_record(fallback_repo_root)
101
+ return [running] if running
102
+
100
103
  terminal = Harnex::TerminalStatus.resolve(id: @options[:id], repo_root: fallback_repo_root)
101
104
  [terminal || Harnex::TerminalStatus.unknown(id: @options[:id], repo_root: fallback_repo_root)]
102
105
  end
103
106
 
107
+ # Registry row missing but the dispatch stream has an uncompleted start
108
+ # row whose pid is alive: the worker is running, just not registry-visible
109
+ # from this context. Report running (labelled degraded), never dead.
110
+ def running_from_start_record(repo_root)
111
+ start = Harnex::DispatchHistory.live_start_record(repo_root: repo_root, id: @options[:id])
112
+ return nil unless start
113
+
114
+ {
115
+ "id" => start["id"].to_s,
116
+ "cli" => start["cli"],
117
+ "pid" => start["pid"],
118
+ "description" => start["description"],
119
+ "repo_root" => start["repo_root"] || repo_root,
120
+ "started_at" => start["started_at"],
121
+ "state" => "running",
122
+ "process_state" => "running",
123
+ "terminal" => false,
124
+ "task_complete" => false,
125
+ "task_failed" => false,
126
+ "done" => false,
127
+ "work_state" => "running",
128
+ "exit" => nil,
129
+ "exit_code" => nil,
130
+ "summary_out" => nil,
131
+ "ended_at" => nil,
132
+ "source" => "dispatch_start",
133
+ "degraded" => true,
134
+ "live_status" => "unreachable"
135
+ }
136
+ end
137
+
104
138
  def normalize_live_status(session)
105
139
  task_failed = task_failed?(session)
106
140
  task_complete = task_complete?(session) && !task_failed
107
141
  work_state = task_failed ? "failed" : Harnex.work_state_for("running", task_complete: task_complete)
142
+ degraded = session["live_status"] == "unreachable"
108
143
  session.merge(
109
144
  "state" => "running",
110
145
  "process_state" => "running",
@@ -117,7 +152,8 @@ module Harnex
117
152
  "exit_code" => nil,
118
153
  "summary_out" => nil,
119
154
  "ended_at" => nil,
120
- "source" => "live"
155
+ "source" => degraded ? "registry" : "live",
156
+ "degraded" => degraded
121
157
  )
122
158
  end
123
159
 
@@ -131,6 +167,9 @@ module Harnex
131
167
  !session["last_failed_at"].to_s.empty?
132
168
  end
133
169
 
170
+ # On HTTP failure the row is still backed by a verified-alive pid, but the
171
+ # data is the registry snapshot, not the live API — label it as degraded
172
+ # instead of silently passing it off as live.
134
173
  def load_live_status(session)
135
174
  uri = URI("http://#{session.fetch('host')}:#{session.fetch('port')}/status")
136
175
  request = Net::HTTP::Get.new(uri)
@@ -140,11 +179,11 @@ module Harnex
140
179
  http.request(request)
141
180
  end
142
181
 
143
- return session unless response.is_a?(Net::HTTPSuccess)
182
+ return session.merge("live_status" => "unreachable") unless response.is_a?(Net::HTTPSuccess)
144
183
 
145
- session.merge(JSON.parse(response.body))
184
+ session.merge(JSON.parse(response.body)).merge("live_status" => "ok")
146
185
  rescue StandardError
147
- session
186
+ session.merge("live_status" => "unreachable")
148
187
  end
149
188
 
150
189
  def render_table(sessions)
@@ -14,6 +14,19 @@ module Harnex
14
14
  EVENT_PREDICATES = %w[task_complete task_failed].freeze
15
15
  LEGACY_EVENT_TYPES = %w[agent_state exited task_complete task_failed].freeze
16
16
 
17
+ # Exit-code contract for `--until done` (documented in guides/04_monitoring.md).
18
+ DONE_EXIT_DONE = 0
19
+ DONE_EXIT_FAILED = 1
20
+ DONE_EXIT_REJECTED_PROOF = 2
21
+ DONE_EXIT_NO_SESSION = 3
22
+ DONE_EXIT_TIMEOUT = 124
23
+
24
+ # Outcome classes where the process completed but the work was not
25
+ # accepted: proof was missing, invalid, rejected, or nothing happened.
26
+ REJECTED_PROOF_CLASSES = %w[
27
+ completed_no_activity report_missing report_invalid report_rejected
28
+ ].freeze
29
+
17
30
  def self.usage(program_name = "harnex wait")
18
31
  <<~TEXT
19
32
  Usage: #{program_name} [options]
@@ -41,8 +54,18 @@ module Harnex
41
54
  #{program_name} --id cx-i-42 --until prompt --timeout 120
42
55
  #{program_name} --id cx-i-42
43
56
 
57
+ Exit codes for --until done:
58
+ 0 completed with accepted work
59
+ 1 failed (work failed, process failed, or killed)
60
+ 2 completed but proof rejected (completed_no_activity,
61
+ report_missing, report_invalid, report_rejected)
62
+ 3 no such session (no live, start, event, or terminal signal)
63
+ 124 --timeout elapsed while the session was still running
64
+
44
65
  Gotchas:
45
66
  done is the safest work-level fence for monitors.
67
+ While the session's pid is alive, --until done blocks (up to
68
+ --timeout); "no signal yet" from a live worker is never terminal.
46
69
  task_complete/task_failed are event predicates; prompt/busy are live state polls.
47
70
  Prompt state alone does not prove work acceptance. Verify artifacts/tests.
48
71
  Exit waits can resolve from terminal summary rows when live registry/
@@ -134,7 +157,9 @@ module Harnex
134
157
  end
135
158
  end
136
159
 
137
- def scan_events(path, offset, predicate, task_complete_seen, start_time)
160
+ # min_ts: with a live session in view, events written before it started
161
+ # belong to an earlier run that reused the id — never match on them.
162
+ def scan_events(path, offset, predicate, task_complete_seen, start_time, min_ts: nil)
138
163
  return [nil, offset, task_complete_seen] unless File.exist?(path) && File.size(path) > offset
139
164
 
140
165
  File.open(path, "r") do |f|
@@ -142,6 +167,7 @@ module Harnex
142
167
  f.each_line do |line|
143
168
  event = parse_event(line)
144
169
  next unless event
170
+ next if stale_event?(event, min_ts)
145
171
 
146
172
  task_complete_seen = true if %w[task_complete task_failed].include?(event_type(event))
147
173
  if matches?(event, predicate, task_complete_seen)
@@ -154,6 +180,17 @@ module Harnex
154
180
  [nil, offset, task_complete_seen]
155
181
  end
156
182
 
183
+ def stale_event?(event, min_ts)
184
+ return false unless min_ts
185
+
186
+ # Events without a parseable ts (legacy formats) cannot be dated;
187
+ # treat them as fresh rather than silently dropping completion signals.
188
+ ts = parse_wait_time(event["ts"])
189
+ return false unless ts
190
+
191
+ ts < min_ts
192
+ end
193
+
157
194
  def parse_event(line)
158
195
  event = JSON.parse(line)
159
196
  event.is_a?(Hash) ? event : nil
@@ -206,11 +243,14 @@ module Harnex
206
243
  seq: event["seq"],
207
244
  waited_seconds: waited
208
245
  }
246
+ exit_code = 0
209
247
  if predicate == "done"
210
248
  failed = done_event_failed?(event)
249
+ exit_code = failed ? done_failure_exit_code(event["outcome_class"]) : DONE_EXIT_DONE
211
250
  payload.merge!(
212
251
  ok: !failed,
213
252
  status: failed ? "failed" : "done",
253
+ wait_result: done_wait_result(exit_code),
214
254
  state: "running",
215
255
  process_state: "running",
216
256
  terminal: false,
@@ -224,79 +264,136 @@ module Harnex
224
264
  payload[:last_error] = event["message"] || event["error"] if failed
225
265
  end
226
266
  puts JSON.generate(payload)
227
- return 1 if predicate == "done" && done_event_failed?(event)
267
+ exit_code
268
+ end
269
+
270
+ def done_failure_exit_code(outcome_class)
271
+ REJECTED_PROOF_CLASSES.include?(outcome_class.to_s) ? DONE_EXIT_REJECTED_PROOF : DONE_EXIT_FAILED
272
+ end
228
273
 
229
- 0
274
+ def done_wait_result(exit_code)
275
+ case exit_code
276
+ when DONE_EXIT_DONE then "done"
277
+ when DONE_EXIT_REJECTED_PROOF then "rejected_proof"
278
+ else "failed"
279
+ end
230
280
  end
231
281
 
282
+ # Contract: while the session's pid is alive, block (up to --timeout).
283
+ # Liveness is re-checked every poll from the registry, falling back to an
284
+ # uncompleted dispatch-start row, so a running worker is never classified
285
+ # as dead just because one signal source is missing. "No signal yet"
286
+ # while alive is not terminal.
232
287
  def wait_until_done
233
288
  repo_root = Harnex.resolve_repo_root(@options[:repo_path])
234
289
  events_path = Harnex.events_log_path(repo_root, @options[:id])
235
290
  exit_path = Harnex.exit_status_path(repo_root, @options[:id])
236
- registry = Harnex.read_registry(repo_root, @options[:id])
237
291
  start_time = Time.now
238
292
  deadline = @options[:timeout] ? start_time + @options[:timeout] : nil
239
293
 
240
294
  offset = 0
241
295
  task_complete_seen = false
242
- final_event_deadline = nil
243
-
244
- status, offset, task_complete_seen = scan_events(events_path, offset, "done", task_complete_seen, start_time)
245
- return status if status
296
+ observed_live = nil
297
+ session_started_at = nil
246
298
 
247
- unless registry
248
- terminal = done_status(repo_root)
249
- return emit_done_terminal_status(terminal) if terminal
250
- return emit_done_exit_status(exit_path, @options[:id]) if File.exist?(exit_path)
251
-
252
- unless File.exist?(events_path)
253
- warn("harnex wait: no session found with id #{@options[:id].inspect}")
254
- puts JSON.generate(ok: false, id: @options[:id], state: "unknown", process_state: "unknown", terminal: false,
255
- task_complete: false, done: false, work_state: "unknown", status: "unknown")
256
- return 1
299
+ loop do
300
+ live = live_session(repo_root)
301
+ if live
302
+ observed_live = live
303
+ session_started_at ||= parse_wait_time(live["started_at"])
257
304
  end
258
- end
259
305
 
260
- target_pid = registry && registry["pid"]
261
-
262
- loop do
263
- status, offset, task_complete_seen = scan_events(events_path, offset, "done", task_complete_seen, start_time)
306
+ status, offset, task_complete_seen =
307
+ scan_events(events_path, offset, "done", task_complete_seen, start_time, min_ts: session_started_at)
264
308
  return status if status
265
309
 
266
- unless registry
310
+ unless live
311
+ if observed_live
312
+ return resolve_after_exit(repo_root, events_path, exit_path, offset,
313
+ task_complete_seen, start_time, observed_live, session_started_at)
314
+ end
315
+
316
+ # Historical query: never observed the session alive.
317
+ return emit_done_exit_status(exit_path, @options[:id]) if File.exist?(exit_path)
318
+
267
319
  terminal = done_status(repo_root)
268
320
  return emit_done_terminal_status(terminal) if terminal
269
- return emit_done_exit_status(exit_path, @options[:id]) if File.exist?(exit_path)
321
+
322
+ unless File.exist?(events_path)
323
+ warn("harnex wait: no session found with id #{@options[:id].inspect}")
324
+ puts JSON.generate(ok: false, id: @options[:id], status: "no_such_session",
325
+ wait_result: "no_such_session", state: "unknown", process_state: "unknown",
326
+ terminal: false, task_complete: false, done: false, work_state: "unknown")
327
+ return DONE_EXIT_NO_SESSION
328
+ end
270
329
  end
271
330
 
272
331
  if deadline && Time.now >= deadline
273
332
  waited = (Time.now - start_time).round(1)
274
- puts JSON.generate(ok: false, id: @options[:id], status: "timeout", waited_seconds: waited,
275
- done: false, work_state: "running")
276
- return 124
333
+ puts JSON.generate(ok: false, id: @options[:id], status: "timeout", wait_result: "timeout",
334
+ waited_seconds: waited, done: false,
335
+ work_state: live ? "running" : "unknown")
336
+ return DONE_EXIT_TIMEOUT
277
337
  end
278
338
 
279
- if target_pid && !Harnex.alive_pid?(target_pid)
280
- final_event_deadline ||= Time.now + FINAL_EVENT_GRACE_SECONDS
281
- if Time.now >= final_event_deadline
282
- await_exit_status(exit_path)
283
- return emit_done_exit_status(exit_path, @options[:id]) if File.exist?(exit_path)
339
+ sleep EVENT_POLL_INTERVAL
340
+ end
341
+ end
284
342
 
285
- terminal = done_status(repo_root)
286
- return emit_done_terminal_status(terminal) if terminal
343
+ # Registry row first; when it is not visible from this context, an
344
+ # uncompleted dispatch-start row with an alive pid still proves liveness.
345
+ def live_session(repo_root)
346
+ registry = Harnex.read_registry(repo_root, @options[:id])
347
+ return registry if registry
287
348
 
288
- waited = (Time.now - start_time).round(1)
289
- puts JSON.generate(ok: false, id: @options[:id], state: "exited", process_state: "exited",
290
- terminal: true, task_complete: false, done: false, work_state: "unknown",
291
- waited_seconds: waited)
292
- return 1
293
- end
294
- else
295
- final_event_deadline = nil
296
- end
349
+ Harnex::DispatchHistory.live_start_record(repo_root: repo_root, id: @options[:id])
350
+ end
297
351
 
298
- sleep EVENT_POLL_INTERVAL
352
+ # The session was alive and its pid is now gone: give teardown a bounded
353
+ # grace to land the final events, summary row, and exit-status file, then
354
+ # classify from the freshest signal that belongs to the observed session.
355
+ def resolve_after_exit(repo_root, events_path, exit_path, offset, task_complete_seen,
356
+ start_time, observed_live, session_started_at)
357
+ await_exit_status(exit_path)
358
+
359
+ status, _offset, _seen =
360
+ scan_events(events_path, offset, "done", task_complete_seen, start_time, min_ts: session_started_at)
361
+ return status if status
362
+
363
+ if File.exist?(exit_path) && fresh_exit_status?(exit_path, observed_live, session_started_at)
364
+ return emit_done_exit_status(exit_path, @options[:id])
299
365
  end
366
+
367
+ terminal = done_status(repo_root, min_started_at: session_started_at)
368
+ return emit_done_terminal_status(terminal) if terminal
369
+
370
+ waited = (Time.now - start_time).round(1)
371
+ puts JSON.generate(ok: false, id: @options[:id], state: "exited", process_state: "exited",
372
+ terminal: true, task_complete: false, done: false, work_state: "unknown",
373
+ wait_result: "failed", waited_seconds: waited)
374
+ DONE_EXIT_FAILED
375
+ end
376
+
377
+ # Guards against classifying from an exit-status file left behind by an
378
+ # earlier dispatch that reused the same id.
379
+ def fresh_exit_status?(exit_path, observed_live, session_started_at)
380
+ data = JSON.parse(File.read(exit_path))
381
+ observed_session_id = observed_live["session_id"].to_s
382
+ exit_session_id = data["session_id"].to_s
383
+ return exit_session_id == observed_session_id unless exit_session_id.empty? || observed_session_id.empty?
384
+
385
+ exited_at = parse_wait_time(data["exited_at"])
386
+ return true unless exited_at && session_started_at
387
+
388
+ exited_at >= session_started_at
389
+ rescue StandardError
390
+ true
391
+ end
392
+
393
+ def parse_wait_time(value)
394
+ Time.iso8601(value.to_s)
395
+ rescue ArgumentError
396
+ nil
300
397
  end
301
398
 
302
399
  def wait_until_state
@@ -441,11 +538,16 @@ module Harnex
441
538
  status
442
539
  end
443
540
 
444
- def done_status(repo_root)
541
+ def done_status(repo_root, min_started_at: nil)
445
542
  status = Harnex::TerminalStatus.resolve(id: @options[:id], repo_root: repo_root)
446
543
  return nil unless status
447
544
  return nil unless status["done"] || status["terminal"]
448
545
 
546
+ if min_started_at
547
+ row_started = parse_wait_time(status["started_at"])
548
+ return nil if row_started && row_started < min_started_at
549
+ end
550
+
449
551
  status
450
552
  end
451
553
 
@@ -457,6 +559,7 @@ module Harnex
457
559
  exit_success = !task_failed && (exit_code.nil? || exit_code.to_i == 0)
458
560
  state = exit_success ? "completed" : "failed"
459
561
  done = task_complete || exit_success
562
+ result_code = done ? DONE_EXIT_DONE : done_failure_exit_code(data["outcome_class"])
460
563
  payload = data.merge(
461
564
  "ok" => done,
462
565
  "id" => id,
@@ -466,32 +569,26 @@ module Harnex
466
569
  "task_complete" => task_complete,
467
570
  "task_failed" => task_failed,
468
571
  "done" => done,
469
- "work_state" => Harnex.work_state_for(state, task_complete: task_complete)
572
+ "work_state" => Harnex.work_state_for(state, task_complete: task_complete),
573
+ "wait_result" => done_wait_result(result_code)
470
574
  )
471
- success = done
472
575
  puts JSON.generate(payload)
473
- return 0 if success
474
-
475
- exit_code.is_a?(Integer) && exit_code.positive? ? exit_code : 1
576
+ result_code
476
577
  rescue JSON::ParserError
477
578
  puts JSON.generate(ok: false, id: id, state: "failed", process_state: "exited", terminal: true,
478
- task_complete: false, done: false, work_state: "failed", status: "invalid_exit_status")
479
- 1
579
+ task_complete: false, done: false, work_state: "failed",
580
+ status: "invalid_exit_status", wait_result: "failed")
581
+ DONE_EXIT_FAILED
480
582
  end
481
583
 
482
584
  def emit_done_terminal_status(status)
483
585
  payload = terminal_payload(status)
484
586
  payload[:ok] = !!payload[:done]
485
587
  payload[:status] = payload[:done] ? "done" : status["state"]
588
+ result_code = payload[:done] ? DONE_EXIT_DONE : done_failure_exit_code(status["outcome_class"])
589
+ payload[:wait_result] = done_wait_result(result_code)
486
590
  puts JSON.generate(payload)
487
-
488
- if payload[:ok]
489
- 0
490
- elsif status["exit_code"].is_a?(Integer) && status["exit_code"] > 0
491
- status["exit_code"]
492
- else
493
- 1
494
- end
591
+ result_code
495
592
  end
496
593
 
497
594
  def emit_terminal_status(status)
@@ -350,8 +350,9 @@ module Harnex
350
350
  -h, --help Show this help
351
351
 
352
352
  `harnex watch` is the safe watcher for existing --tmux or detached
353
- dispatches. It exits 0 for task_complete/done, non-zero for task_failed
354
- or failed terminal summaries, and 124 for --max-wait timeouts.
353
+ dispatches. Exit codes follow the `harnex wait --until done` contract:
354
+ 0 accepted work, 1 failed, 2 completed-but-proof-rejected, 3 no such
355
+ session, 124 for --max-wait timeouts.
355
356
 
356
357
  For launch-and-babysit stall recovery, use `harnex run --watch`.
357
358
  TEXT
data/lib/harnex/core.rb CHANGED
@@ -157,8 +157,18 @@ module Harnex
157
157
  {}
158
158
  end
159
159
 
160
+ # Canonical path resolution shared by every registry/exit/events writer and
161
+ # reader. realpath collapses symlinked prefixes so a session registered from
162
+ # a symlinked cwd stays visible to checkers resolving the physical path.
163
+ def canonical_repo_root(path)
164
+ expanded = File.expand_path(path)
165
+ File.realpath(expanded)
166
+ rescue StandardError
167
+ expanded
168
+ end
169
+
160
170
  def repo_key(repo_root)
161
- Digest::SHA256.hexdigest(repo_root)[0, 16]
171
+ Digest::SHA256.hexdigest(canonical_repo_root(repo_root))[0, 16]
162
172
  end
163
173
 
164
174
  def normalize_id(id)
@@ -62,12 +62,107 @@ module Harnex
62
62
  end
63
63
  end
64
64
 
65
+ def start_record?(record)
66
+ record.is_a?(Hash) && record["record_type"] == "dispatch_start"
67
+ end
68
+
69
+ def end_record?(record)
70
+ return false unless record.is_a?(Hash)
71
+ return true if record["record_type"] == "dispatch_end"
72
+
73
+ # Legacy end rows predate record_type.
74
+ record["schema_version"] == 1 && record.key?("status") && !record.key?("record_type")
75
+ end
76
+
77
+ def end_matches_start?(end_record, start_record)
78
+ end_session = end_record["session_id"].to_s
79
+ start_session = start_record["session_id"].to_s
80
+ return end_session == start_session unless end_session.empty? || start_session.empty?
81
+
82
+ end_record["id"].to_s == start_record["id"].to_s &&
83
+ end_record["started_at"].to_s == start_record["started_at"].to_s
84
+ end
85
+
86
+ # Latest start row for `id` and any end row that completes it.
87
+ def latest_rows(path, id)
88
+ latest_start = nil
89
+ matching_end = nil
90
+
91
+ return { start: nil, end: nil } unless File.file?(path)
92
+
93
+ File.foreach(path) do |line|
94
+ record = JSON.parse(line)
95
+ next unless record.is_a?(Hash)
96
+ next unless record["id"].to_s == id
97
+
98
+ if start_record?(record)
99
+ latest_start = record
100
+ matching_end = nil
101
+ elsif end_record?(record)
102
+ matching_end = record if latest_start && end_matches_start?(record, latest_start)
103
+ end
104
+ rescue JSON::ParserError
105
+ next
106
+ end
107
+
108
+ { start: latest_start, end: matching_end }
109
+ end
110
+
111
+ # A dispatch_start row with no completing end row whose pid is alive on
112
+ # this host — evidence of a running session even when the live registry
113
+ # is not visible from the caller's context.
114
+ def live_start_record(repo_root:, id:)
115
+ normalized_id = Harnex.normalize_id(id)
116
+ rows = latest_rows(path_for(repo_root), normalized_id)
117
+ start = rows[:start]
118
+ return nil unless start
119
+ return nil if rows[:end]
120
+ return nil unless same_host?(start)
121
+
122
+ pid = start["pid"]
123
+ return nil unless pid && Harnex.alive_pid?(pid)
124
+
125
+ start
126
+ rescue StandardError
127
+ nil
128
+ end
129
+
130
+ def same_host?(record)
131
+ host = record["host"].to_s
132
+ return true if host.empty?
133
+
134
+ host == Harnex.host_info[:host].to_s
135
+ end
136
+
137
+ # Appended at registration so a running dispatch always has a durable
138
+ # trace; the dispatch_end row written in finalize_session! completes it.
139
+ def build_start_record(session)
140
+ {
141
+ schema_version: 1,
142
+ record_type: "dispatch_start",
143
+ id: session.id,
144
+ session_id: session.session_id,
145
+ pid: session.pid,
146
+ host: Harnex.host_info[:host],
147
+ cli: session.adapter.key,
148
+ description: session.description,
149
+ started_at: session.started_at.utc.iso8601,
150
+ repo_root: session.repo_root,
151
+ tier: session.__send__(:meta_hash)["tier"],
152
+ meta: session.__send__(:meta_hash),
153
+ summary_out_path: session.summary_out,
154
+ events_log_path: session.events_log_path
155
+ }
156
+ end
157
+
65
158
  def build_record(session)
66
159
  ended_at = session.ended_at || Time.now
67
160
  status, terminal_event = classify(session)
68
161
  {
69
162
  schema_version: 1,
163
+ record_type: "dispatch_end",
70
164
  id: session.id,
165
+ session_id: session.session_id,
71
166
  description: session.description,
72
167
  cli: session.adapter.key,
73
168
  started_at: session.started_at.utc.iso8601,
@@ -205,6 +205,7 @@ module Harnex
205
205
  @server = ApiServer.new(self)
206
206
  @server.start
207
207
  persist_registry
208
+ append_dispatch_start_record
208
209
 
209
210
  stdin_state = STDIN.tty? ? STDIN.raw! : nil
210
211
  watch_thread = start_watch_thread
@@ -474,6 +475,7 @@ module Harnex
474
475
  @server = ApiServer.new(self)
475
476
  @server.start
476
477
  persist_registry
478
+ append_dispatch_start_record
477
479
 
478
480
  watch_thread = start_watch_thread
479
481
  @inbox.start
@@ -1993,13 +1995,26 @@ module Harnex
1993
1995
  warn("harnex: failed to write dispatch summary #{summary_out}: #{e.message}")
1994
1996
  end
1995
1997
 
1998
+ def append_dispatch_start_record
1999
+ DispatchHistory.append(dispatch_history_path, DispatchHistory.build_start_record(self))
2000
+ rescue StandardError => e
2001
+ warn("harnex: failed to write dispatch start record: #{e.message}")
2002
+ end
2003
+
1996
2004
  def append_dispatch_history_record
1997
- path = DispatchHistory.path_for(launch_cwd)
1998
- DispatchHistory.append(path, DispatchHistory.build_record(self))
2005
+ DispatchHistory.append(dispatch_history_path, DispatchHistory.build_record(self))
1999
2006
  rescue StandardError => e
2000
2007
  warn("harnex: failed to write dispatch history: #{e.message}")
2001
2008
  end
2002
2009
 
2010
+ # One canonical stream per session: the start row and the end row must
2011
+ # land in the same file so readers can pair them. repo_root — not the
2012
+ # launch cwd — is the root that registry, events, and default summary
2013
+ # paths already key off.
2014
+ def dispatch_history_path
2015
+ @dispatch_history_path ||= DispatchHistory.path_for(repo_root)
2016
+ end
2017
+
2003
2018
  def normalized_usage_summary(summary)
2004
2019
  summary ||= {}
2005
2020
  USAGE_FIELDS.to_h { |field| [field, summary[field] || summary[field.to_s]] }
@@ -78,6 +78,7 @@ module Harnex
78
78
  File.foreach(path) do |line|
79
79
  record = JSON.parse(line)
80
80
  next unless record.is_a?(Hash)
81
+ next if DispatchHistory.start_record?(record)
81
82
 
82
83
  if summary_record?(record) && record.dig("meta", "id").to_s == id
83
84
  summary_record = record
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.7.14"
3
- RELEASE_DATE = "2026-07-15"
2
+ VERSION = "0.8.0"
3
+ RELEASE_DATE = "2026-08-02"
4
4
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: harnex
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.14
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jikku Jose
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-15 00:00:00.000000000 Z
11
+ date: 2026-08-02 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A local PTY harness that wraps terminal AI agents (Claude, Codex, Pi)
14
14
  and adds a control plane for discovery, messaging, and coordination.