harnex 0.7.4 → 0.7.5

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: e99b85fa8112b666665da16757d871098867e0b05c9521c3507fc2092616f825
4
- data.tar.gz: 78a5190335ee968c77d75531d23998c34272d24a3b6a8d6a53d5e9a8922c87da
3
+ metadata.gz: 4c6173057b1fe4fa547979d3d9803f345acb4e658dd942aefa096ef886727c33
4
+ data.tar.gz: da1662c9dce791ae644aa4a0ea57d2124a1698593e6db98b2ac737d9f42816f6
5
5
  SHA512:
6
- metadata.gz: 4c32673867f2b86ee84fa1768ee4fee82a3ddb0fb69abd5c369349d59fe9feef3cc468ee71776115da19ca2997b1f647678b413f8c93d02538f56c17daf316d4
7
- data.tar.gz: bf049097af8736c25991af1894dd15b6cc8159393f99eb215a0dc7ddd67f9716b1864589664fdd08671edc6859899c9cb84c46852b940323b8f11f42d5f8ee29
6
+ metadata.gz: e2758cfa10fc9b221235efea20ff21be32b5558c1ab06eb4515cea5485be96e1371d663735c826d9587833caa46aed65486b5c3cedfe8ce3be04e7ac1c328f7f
7
+ data.tar.gz: 765cf4ef020f3a7cfa8bbd1aad517ac1b4ec8901579b3720ea618ec246b20009c07c088d4d86e354ead78194844bba771790df0271d54fccecad6f6058874d9f
data/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.7.5] - 2026-05-26 | 05:18 PM | IST
6
+
7
+ ### Added
8
+
9
+ - `Harnex::TerminalStatus` now resolves durable terminal dispatch state from
10
+ summary/history rows so commands can classify inactive sessions without
11
+ relying on tmp done markers.
12
+
13
+ ### Changed
14
+
15
+ - `harnex status --json --id <id>` now returns a machine-readable row even when
16
+ the live session is gone, with `state` in `running|completed|failed|unknown`
17
+ and terminal metadata (`terminal`, `exit`, `exit_code`, `summary_out`).
18
+ - `harnex wait --id <id>` now falls back to terminal summary/history telemetry
19
+ when registry and exit-status files are missing, returning `completed` on
20
+ summary success and `unknown` when no durable terminal signal exists.
21
+ - Monitoring guides now treat `/tmp/*-done.txt` as legacy compatibility hints;
22
+ canonical completion is `harnex wait` / `harnex status --json` / dispatch
23
+ summary rows.
24
+
5
25
  ## [0.7.4] - 2026-05-25 | 08:45 AM | IST
6
26
 
7
27
  ### Added
data/guides/03_buddy.md CHANGED
@@ -54,8 +54,8 @@ If the worker appears stuck at a prompt or permission dialog for more than
54
54
  10 minutes with no progress, nudge it:
55
55
  - `harnex send --id pi-i-42 --message "You appear to have stalled. Continue with your current task."`
56
56
 
57
- If the worker exits or writes `/tmp/pi-i-42-done.txt`, report back:
58
- - `tmux send-keys -t "$HARNEX_SPAWNER_PANE" "pi-i-42 finished. Check /tmp/pi-i-42-done.txt." Enter`
57
+ If `harnex status --id pi-i-42 --json` reports `state=completed` or `state=failed`, report back:
58
+ - `tmux send-keys -t "$HARNEX_SPAWNER_PANE" "pi-i-42 terminal state reached. Check harnex status --id pi-i-42 --json." Enter`
59
59
 
60
60
  Do not interfere with active work. Stop yourself after reporting completion.
61
61
  ```
@@ -24,16 +24,20 @@ afterward.
24
24
 
25
25
  ## Completion Test
26
26
 
27
- For unattended work, declare done with a conjunction of work-level facts:
27
+ For unattended work, first gate on harnex terminal state, then verify the task
28
+ artifact and repo health:
28
29
 
29
30
  ```bash
30
- test -f /tmp/pi-i-NN-done.txt &&
31
- test -z "$(git status --short)" &&
32
- test "$(git log -1 --format=%ct)" -lt "$(($(date +%s) - 600))"
31
+ harnex wait --id pi-i-NN --timeout 5400 &&
32
+ test -f path/to/expected-artifact &&
33
+ test -z "$(git status --short)"
33
34
  ```
34
35
 
35
- Adjust the artifact path and commit-age window to the task. The point is to
36
- avoid declaring done while a worker is between edits or between commits.
36
+ `harnex wait` succeeds from durable terminal telemetry (`--summary-out` /
37
+ `.harnex/dispatch.jsonl` / exit status), not from tmp done markers.
38
+
39
+ Adjust the artifact path to the task. The point is to avoid declaring done while
40
+ a worker is between edits or between commits.
37
41
 
38
42
  ## Why Pane State Alone Is Not Enough
39
43
 
@@ -73,22 +77,30 @@ harnex wait --id pi-i-NN --until task_complete --timeout 900
73
77
 
74
78
  ## Background Sweeper
75
79
 
76
- Consumers often run a small shell loop that checks the expected done marker,
77
- tree state, and harnex liveness. Keep a hard wall-clock cap so an unattended
78
- pipeline cannot wait forever:
80
+ Consumers often run a small shell loop that checks terminal state, then drops
81
+ to pane diagnostics only while work is still running. Keep a hard wall-clock cap
82
+ so an unattended pipeline cannot wait forever:
79
83
 
80
84
  ```bash
81
85
  start=$(date +%s)
82
86
  max_wait=5400
83
87
 
84
- until test -f /tmp/pi-i-NN-done.txt; do
88
+ while :; do
85
89
  if test "$(($(date +%s) - start))" -gt "$max_wait"; then
86
90
  echo "wall-clock cap hit for pi-i-NN" >&2
87
91
  exit 2
88
92
  fi
89
93
 
90
- harnex status --id pi-i-NN --json
91
- harnex pane --id pi-i-NN --lines 20
94
+ row=$(harnex status --id pi-i-NN --json | ruby -rjson -e 'rows=JSON.parse(STDIN.read); print JSON.generate(rows.first || {})')
95
+ state=$(printf '%s' "$row" | ruby -rjson -e 'print(JSON.parse(STDIN.read)["state"].to_s)')
96
+
97
+ case "$state" in
98
+ completed) echo "pi-i-NN completed"; break ;;
99
+ failed|unknown) echo "pi-i-NN terminal state: $state" >&2; exit 1 ;;
100
+ running) harnex pane --id pi-i-NN --lines 20 ;;
101
+ *) harnex pane --id pi-i-NN --lines 20 ;;
102
+ esac
103
+
92
104
  sleep 60
93
105
  done
94
106
  ```
@@ -125,6 +137,7 @@ interpretation.
125
137
  ## Anti-Patterns
126
138
 
127
139
  - Polling `state=prompt` alone and calling it done.
140
+ - Blocking orchestrators on `/tmp/*-done.txt` as the only completion signal.
128
141
  - Letting an unattended loop run with no wall-clock cap.
129
142
  - Reading raw tmux panes instead of `harnex pane`.
130
143
  - Using `--wait-for-idle` as acceptance proof.
data/guides/05_naming.md CHANGED
@@ -93,9 +93,9 @@ The task file name does not need to duplicate the exact short phase code. It
93
93
  should be easy to scan in `/tmp` and should include the same task number as the
94
94
  session ID.
95
95
 
96
- ## Done Markers
96
+ ## Done Markers (Legacy Compatibility)
97
97
 
98
- Derive done markers from the session ID:
98
+ If a legacy workflow still expects a done marker, derive it from the session ID:
99
99
 
100
100
  ```text
101
101
  /tmp/pi-p-42-done.txt
@@ -103,5 +103,9 @@ Derive done markers from the session ID:
103
103
  /tmp/pi-cr-42-done.txt
104
104
  ```
105
105
 
106
+ Treat done markers as compatibility hints only. Canonical completion should come
107
+ from harnex terminal telemetry (`harnex wait` / `harnex status --json` / summary
108
+ rows in `.harnex/dispatch.jsonl`).
109
+
106
110
  When a brief asks for a completion marker, make it one line and include the
107
111
  highest-signal result: tests passed, review clean, or the blocking issue.
@@ -28,6 +28,8 @@ module Harnex
28
28
  Gotchas:
29
29
  By default, status filters to the current repo root.
30
30
  Use --all when supervising workers launched from sibling worktrees.
31
+ With --id, terminal summaries can report completed/failed/unknown
32
+ even after the live session registry is gone.
31
33
  A prompt-like state is not a completion signal by itself.
32
34
  TEXT
33
35
  end
@@ -83,12 +85,31 @@ module Harnex
83
85
  end
84
86
 
85
87
  def load_sessions
86
- repo_root = @options[:all] ? nil : Harnex.resolve_repo_root(@options[:repo_path])
87
- sessions = Harnex.active_sessions(repo_root, id: @options[:id])
88
+ active_repo_root = @options[:all] ? nil : Harnex.resolve_repo_root(@options[:repo_path])
89
+ fallback_repo_root = Harnex.resolve_repo_root(@options[:repo_path])
90
+ sessions = Harnex.active_sessions(active_repo_root, id: @options[:id])
91
+
92
+ live = sessions.map { |session| normalize_live_status(load_live_status(session)) }
93
+ .sort_by { |session| [session["repo_root"].to_s, session["started_at"].to_s, session["id"].to_s] }
94
+ .reverse
95
+ return live unless @options[:id]
96
+ return [live.first] unless live.empty?
97
+
98
+ terminal = Harnex::TerminalStatus.resolve(id: @options[:id], repo_root: fallback_repo_root)
99
+ [terminal || Harnex::TerminalStatus.unknown(id: @options[:id], repo_root: fallback_repo_root)]
100
+ end
88
101
 
89
- sessions.map { |session| load_live_status(session) }
90
- .sort_by { |session| [session["repo_root"].to_s, session["started_at"].to_s, session["id"].to_s] }
91
- .reverse
102
+ def normalize_live_status(session)
103
+ session.merge(
104
+ "state" => "running",
105
+ "terminal" => false,
106
+ "task_complete" => !session["last_completed_at"].to_s.empty?,
107
+ "exit" => nil,
108
+ "exit_code" => nil,
109
+ "summary_out" => nil,
110
+ "ended_at" => nil,
111
+ "source" => "live"
112
+ )
92
113
  end
93
114
 
94
115
  def load_live_status(session)
@@ -128,7 +149,7 @@ module Harnex
128
149
  "PORT" => session["port"].to_s,
129
150
  "AGE" => timeago(session["started_at"]),
130
151
  "IDLE" => format_idle(session["log_idle_s"]),
131
- "STATE" => session.dig("input_state", "state").to_s.empty? ? "-" : session.dig("input_state", "state").to_s,
152
+ "STATE" => table_state(session),
132
153
  "DESC" => truncate(session["description"])
133
154
  }
134
155
  row["REPO"] = truncate_repo(session["repo_root"])
@@ -139,6 +160,14 @@ module Harnex
139
160
  columns.map { |column| row.fetch(column).ljust(widths.fetch(column)) }.join(" ")
140
161
  end
141
162
 
163
+ def table_state(session)
164
+ input_state = session.dig("input_state", "state").to_s
165
+ return input_state unless input_state.empty?
166
+
167
+ state = session["state"].to_s
168
+ state.empty? ? "-" : state
169
+ end
170
+
142
171
  def timeago(timestamp)
143
172
  return "-" if timestamp.to_s.empty?
144
173
 
@@ -38,6 +38,8 @@ module Harnex
38
38
  Gotchas:
39
39
  task_complete is an event predicate; prompt/busy are live state polls.
40
40
  Prompt state alone does not prove work acceptance. Verify artifacts/tests.
41
+ Exit waits can resolve from terminal summary rows when live registry/
42
+ exit-status files are already gone.
41
43
  Without --timeout, wait can block indefinitely.
42
44
  TEXT
43
45
  end
@@ -238,7 +240,11 @@ module Harnex
238
240
  unless registry
239
241
  return read_exit_status(exit_path, @options[:id]) if File.exist?(exit_path)
240
242
 
243
+ terminal = terminal_status(repo_root)
244
+ return emit_terminal_status(terminal) if terminal
245
+
241
246
  warn("harnex wait: no session found with id #{@options[:id].inspect}")
247
+ puts JSON.generate(ok: false, id: @options[:id], state: "unknown", terminal: false, status: "unknown")
242
248
  return 1
243
249
  end
244
250
 
@@ -248,7 +254,13 @@ module Harnex
248
254
  loop do
249
255
  unless Harnex.alive_pid?(target_pid)
250
256
  await_exit_status(exit_path)
251
- return read_exit_status(exit_path, @options[:id])
257
+ return read_exit_status(exit_path, @options[:id]) if File.exist?(exit_path)
258
+
259
+ terminal = terminal_status(repo_root)
260
+ return emit_terminal_status(terminal) if terminal
261
+
262
+ puts JSON.generate(ok: false, id: @options[:id], state: "unknown", terminal: false, status: "unknown")
263
+ return 1
252
264
  end
253
265
 
254
266
  if deadline && Time.now >= deadline
@@ -308,6 +320,38 @@ module Harnex
308
320
  end
309
321
  end
310
322
 
323
+ def terminal_status(repo_root)
324
+ status = Harnex::TerminalStatus.resolve(id: @options[:id], repo_root: repo_root)
325
+ return nil unless status
326
+ return nil unless status["terminal"]
327
+
328
+ status
329
+ end
330
+
331
+ def emit_terminal_status(status)
332
+ payload = {
333
+ ok: status["state"] == "completed",
334
+ id: status["id"],
335
+ state: status["state"],
336
+ terminal: true,
337
+ task_complete: status["task_complete"],
338
+ exit: status["exit"],
339
+ exit_code: status["exit_code"],
340
+ summary_out: status["summary_out"],
341
+ ended_at: status["ended_at"],
342
+ source: status["source"]
343
+ }
344
+ puts JSON.generate(payload)
345
+
346
+ if payload[:ok]
347
+ 0
348
+ elsif status["exit_code"].is_a?(Integer) && status["exit_code"] > 0
349
+ status["exit_code"]
350
+ else
351
+ 1
352
+ end
353
+ end
354
+
311
355
  def parser
312
356
  @parser ||= OptionParser.new do |opts|
313
357
  opts.banner = "Usage: harnex wait [options]"
@@ -0,0 +1,202 @@
1
+ require "json"
2
+ require "time"
3
+
4
+ module Harnex
5
+ module TerminalStatus
6
+ module_function
7
+
8
+ def resolve(id:, repo_root: Dir.pwd)
9
+ normalized_id = Harnex.normalize_id(id)
10
+ root = File.expand_path(repo_root.to_s.empty? ? Dir.pwd : repo_root)
11
+
12
+ latest_summary = nil
13
+ latest_summary_path = nil
14
+ latest_history = nil
15
+
16
+ history_paths(root).each do |path|
17
+ summary, history = scan_dispatch_path(path, normalized_id)
18
+ if newer_summary?(summary, latest_summary)
19
+ latest_summary = summary
20
+ latest_summary_path = path
21
+ end
22
+ latest_history = history if newer_history?(history, latest_history)
23
+ end
24
+
25
+ summary_path = latest_history && latest_history["summary_out_path"].to_s.strip
26
+ if summary_path && !summary_path.empty? && File.file?(summary_path)
27
+ summary, = scan_dispatch_path(summary_path, normalized_id)
28
+ if newer_summary?(summary, latest_summary)
29
+ latest_summary = summary
30
+ latest_summary_path = summary_path
31
+ end
32
+ end
33
+
34
+ return build_from_summary(latest_summary, latest_summary_path, root) if latest_summary
35
+ return build_from_history(latest_history, root) if latest_history
36
+
37
+ nil
38
+ end
39
+
40
+ def unknown(id:, repo_root: Dir.pwd)
41
+ {
42
+ "id" => Harnex.normalize_id(id),
43
+ "repo_root" => File.expand_path(repo_root.to_s.empty? ? Dir.pwd : repo_root),
44
+ "state" => "unknown",
45
+ "terminal" => false,
46
+ "task_complete" => false,
47
+ "exit" => nil,
48
+ "exit_code" => nil,
49
+ "summary_out" => nil,
50
+ "started_at" => nil,
51
+ "ended_at" => nil,
52
+ "source" => "none"
53
+ }
54
+ end
55
+
56
+ def history_paths(repo_root)
57
+ local_path = DispatchHistory.path_for(repo_root)
58
+ return [local_path] if File.file?(local_path)
59
+
60
+ global_path = DispatchHistory.global_path
61
+ return [global_path] if File.file?(global_path)
62
+
63
+ []
64
+ rescue StandardError
65
+ []
66
+ end
67
+
68
+ def scan_dispatch_path(path, id)
69
+ summary_record = nil
70
+ history_record = nil
71
+
72
+ File.foreach(path) do |line|
73
+ record = JSON.parse(line)
74
+ next unless record.is_a?(Hash)
75
+
76
+ if summary_record?(record) && record.dig("meta", "id").to_s == id
77
+ summary_record = record
78
+ elsif history_record?(record) && record["id"].to_s == id
79
+ history_record = record
80
+ end
81
+ rescue JSON::ParserError
82
+ next
83
+ end
84
+
85
+ [summary_record, history_record]
86
+ rescue Errno::ENOENT
87
+ [nil, nil]
88
+ end
89
+
90
+ def summary_record?(record)
91
+ record["meta"].is_a?(Hash) && record["actual"].is_a?(Hash)
92
+ end
93
+
94
+ def history_record?(record)
95
+ record["schema_version"] == 1 && record.key?("status")
96
+ end
97
+
98
+ def newer_summary?(candidate, current)
99
+ return false unless candidate
100
+ return true unless current
101
+
102
+ summary_time(candidate) >= summary_time(current)
103
+ end
104
+
105
+ def newer_history?(candidate, current)
106
+ return false unless candidate
107
+ return true unless current
108
+
109
+ history_time(candidate) >= history_time(current)
110
+ end
111
+
112
+ def summary_time(record)
113
+ parse_time(record.dig("meta", "ended_at")) || parse_time(record.dig("meta", "started_at")) || Time.at(0)
114
+ end
115
+
116
+ def history_time(record)
117
+ parse_time(record["ended_at"]) || parse_time(record["started_at"]) || Time.at(0)
118
+ end
119
+
120
+ def parse_time(value)
121
+ Time.iso8601(value.to_s)
122
+ rescue ArgumentError
123
+ nil
124
+ end
125
+
126
+ def build_from_summary(record, summary_path, fallback_repo_root)
127
+ meta = record["meta"] || {}
128
+ actual = record["actual"] || {}
129
+ state = classify_summary_state(actual)
130
+ {
131
+ "id" => meta["id"].to_s,
132
+ "repo_root" => meta["repo"] || fallback_repo_root,
133
+ "state" => state,
134
+ "terminal" => state != "unknown",
135
+ "task_complete" => !!actual["task_complete"],
136
+ "exit" => blank_to_nil(actual["exit"]),
137
+ "exit_code" => actual["exit_code"],
138
+ "summary_out" => summary_path,
139
+ "started_at" => meta["started_at"],
140
+ "ended_at" => meta["ended_at"],
141
+ "source" => "summary_out"
142
+ }
143
+ end
144
+
145
+ def classify_summary_state(actual)
146
+ exit = actual["exit"].to_s
147
+ exit_code = actual["exit_code"]
148
+
149
+ return "completed" if exit == "success"
150
+ return "completed" if exit.empty? && exit_code == 0
151
+ return "failed" unless exit.empty? && exit_code.nil?
152
+
153
+ "unknown"
154
+ end
155
+
156
+ def build_from_history(record, fallback_repo_root)
157
+ status = record["status"].to_s
158
+ state =
159
+ case status
160
+ when "completed"
161
+ "completed"
162
+ when "failed", "timeout", "killed"
163
+ "failed"
164
+ else
165
+ "unknown"
166
+ end
167
+ {
168
+ "id" => record["id"].to_s,
169
+ "repo_root" => fallback_repo_root,
170
+ "state" => state,
171
+ "terminal" => state != "unknown",
172
+ "task_complete" => record["terminal_event"].to_s == "task_complete",
173
+ "exit" => history_exit(status),
174
+ "exit_code" => nil,
175
+ "summary_out" => blank_to_nil(record["summary_out_path"]),
176
+ "started_at" => record["started_at"],
177
+ "ended_at" => record["ended_at"],
178
+ "source" => "dispatch_history"
179
+ }
180
+ end
181
+
182
+ def history_exit(status)
183
+ case status
184
+ when "completed"
185
+ "success"
186
+ when "timeout"
187
+ "timeout"
188
+ when "killed"
189
+ "killed"
190
+ when "failed"
191
+ "failure"
192
+ else
193
+ nil
194
+ end
195
+ end
196
+
197
+ def blank_to_nil(value)
198
+ text = value.to_s
199
+ text.empty? ? nil : text
200
+ end
201
+ end
202
+ end
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.7.4"
3
- RELEASE_DATE = "2026-05-25"
2
+ VERSION = "0.7.5"
3
+ RELEASE_DATE = "2026-05-26"
4
4
  end
data/lib/harnex.rb CHANGED
@@ -5,6 +5,7 @@ require "open3"
5
5
  require_relative "harnex/version"
6
6
  require_relative "harnex/core"
7
7
  require_relative "harnex/dispatch_history"
8
+ require_relative "harnex/terminal_status"
8
9
  require_relative "harnex/watcher"
9
10
  require_relative "harnex/adapters"
10
11
  require_relative "harnex/runtime/session_state"
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.4
4
+ version: 0.7.5
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-05-25 00:00:00.000000000 Z
11
+ date: 2026-05-26 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.
@@ -65,6 +65,7 @@ files:
65
65
  - lib/harnex/runtime/message.rb
66
66
  - lib/harnex/runtime/session.rb
67
67
  - lib/harnex/runtime/session_state.rb
68
+ - lib/harnex/terminal_status.rb
68
69
  - lib/harnex/version.rb
69
70
  - lib/harnex/watcher.rb
70
71
  - lib/harnex/watcher/inotify.rb