harnex 0.7.5 → 0.7.7

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: 4c6173057b1fe4fa547979d3d9803f345acb4e658dd942aefa096ef886727c33
4
- data.tar.gz: da1662c9dce791ae644aa4a0ea57d2124a1698593e6db98b2ac737d9f42816f6
3
+ metadata.gz: '09276571f581a98eb6c613e88e4035ed8420bcae710c43d57e307cc5eb4d468b'
4
+ data.tar.gz: 886e1555b2ee4d128d992e4d35aafc716c0bc2aa16318123b053e5e128a7337a
5
5
  SHA512:
6
- metadata.gz: e2758cfa10fc9b221235efea20ff21be32b5558c1ab06eb4515cea5485be96e1371d663735c826d9587833caa46aed65486b5c3cedfe8ce3be04e7ac1c328f7f
7
- data.tar.gz: 765cf4ef020f3a7cfa8bbd1aad517ac1b4ec8901579b3720ea618ec246b20009c07c088d4d86e354ead78194844bba771790df0271d54fccecad6f6058874d9f
6
+ metadata.gz: 90f7f457f829f99424c1f2635706c3231c08bf1cea7329e47b6992fde3aa45e123c40c0d278bf2c9a5c8bc2ecd6d0f742a812320c659c5bbe68fd4ab94df6c9d
7
+ data.tar.gz: 65323fa96b6f5c92564b2be10cfe802f846fff1b76079f26cbaa362588dce50ae86a59c250b456058f3acad8ca31f03c9c49b2ad096c85a6aae5510797ff3abb
data/CHANGELOG.md CHANGED
@@ -2,6 +2,42 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.7.7] - 2026-06-12 | 10:48 AM | IST
6
+
7
+ ### Fixed
8
+
9
+ - Codex app-server failed turns now emit `task_failed` instead of being
10
+ misreported as successful `task_complete` work. `harnex wait --until done`
11
+ returns non-zero for failed-turn events, dispatch history records
12
+ `terminal_event=task_failed`, and auto-stop terminates structured sessions
13
+ without sending a stale `turn/interrupt` after the turn is already complete.
14
+ - Codex app-server nested error notifications now preserve the real Codex error
15
+ message (for example missing provider credentials) without counting them as
16
+ transport disconnects.
17
+
18
+ ### Changed
19
+
20
+ - Refreshed the pinned Codex app-server JSON Schema fixtures to
21
+ `codex-cli 0.139.0` and taught the test schema validator `minLength`.
22
+
23
+ ## [0.7.6] - 2026-06-09 | 12:59 AM | IST
24
+
25
+ ### Added
26
+
27
+ - `harnex status --json` now exposes work-level completion fields (`done`,
28
+ `work_state`, and `process_state`) so monitors can distinguish completed
29
+ work from a still-live interactive process.
30
+ - `harnex wait --until done` waits for `task_complete` or terminal exit,
31
+ whichever arrives first, giving queue monitors a safe default fence.
32
+
33
+ ### Changed
34
+
35
+ - Refreshed README quick-start and monitoring guidance to emphasize
36
+ `--context --auto-stop`, `--until task_complete` for interactive structured
37
+ sessions, durable terminal summaries, and timeout/artifact verification.
38
+ - Monitoring, buddy, and recipe examples now gate unattended work on
39
+ `--until done` / `done` / `work_state` instead of `state=completed` alone.
40
+
5
41
  ## [0.7.5] - 2026-05-26 | 05:18 PM | IST
6
42
 
7
43
  ### Added
data/README.md CHANGED
@@ -33,16 +33,24 @@ or project-local docs are required.
33
33
  ## What it does
34
34
 
35
35
  ```bash
36
- # Start an agent in tmux
37
- harnex run pi --id planner --tmux planner
36
+ # Start a visible one-shot worker, give it a task, and stop on completion
37
+ harnex run pi --id planner --tmux planner \
38
+ --context "Write a plan to /tmp/plan.md" --auto-stop
38
39
 
39
- # Send it a task and wait for it to finish
40
- harnex send --id planner --message "Write a plan to /tmp/plan.md" --wait-for-idle
40
+ # Wait for the work-level completion signal and terminal telemetry
41
+ harnex wait --id planner --until done --timeout 900
41
42
 
42
- # Peek at what it's doing
43
- harnex pane --id planner --lines 30
43
+ # Inspect the final state or the recent dispatch history
44
+ harnex status --id planner --json
45
+ harnex history --limit 5
46
+ ```
47
+
48
+ For a long-lived interactive session, keep the worker open and send work to it:
44
49
 
45
- # Stop it
50
+ ```bash
51
+ harnex run pi --id planner --tmux planner
52
+ harnex send --id planner --message "Write a plan to /tmp/plan.md" --wait-for-idle
53
+ harnex pane --id planner --lines 30
46
54
  harnex stop --id planner
47
55
  ```
48
56
 
@@ -59,8 +67,9 @@ job, watch it work, stop it when done.
59
67
  tmux-backed terminal, `harnex logs` tails the persisted transcript, and
60
68
  `harnex events` streams structured JSONL lifecycle events.
61
69
 
62
- - **You don't want to babysit.** Send a task with `--wait-for-idle`,
63
- walk away, check back when it's done.
70
+ - **You don't want to babysit.** Use `--context --auto-stop` for
71
+ one-shot work, `--watch` for bounded stall recovery, or
72
+ `--wait-for-idle` as a send fence.
64
73
 
65
74
  - **You want local-only orchestration.** Everything runs on your
66
75
  machine. No cloud services, no API keys beyond what the agents need.
@@ -169,6 +178,31 @@ bare `--watch` means babysitter mode.
169
178
  For one-shot startup prompts, add `--auto-stop`. It requires `--context`
170
179
  and stops the session after the first task completion or PTY prompt return.
171
180
 
181
+ ## Completion and waiting
182
+
183
+ Choose the wait predicate that matches how you launched the worker:
184
+
185
+ - `harnex wait --id ID --until done --timeout SECS` is the safest unattended
186
+ work fence. It returns when Harnex sees `task_complete`, `task_failed`, or a
187
+ terminal exit, whichever comes first; failed work returns non-zero.
188
+ - `harnex wait --id ID` waits for the wrapped process to exit. This is right
189
+ for already-exited sessions and terminal-summary recovery, but interactive
190
+ agents can stay open after finishing a turn.
191
+ - For structured Pi RPC and Codex app-server sessions, use
192
+ `harnex wait --id ID --until task_complete --timeout SECS` when you need the
193
+ exact successful-turn event instead of terminal-exit fallback. Use
194
+ `--until task_failed` to wait specifically for a failed structured turn.
195
+ - `harnex send --wait-for-idle` is an atomic send fence for PTY-style
196
+ interactions. It proves the turn returned to an idle/prompt state, not that
197
+ your acceptance criteria passed.
198
+ - `harnex status --id ID --json` can report `running`, `completed`, `failed`,
199
+ or `unknown` from durable terminal summary rows even after the live registry
200
+ is gone. Treat `state` as process/session state; use `done` and `work_state`
201
+ as the work-level monitor contract.
202
+
203
+ Always set a timeout for unattended waits and verify the expected artifact,
204
+ tests, or git state after harnex reports completion.
205
+
172
206
  For structured subscriptions, stream JSONL events:
173
207
 
174
208
  ```bash
@@ -183,7 +217,9 @@ Schema details and compatibility policy are documented in
183
217
  Every finished `harnex run` writes dispatch records. In a git repo, the
184
218
  default path is `<repo>/.harnex/dispatch.jsonl`; outside a git repo, the
185
219
  compact history record falls back to `~/.local/state/harnex/dispatch.jsonl`.
186
- `harnex history` reads the compact records from that location.
220
+ `harnex history` reads the compact records from that location, and
221
+ `harnex status --id ID --json` / `harnex wait` can use the same durable
222
+ terminal summaries when the live session registry is already gone.
187
223
 
188
224
  Use `harnex history` to inspect it:
189
225
 
@@ -274,14 +310,14 @@ See [recipes/03_buddy.md](recipes/03_buddy.md) for the full pattern.
274
310
  | Command | What it does |
275
311
  |---------|-------------|
276
312
  | `harnex run <cli>` | Start an agent (`--tmux` visible, `--detach` background, `--watch` built-in monitoring) |
277
- | `harnex send --id <id>` | Send a message (queues if busy, `--wait-for-idle` to block until done) |
313
+ | `harnex send --id <id>` | Send a message (queues if busy, `--wait-for-idle` to block until the turn returns idle) |
278
314
  | `harnex stop --id <id>` | Send the agent's native exit sequence |
279
- | `harnex status` | List running sessions (`--json` for full payloads) |
315
+ | `harnex status` | List running sessions; with `--id ID --json`, terminal summaries can classify completed/failed sessions after exit |
280
316
  | `harnex pane --id <id>` | Capture a tmux-backed session's screen (`--follow` for live) |
281
317
  | `harnex logs --id <id>` | Read session transcript (`--follow` to tail) |
282
318
  | `harnex events --id <id>` | Stream structured session events (`--snapshot` for non-blocking dump) |
283
319
  | `harnex history` | List completed dispatches from `.harnex/dispatch.jsonl` |
284
- | `harnex wait --id <id>` | Block until exit, a target state, or `--until task_complete` |
320
+ | `harnex wait --id <id>` | Block until process exit by default; use `--until done` for unattended work completion or `--until task_complete` for exact structured turn completion |
285
321
  | `harnex doctor` | Run adapter dependency preflight checks; add `--sweep` for read-only session drift diagnostics |
286
322
  | `harnex guide` | Getting started walkthrough |
287
323
  | `harnex agents-guide` | Agent-facing dispatch, chain, buddy, monitoring, and naming guides |
@@ -73,7 +73,7 @@ for i in 1 2 3; do
73
73
  harnex run pi --id w-$i --tmux w-$i --detach \
74
74
  --context "Read and execute /tmp/task-$i.md" --auto-stop &
75
75
  done
76
- for i in 1 2 3; do harnex wait --id w-$i & done
76
+ for i in 1 2 3; do harnex wait --id w-$i --until done & done
77
77
  wait
78
78
  ```
79
79
 
@@ -125,7 +125,8 @@ Use the lightest primitive that gives the signal you need:
125
125
  | Continuous pane view | `harnex pane --id pi-i-NN --follow` |
126
126
  | Transcript tail | `harnex logs --id pi-i-NN --lines 80` |
127
127
  | Structured events | `harnex events --id pi-i-NN --snapshot` |
128
- | Native turn completion | `harnex wait --id pi-i-NN --until task_complete` |
128
+ | Work completion/failure fence | `harnex wait --id pi-i-NN --until done` |
129
+ | Native successful-turn completion | `harnex wait --id pi-i-NN --until task_complete` |
129
130
 
130
131
  For unattended policy-only stall recovery, use built-in watch mode:
131
132
 
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 `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`
57
+ If `harnex status --id pi-i-42 --json` reports `done=true` or `work_state=failed`, report back:
58
+ - `tmux send-keys -t "$HARNEX_SPAWNER_PANE" "pi-i-42 work-level 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
  ```
@@ -17,24 +17,28 @@ Prefer signals in this order:
17
17
  | `harnex pane` | Live UI interpretation and prompt/error diagnosis |
18
18
  | `harnex status` | Session liveness and coarse state |
19
19
 
20
- For structured sessions (Pi RPC and Codex app-server),
21
- `harnex wait --until task_complete` is a strong turn-level fence. It still
22
- does not know your acceptance criteria; verify the expected artifact or tests
23
- afterward.
20
+ For unattended monitors, prefer `harnex wait --until done`: it returns on the
21
+ work-level `task_complete` or `task_failed` signal, or terminal exit, whichever
22
+ comes first. Failed work returns non-zero. For structured sessions (Pi RPC and
23
+ Codex app-server), `harnex wait --until task_complete` remains the exact
24
+ successful-turn fence. Neither knows your acceptance criteria; verify the
25
+ expected artifact or tests afterward.
24
26
 
25
27
  ## Completion Test
26
28
 
27
- For unattended work, first gate on harnex terminal state, then verify the task
29
+ For unattended work, first gate on harnex work completion, then verify the task
28
30
  artifact and repo health:
29
31
 
30
32
  ```bash
31
- harnex wait --id pi-i-NN --timeout 5400 &&
33
+ harnex wait --id pi-i-NN --until done --timeout 5400 &&
32
34
  test -f path/to/expected-artifact &&
33
35
  test -z "$(git status --short)"
34
36
  ```
35
37
 
36
- `harnex wait` succeeds from durable terminal telemetry (`--summary-out` /
37
- `.harnex/dispatch.jsonl` / exit status), not from tmp done markers.
38
+ `harnex wait --until done` succeeds from `task_complete` or durable successful
39
+ terminal telemetry (`--summary-out` / `.harnex/dispatch.jsonl` / exit status),
40
+ returns non-zero for `task_failed` / failed terminal telemetry, and does not use
41
+ tmp done markers.
38
42
 
39
43
  Adjust the artifact path to the task. The point is to avoid declaring done while
40
44
  a worker is between edits or between commits.
@@ -72,6 +76,8 @@ harnex events --id pi-i-NN
72
76
  For task completion:
73
77
 
74
78
  ```bash
79
+ harnex wait --id pi-i-NN --until done --timeout 900
80
+ # Or, when you specifically need the structured turn event:
75
81
  harnex wait --id pi-i-NN --until task_complete --timeout 900
76
82
  ```
77
83
 
@@ -92,12 +98,13 @@ while :; do
92
98
  fi
93
99
 
94
100
  row=$(harnex status --id pi-i-NN --json | ruby -rjson -e 'rows=JSON.parse(STDIN.read); print JSON.generate(rows.first || {})')
101
+ done=$(printf '%s' "$row" | ruby -rjson -e 'print(JSON.parse(STDIN.read)["done"] ? "true" : "false")')
102
+ work_state=$(printf '%s' "$row" | ruby -rjson -e 'print(JSON.parse(STDIN.read)["work_state"].to_s)')
95
103
  state=$(printf '%s' "$row" | ruby -rjson -e 'print(JSON.parse(STDIN.read)["state"].to_s)')
96
104
 
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 ;;
105
+ case "$done:$work_state" in
106
+ true:*) echo "pi-i-NN work completed"; break ;;
107
+ false:failed) echo "pi-i-NN work failed; process state: $state" >&2; exit 1 ;;
101
108
  *) harnex pane --id pi-i-NN --lines 20 ;;
102
109
  esac
103
110
 
@@ -136,6 +143,7 @@ interpretation.
136
143
 
137
144
  ## Anti-Patterns
138
145
 
146
+ - Polling `state=completed` alone and missing live sessions with `task_complete=true`.
139
147
  - Polling `state=prompt` alone and calling it done.
140
148
  - Blocking orchestrators on `/tmp/*-done.txt` as the only completion signal.
141
149
  - Letting an unattended loop run with no wall-clock cap.
@@ -339,7 +339,7 @@ module Harnex
339
339
  @current_turn_id = nil
340
340
  @state = :prompt
341
341
  when "error"
342
- @state = :disconnected
342
+ @state = :busy
343
343
  end
344
344
 
345
345
  @notification_handler&.call(message)
@@ -265,8 +265,7 @@ module Harnex
265
265
 
266
266
  if message["error"]
267
267
  err_msg = message.dig("error", "message") || "RPC error"
268
- pending.push(StandardError.new("codex_appserver RPC error: #{err_msg}"))
269
- signal_disconnect(message["error"])
268
+ pending.push(StandardError.new(err_msg))
270
269
  else
271
270
  pending.push(message["result"] || {})
272
271
  end
@@ -30,6 +30,8 @@ module Harnex
30
30
  Use --all when supervising workers launched from sibling worktrees.
31
31
  With --id, terminal summaries can report completed/failed/unknown
32
32
  even after the live session registry is gone.
33
+ `state` is process/session state; use JSON `done`/`work_state`
34
+ or `harnex wait --until done` for work-level completion.
33
35
  A prompt-like state is not a completion signal by itself.
34
36
  TEXT
35
37
  end
@@ -100,10 +102,17 @@ module Harnex
100
102
  end
101
103
 
102
104
  def normalize_live_status(session)
105
+ task_failed = task_failed?(session)
106
+ task_complete = task_complete?(session) && !task_failed
107
+ work_state = task_failed ? "failed" : Harnex.work_state_for("running", task_complete: task_complete)
103
108
  session.merge(
104
109
  "state" => "running",
110
+ "process_state" => "running",
105
111
  "terminal" => false,
106
- "task_complete" => !session["last_completed_at"].to_s.empty?,
112
+ "task_complete" => task_complete,
113
+ "task_failed" => task_failed,
114
+ "done" => Harnex.work_done_for("running", task_complete: task_complete),
115
+ "work_state" => work_state,
107
116
  "exit" => nil,
108
117
  "exit_code" => nil,
109
118
  "summary_out" => nil,
@@ -112,6 +121,16 @@ module Harnex
112
121
  )
113
122
  end
114
123
 
124
+ def task_complete?(session)
125
+ session["task_complete"] == true || session["task_complete"].to_s == "true" ||
126
+ !session["last_completed_at"].to_s.empty?
127
+ end
128
+
129
+ def task_failed?(session)
130
+ session["task_failed"] == true || session["task_failed"].to_s == "true" ||
131
+ !session["last_failed_at"].to_s.empty?
132
+ end
133
+
115
134
  def load_live_status(session)
116
135
  uri = URI("http://#{session.fetch('host')}:#{session.fetch('port')}/status")
117
136
  request = Net::HTTP::Get.new(uri)
@@ -11,8 +11,8 @@ module Harnex
11
11
  EXIT_STATUS_GRACE_POLL_INTERVAL = 0.05
12
12
  FINAL_EVENT_GRACE_SECONDS = 5.0
13
13
 
14
- EVENT_PREDICATES = %w[task_complete].freeze
15
- LEGACY_EVENT_TYPES = %w[agent_state exited task_complete].freeze
14
+ EVENT_PREDICATES = %w[task_complete task_failed].freeze
15
+ LEGACY_EVENT_TYPES = %w[agent_state exited task_complete task_failed].freeze
16
16
 
17
17
  def self.usage(program_name = "harnex wait")
18
18
  <<~TEXT
@@ -21,8 +21,13 @@ module Harnex
21
21
  Options:
22
22
  --id ID Session ID to wait for (required)
23
23
  --until STATE Wait until session reaches STATE. Supported:
24
+ done (work fence — task_complete,
25
+ task_failed, or terminal exit,
26
+ whichever comes first)
24
27
  task_complete (events JSONL — fires on
25
- turn/completed; adapter-agnostic)
28
+ successful turn completion)
29
+ task_failed (events JSONL — fires on
30
+ failed turn completion)
26
31
  <other> (agent_state HTTP poll, e.g.
27
32
  "prompt", "busy")
28
33
  Without --until, waits for session exit (default).
@@ -31,12 +36,14 @@ module Harnex
31
36
  -h, --help Show this help
32
37
 
33
38
  Common patterns:
39
+ #{program_name} --id cx-i-42 --until done --timeout 900
34
40
  #{program_name} --id cx-i-42 --until task_complete --timeout 900
35
41
  #{program_name} --id cx-i-42 --until prompt --timeout 120
36
42
  #{program_name} --id cx-i-42
37
43
 
38
44
  Gotchas:
39
- task_complete is an event predicate; prompt/busy are live state polls.
45
+ done is the safest work-level fence for monitors.
46
+ task_complete/task_failed are event predicates; prompt/busy are live state polls.
40
47
  Prompt state alone does not prove work acceptance. Verify artifacts/tests.
41
48
  Exit waits can resolve from terminal summary rows when live registry/
42
49
  exit-status files are already gone.
@@ -65,7 +72,10 @@ module Harnex
65
72
  raise "--id is required for harnex wait" unless @options[:id]
66
73
 
67
74
  if @options[:until_state]
68
- if EVENT_PREDICATES.include?(@options[:until_state])
75
+ case @options[:until_state]
76
+ when "done"
77
+ wait_until_done
78
+ when *EVENT_PREDICATES
69
79
  wait_until_event(@options[:until_state])
70
80
  else
71
81
  wait_until_state
@@ -133,9 +143,9 @@ module Harnex
133
143
  event = parse_event(line)
134
144
  next unless event
135
145
 
136
- task_complete_seen = true if event_type(event) == "task_complete"
146
+ task_complete_seen = true if %w[task_complete task_failed].include?(event_type(event))
137
147
  if matches?(event, predicate, task_complete_seen)
138
- return [emit_event_match(event, start_time), f.pos, task_complete_seen]
148
+ return [emit_event_match(event, start_time, predicate), f.pos, task_complete_seen]
139
149
  end
140
150
  end
141
151
  offset = f.pos
@@ -168,6 +178,10 @@ module Harnex
168
178
  case predicate
169
179
  when "task_complete"
170
180
  type == "task_complete"
181
+ when "task_failed"
182
+ type == "task_failed"
183
+ when "done"
184
+ %w[task_complete task_failed].include?(type)
171
185
  when "prompt"
172
186
  type == "task_complete" ||
173
187
  (task_complete_seen && type == "agent_state" && event["state"] == "prompt")
@@ -176,18 +190,112 @@ module Harnex
176
190
  end
177
191
  end
178
192
 
179
- def emit_event_match(event, start_time)
193
+ def done_event_failed?(event)
194
+ return true if event_type(event) == "task_failed"
195
+
196
+ status = event["status"].to_s
197
+ !status.empty? && !%w[completed success succeeded].include?(status)
198
+ end
199
+
200
+ def emit_event_match(event, start_time, predicate)
180
201
  waited = (Time.now - start_time).round(1)
181
- puts JSON.generate(
202
+ payload = {
182
203
  ok: true,
183
204
  id: @options[:id],
184
205
  event: event_type(event),
185
206
  seq: event["seq"],
186
207
  waited_seconds: waited
187
- )
208
+ }
209
+ if predicate == "done"
210
+ failed = done_event_failed?(event)
211
+ payload.merge!(
212
+ ok: !failed,
213
+ status: failed ? "failed" : "done",
214
+ state: "running",
215
+ process_state: "running",
216
+ terminal: false,
217
+ task_complete: !failed,
218
+ done: !failed,
219
+ work_state: failed ? "failed" : "completed"
220
+ )
221
+ payload[:last_error] = event["message"] || event["error"] if failed
222
+ end
223
+ puts JSON.generate(payload)
224
+ return 1 if predicate == "done" && done_event_failed?(event)
225
+
188
226
  0
189
227
  end
190
228
 
229
+ def wait_until_done
230
+ repo_root = Harnex.resolve_repo_root(@options[:repo_path])
231
+ events_path = Harnex.events_log_path(repo_root, @options[:id])
232
+ exit_path = Harnex.exit_status_path(repo_root, @options[:id])
233
+ registry = Harnex.read_registry(repo_root, @options[:id])
234
+ start_time = Time.now
235
+ deadline = @options[:timeout] ? start_time + @options[:timeout] : nil
236
+
237
+ offset = 0
238
+ task_complete_seen = false
239
+ final_event_deadline = nil
240
+
241
+ status, offset, task_complete_seen = scan_events(events_path, offset, "done", task_complete_seen, start_time)
242
+ return status if status
243
+
244
+ unless registry
245
+ terminal = done_status(repo_root)
246
+ return emit_done_terminal_status(terminal) if terminal
247
+ return emit_done_exit_status(exit_path, @options[:id]) if File.exist?(exit_path)
248
+
249
+ unless File.exist?(events_path)
250
+ warn("harnex wait: no session found with id #{@options[:id].inspect}")
251
+ puts JSON.generate(ok: false, id: @options[:id], state: "unknown", process_state: "unknown", terminal: false,
252
+ task_complete: false, done: false, work_state: "unknown", status: "unknown")
253
+ return 1
254
+ end
255
+ end
256
+
257
+ target_pid = registry && registry["pid"]
258
+
259
+ loop do
260
+ status, offset, task_complete_seen = scan_events(events_path, offset, "done", task_complete_seen, start_time)
261
+ return status if status
262
+
263
+ unless registry
264
+ terminal = done_status(repo_root)
265
+ return emit_done_terminal_status(terminal) if terminal
266
+ return emit_done_exit_status(exit_path, @options[:id]) if File.exist?(exit_path)
267
+ end
268
+
269
+ if deadline && Time.now >= deadline
270
+ waited = (Time.now - start_time).round(1)
271
+ puts JSON.generate(ok: false, id: @options[:id], status: "timeout", waited_seconds: waited,
272
+ done: false, work_state: "running")
273
+ return 124
274
+ end
275
+
276
+ if target_pid && !Harnex.alive_pid?(target_pid)
277
+ final_event_deadline ||= Time.now + FINAL_EVENT_GRACE_SECONDS
278
+ if Time.now >= final_event_deadline
279
+ await_exit_status(exit_path)
280
+ return emit_done_exit_status(exit_path, @options[:id]) if File.exist?(exit_path)
281
+
282
+ terminal = done_status(repo_root)
283
+ return emit_done_terminal_status(terminal) if terminal
284
+
285
+ waited = (Time.now - start_time).round(1)
286
+ puts JSON.generate(ok: false, id: @options[:id], state: "exited", process_state: "exited",
287
+ terminal: true, task_complete: false, done: false, work_state: "unknown",
288
+ waited_seconds: waited)
289
+ return 1
290
+ end
291
+ else
292
+ final_event_deadline = nil
293
+ end
294
+
295
+ sleep EVENT_POLL_INTERVAL
296
+ end
297
+ end
298
+
191
299
  def wait_until_state
192
300
  repo_root = Harnex.resolve_repo_root(@options[:repo_path])
193
301
  target_state = @options[:until_state]
@@ -244,7 +352,8 @@ module Harnex
244
352
  return emit_terminal_status(terminal) if terminal
245
353
 
246
354
  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")
355
+ puts JSON.generate(ok: false, id: @options[:id], state: "unknown", process_state: "unknown",
356
+ terminal: false, task_complete: false, done: false, work_state: "unknown", status: "unknown")
248
357
  return 1
249
358
  end
250
359
 
@@ -259,7 +368,8 @@ module Harnex
259
368
  terminal = terminal_status(repo_root)
260
369
  return emit_terminal_status(terminal) if terminal
261
370
 
262
- puts JSON.generate(ok: false, id: @options[:id], state: "unknown", terminal: false, status: "unknown")
371
+ puts JSON.generate(ok: false, id: @options[:id], state: "unknown", process_state: "unknown",
372
+ terminal: false, task_complete: false, done: false, work_state: "unknown", status: "unknown")
263
373
  return 1
264
374
  end
265
375
 
@@ -328,19 +438,62 @@ module Harnex
328
438
  status
329
439
  end
330
440
 
441
+ def done_status(repo_root)
442
+ status = Harnex::TerminalStatus.resolve(id: @options[:id], repo_root: repo_root)
443
+ return nil unless status
444
+ return nil unless status["done"] || status["terminal"]
445
+
446
+ status
447
+ end
448
+
449
+ def emit_done_exit_status(exit_path, id)
450
+ data = JSON.parse(File.read(exit_path))
451
+ exit_code = data["exit_code"]
452
+ task_complete = data["task_complete"] == true || data["task_complete"].to_s == "true"
453
+ task_failed = data["task_failed"] == true || data["task_failed"].to_s == "true"
454
+ exit_success = !task_failed && (exit_code.nil? || exit_code.to_i == 0)
455
+ state = exit_success ? "completed" : "failed"
456
+ done = task_complete || exit_success
457
+ payload = data.merge(
458
+ "ok" => done,
459
+ "id" => id,
460
+ "state" => state,
461
+ "process_state" => "exited",
462
+ "terminal" => true,
463
+ "task_complete" => task_complete,
464
+ "task_failed" => task_failed,
465
+ "done" => done,
466
+ "work_state" => Harnex.work_state_for(state, task_complete: task_complete)
467
+ )
468
+ success = done
469
+ puts JSON.generate(payload)
470
+ return 0 if success
471
+
472
+ exit_code.is_a?(Integer) && exit_code.positive? ? exit_code : 1
473
+ rescue JSON::ParserError
474
+ puts JSON.generate(ok: false, id: id, state: "failed", process_state: "exited", terminal: true,
475
+ task_complete: false, done: false, work_state: "failed", status: "invalid_exit_status")
476
+ 1
477
+ end
478
+
479
+ def emit_done_terminal_status(status)
480
+ payload = terminal_payload(status)
481
+ payload[:ok] = !!payload[:done]
482
+ payload[:status] = payload[:done] ? "done" : status["state"]
483
+ puts JSON.generate(payload)
484
+
485
+ if payload[:ok]
486
+ 0
487
+ elsif status["exit_code"].is_a?(Integer) && status["exit_code"] > 0
488
+ status["exit_code"]
489
+ else
490
+ 1
491
+ end
492
+ end
493
+
331
494
  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
- }
495
+ payload = terminal_payload(status)
496
+ payload[:ok] = status["state"] == "completed"
344
497
  puts JSON.generate(payload)
345
498
 
346
499
  if payload[:ok]
@@ -352,6 +505,29 @@ module Harnex
352
505
  end
353
506
  end
354
507
 
508
+ def terminal_payload(status)
509
+ task_complete = !!status["task_complete"]
510
+ task_failed = !!status["task_failed"]
511
+ work_state = status["work_state"] || Harnex.work_state_for(status["state"], task_complete: task_complete)
512
+ done = status.key?("done") ? !!status["done"] : work_state == "completed"
513
+ {
514
+ ok: false,
515
+ id: status["id"],
516
+ state: status["state"],
517
+ process_state: status["process_state"] || Harnex.process_state_for(status["state"], terminal: true),
518
+ terminal: status.key?("terminal") ? !!status["terminal"] : true,
519
+ task_complete: task_complete,
520
+ task_failed: task_failed,
521
+ done: done,
522
+ work_state: work_state,
523
+ exit: status["exit"],
524
+ exit_code: status["exit_code"],
525
+ summary_out: status["summary_out"],
526
+ ended_at: status["ended_at"],
527
+ source: status["source"]
528
+ }
529
+ end
530
+
355
531
  def parser
356
532
  @parser ||= OptionParser.new do |opts|
357
533
  opts.banner = "Usage: harnex wait [options]"
data/lib/harnex/core.rb CHANGED
@@ -68,6 +68,36 @@ module Harnex
68
68
  VERSION
69
69
  end
70
70
 
71
+ def work_state_for(session_state, task_complete: false)
72
+ return "completed" if task_complete
73
+
74
+ case session_state.to_s
75
+ when "completed"
76
+ "completed"
77
+ when "failed"
78
+ "failed"
79
+ when "running"
80
+ "running"
81
+ else
82
+ "unknown"
83
+ end
84
+ end
85
+
86
+ def work_done_for(session_state, task_complete: false)
87
+ work_state_for(session_state, task_complete: task_complete) == "completed"
88
+ end
89
+
90
+ def process_state_for(session_state, terminal: false)
91
+ return "running" unless terminal
92
+
93
+ case session_state.to_s
94
+ when "completed", "failed"
95
+ "exited"
96
+ else
97
+ "unknown"
98
+ end
99
+ end
100
+
71
101
  def host_info
72
102
  {
73
103
  host: Socket.gethostname,
@@ -85,6 +85,7 @@ module Harnex
85
85
  end
86
86
 
87
87
  def classify(session)
88
+ return ["failed", "task_failed"] if session.respond_to?(:task_failed?) && session.task_failed?
88
89
  return ["completed", "task_complete"] if session.task_complete?
89
90
  return ["timeout", "timeout"] if session.exit_code == 124
90
91
  return ["killed", "process_kill"] if session.term_signal
@@ -16,6 +16,7 @@ module Harnex
16
16
  agent_session_id cost_usd
17
17
  ].freeze
18
18
  BUDGET_META_FIELDS = %w[read_budget_lines output_ceiling_lines].freeze
19
+ SUCCESSFUL_TURN_STATUSES = %w[completed success succeeded].freeze
19
20
  class EventCounters
20
21
  def initialize
21
22
  @counts = {
@@ -103,6 +104,8 @@ module Harnex
103
104
  @session_finalized = false
104
105
  @turn_started_seen = false
105
106
  @last_completed_at = nil
107
+ @last_failed_at = nil
108
+ @last_failed_status = nil
106
109
  @pi_streamed_text_by_message = {}
107
110
  @auto_stop = !!auto_stop
108
111
  @auto_stop_fired = false
@@ -221,9 +224,19 @@ module Harnex
221
224
  end
222
225
 
223
226
  payload[:input_state] = adapter.input_state(screen_snapshot) if include_input_state
227
+ task_complete = task_complete?
228
+ task_failed = task_failed?
229
+ work_state = task_failed ? "failed" : Harnex.work_state_for("running", task_complete: task_complete)
224
230
  payload[:agent_state] = @state_machine.to_s
231
+ payload[:process_state] = "running"
225
232
  payload[:inbox] = @inbox.stats
226
233
  payload[:last_completed_at] = @last_completed_at&.iso8601
234
+ payload[:last_failed_at] = @last_failed_at&.iso8601
235
+ payload[:task_complete] = task_complete
236
+ payload[:task_failed] = task_failed
237
+ payload[:done] = Harnex.work_done_for("running", task_complete: task_complete)
238
+ payload[:work_state] = work_state
239
+ payload[:last_error] = @last_error
227
240
  payload[:model] = summary_model
228
241
  payload[:effort] = meta_hash["effort"]
229
242
  payload[:auto_disconnects] = @event_counters.snapshot[:disconnections]
@@ -231,7 +244,11 @@ module Harnex
231
244
  end
232
245
 
233
246
  def task_complete?
234
- !!@last_completed_at
247
+ !!@last_completed_at && !task_failed?
248
+ end
249
+
250
+ def task_failed?
251
+ !!@last_failed_at
235
252
  end
236
253
 
237
254
  def git_start
@@ -252,7 +269,7 @@ module Harnex
252
269
  inject_sequence([{ text: text, newline: newline }])
253
270
  end
254
271
 
255
- def inject_stop(turn_id: nil)
272
+ def inject_stop(turn_id: nil, interrupt: true)
256
273
  unless structured_transport?
257
274
  raise "session is not running" unless pid && Harnex.alive_pid?(pid)
258
275
  end
@@ -269,15 +286,21 @@ module Harnex
269
286
  end
270
287
  end
271
288
  end
272
- @inject_mutex.synchronize do
273
- begin
274
- adapter.interrupt(turn_id: turn_id)
275
- rescue StandardError
276
- nil
289
+ if interrupt
290
+ @inject_mutex.synchronize do
291
+ begin
292
+ adapter.interrupt(turn_id: turn_id)
293
+ rescue StandardError
294
+ nil
295
+ end
296
+ @state_machine.force_busy!
277
297
  end
278
- @state_machine.force_busy!
298
+ return { ok: true, signal: "interrupt_sent" }
279
299
  end
280
- return { ok: true, signal: "interrupt_sent" }
300
+
301
+ @state_machine.force_busy!
302
+ signal_rpc_done! unless @pid
303
+ return { ok: true, signal: "terminate_sent" }
281
304
  end
282
305
 
283
306
  @inject_mutex.synchronize do
@@ -331,7 +354,12 @@ module Harnex
331
354
 
332
355
  turn_id = nil
333
356
  @inject_mutex.synchronize do
334
- turn_id = adapter.dispatch(**dispatch)
357
+ begin
358
+ turn_id = adapter.dispatch(**dispatch)
359
+ rescue StandardError => e
360
+ mark_task_failed(status: "dispatch_error", error: e.message)
361
+ raise
362
+ end
335
363
  @state_machine.force_busy!
336
364
  @injected_count += 1
337
365
  @last_injected_at = Time.now
@@ -455,14 +483,25 @@ module Harnex
455
483
  @state_machine.force_busy!
456
484
  emit_event("turn_started", turnId: params.dig("turn", "id"))
457
485
  when "turn/completed"
458
- @last_completed_at = Time.now
459
486
  @state_machine.force_prompt!
460
487
  turn = params["turn"] || {}
461
- payload = { turnId: turn["id"] }
462
- payload[:status] = turn["status"] if turn["status"]
463
- payload[:tokenUsage] = params["tokenUsage"] if params["tokenUsage"]
464
- emit_event("task_complete", **payload)
465
- schedule_auto_stop("task_complete", turn_id: payload[:turnId])
488
+ status = turn["status"]
489
+ turn_id = turn["id"] || params["turnId"]
490
+ payload = { turnId: turn_id }
491
+ payload[:status] = status if status
492
+ payload[:tokenUsage] = params["tokenUsage"] if params["tokenUsage"].is_a?(Hash)
493
+ if successful_turn_status?(status)
494
+ @last_completed_at = Time.now
495
+ emit_event("task_complete", **payload)
496
+ else
497
+ mark_task_failed(
498
+ turn_id: turn_id,
499
+ status: status,
500
+ error: extract_turn_error_message(turn),
501
+ codex_error_info: extract_turn_error_info(turn)
502
+ )
503
+ end
504
+ schedule_auto_stop("turn_completed", interrupt: false)
466
505
  when "item/completed"
467
506
  emit_event("item_completed", item: params["item"])
468
507
  @event_counters.record_item(params["item"])
@@ -482,15 +521,70 @@ module Harnex
482
521
  when "account/rateLimits/updated"
483
522
  @rate_limits = params
484
523
  when "error"
485
- @last_error = params["message"].to_s unless params["message"].to_s.empty?
524
+ message = extract_error_notification_message(params)
525
+ @last_error = message unless message.to_s.empty?
486
526
  @state_machine.force_busy!
487
- emit_event("disconnected", source: "error_notification", message: params["message"])
488
- signal_rpc_done!
527
+ emit_event(
528
+ "error",
529
+ source: "error_notification",
530
+ message: message,
531
+ codex_error_info: extract_error_notification_info(params),
532
+ will_retry: params["willRetry"],
533
+ threadId: params["threadId"],
534
+ turnId: params["turnId"]
535
+ )
536
+ signal_rpc_done! if params["turnId"].to_s.empty?
489
537
  end
490
538
  rescue StandardError => e
491
539
  warn("harnex: rpc notification handler error: #{e.message}")
492
540
  end
493
541
 
542
+ def successful_turn_status?(status)
543
+ text = status.to_s
544
+ return true if text.empty?
545
+
546
+ SUCCESSFUL_TURN_STATUSES.include?(text)
547
+ end
548
+
549
+ def mark_task_failed(turn_id: nil, status: nil, error: nil, codex_error_info: nil)
550
+ @last_failed_at = Time.now
551
+ @last_failed_status = status.to_s.empty? ? "failed" : status.to_s
552
+ @last_error = error.to_s unless error.to_s.empty?
553
+
554
+ payload = { status: @last_failed_status }
555
+ payload[:turnId] = turn_id if turn_id
556
+ payload[:message] = error unless error.to_s.empty?
557
+ payload[:codex_error_info] = codex_error_info if codex_error_info
558
+ emit_event("task_failed", **payload)
559
+ end
560
+
561
+ def extract_error_notification_message(params)
562
+ error = params["error"]
563
+ if error.is_a?(Hash)
564
+ error["message"] || error.dig("error", "message") || params["message"]
565
+ else
566
+ params["message"]
567
+ end
568
+ end
569
+
570
+ def extract_error_notification_info(params)
571
+ error = params["error"]
572
+ error.is_a?(Hash) ? error["codexErrorInfo"] : nil
573
+ end
574
+
575
+ def extract_turn_error_message(turn)
576
+ error = turn["error"]
577
+ return error["message"] if error.is_a?(Hash)
578
+ return error if error.is_a?(String)
579
+
580
+ nil
581
+ end
582
+
583
+ def extract_turn_error_info(turn)
584
+ error = turn["error"]
585
+ error.is_a?(Hash) ? error["codexErrorInfo"] : nil
586
+ end
587
+
494
588
  def handle_jsonl_notification(message)
495
589
  event_type = message["type"].to_s
496
590
 
@@ -504,7 +598,7 @@ module Harnex
504
598
  @state_machine.force_prompt!
505
599
  emit_event("task_complete")
506
600
  adapter.request_session_stats_async if adapter.respond_to?(:request_session_stats_async)
507
- schedule_auto_stop("task_complete")
601
+ schedule_auto_stop("task_complete", interrupt: false)
508
602
  when "message_start"
509
603
  @pi_streamed_text_by_message[pi_message_key(message["message"])] = false
510
604
  when "message_update"
@@ -573,12 +667,21 @@ module Harnex
573
667
 
574
668
  def handle_rpc_disconnect(error)
575
669
  msg = error.is_a?(Hash) ? error["message"] : error&.message
670
+ if normal_auto_stop_disconnect?(msg)
671
+ signal_rpc_done!
672
+ return
673
+ end
674
+
576
675
  @last_error = msg.to_s unless msg.to_s.empty?
577
676
  @state_machine.force_busy!
578
677
  emit_event("disconnected", source: "transport", message: msg) rescue nil
579
678
  signal_rpc_done!
580
679
  end
581
680
 
681
+ def normal_auto_stop_disconnect?(message)
682
+ message.to_s.empty? && @auto_stop_fired && (task_complete? || task_failed?)
683
+ end
684
+
582
685
  def dispatch_initial_prompt
583
686
  return unless adapter.respond_to?(:initial_prompt)
584
687
 
@@ -733,13 +836,22 @@ module Harnex
733
836
  return unless defined?(@exit_code) && !@exit_code.nil?
734
837
 
735
838
  exit_path = Harnex.exit_status_path(repo_root, id)
839
+ task_complete = task_complete?
840
+ task_failed = task_failed?
841
+ state = task_failed || @exit_code.to_i != 0 ? "failed" : "completed"
736
842
  payload = {
737
- ok: true,
843
+ ok: !task_failed && state == "completed",
738
844
  id: id,
739
845
  cli: adapter.key,
740
846
  session_id: session_id,
741
847
  repo_root: repo_root,
742
848
  exit_code: @exit_code,
849
+ state: state,
850
+ process_state: "exited",
851
+ task_complete: task_complete,
852
+ task_failed: task_failed,
853
+ done: Harnex.work_done_for(state, task_complete: task_complete),
854
+ work_state: Harnex.work_state_for(state, task_complete: task_complete),
743
855
  started_at: @started_at.iso8601,
744
856
  exited_at: Time.now.iso8601,
745
857
  injected_count: @injected_count
@@ -943,7 +1055,7 @@ module Harnex
943
1055
  schedule_auto_stop("prompt_after_busy") if seen_busy && new_state == :prompt
944
1056
  end
945
1057
 
946
- def schedule_auto_stop(reason, turn_id: nil)
1058
+ def schedule_auto_stop(reason, turn_id: nil, interrupt: true)
947
1059
  return unless @auto_stop
948
1060
 
949
1061
  should_fire = @auto_stop_mutex.synchronize do
@@ -958,7 +1070,7 @@ module Harnex
958
1070
 
959
1071
  thread = Thread.new do
960
1072
  begin
961
- inject_stop(turn_id: turn_id)
1073
+ inject_stop(turn_id: turn_id, interrupt: interrupt)
962
1074
  rescue StandardError => e
963
1075
  warn("harnex: auto-stop failed after #{reason}: #{e.message}")
964
1076
  end
@@ -1004,17 +1116,26 @@ module Harnex
1004
1116
 
1005
1117
  def normalize_auto_stop_exit_code!
1006
1118
  return unless @auto_stop
1007
- return unless @last_completed_at
1008
1119
  return unless @auto_stop_fired
1009
1120
 
1121
+ if task_failed?
1122
+ @exit_code = 1 if @exit_code.nil? || @exit_code.zero? || @term_signal
1123
+ @term_signal = nil if @exit_code == 1
1124
+ return
1125
+ end
1126
+
1127
+ return unless task_complete?
1128
+
1010
1129
  @exit_code = 0
1011
1130
  @term_signal = nil
1012
1131
  end
1013
1132
 
1014
1133
  def classify_exit
1015
1134
  return "timeout" if @exit_code == 124
1016
- return "success" if @exit_code == 0 && session_summary_present?
1017
1135
  return "boot_failure" if boot_failure_exit?
1136
+ return "failure" if task_failed?
1137
+ return "success" if @exit_code == 0 && task_complete?
1138
+ return "success" if @exit_code == 0 && session_summary_present?
1018
1139
  return "failure" unless @exit_code == 0
1019
1140
 
1020
1141
  "disconnected"
@@ -1098,7 +1219,7 @@ module Harnex
1098
1219
  files_changed: @git_end[:files_changed],
1099
1220
  commits: @git_end[:commits],
1100
1221
  exit: @exit_reason,
1101
- task_complete: !!@last_completed_at,
1222
+ task_complete: task_complete?,
1102
1223
  signal: @term_signal,
1103
1224
  exit_code: @exit_code,
1104
1225
  last_error: @last_error,
@@ -1244,6 +1365,7 @@ module Harnex
1244
1365
  @event_counters.record(type)
1245
1366
  @events_mutex.synchronize do
1246
1367
  return unless @events_log
1368
+ return if @events_log.closed?
1247
1369
 
1248
1370
  @events_log_seq += 1
1249
1371
  event = {
@@ -42,8 +42,12 @@ module Harnex
42
42
  "id" => Harnex.normalize_id(id),
43
43
  "repo_root" => File.expand_path(repo_root.to_s.empty? ? Dir.pwd : repo_root),
44
44
  "state" => "unknown",
45
+ "process_state" => "unknown",
45
46
  "terminal" => false,
46
47
  "task_complete" => false,
48
+ "task_failed" => false,
49
+ "done" => false,
50
+ "work_state" => "unknown",
47
51
  "exit" => nil,
48
52
  "exit_code" => nil,
49
53
  "summary_out" => nil,
@@ -127,12 +131,19 @@ module Harnex
127
131
  meta = record["meta"] || {}
128
132
  actual = record["actual"] || {}
129
133
  state = classify_summary_state(actual)
134
+ task_complete = !!actual["task_complete"]
135
+ task_failed = state == "failed" && !task_complete
136
+ terminal = state != "unknown"
130
137
  {
131
138
  "id" => meta["id"].to_s,
132
139
  "repo_root" => meta["repo"] || fallback_repo_root,
133
140
  "state" => state,
134
- "terminal" => state != "unknown",
135
- "task_complete" => !!actual["task_complete"],
141
+ "process_state" => Harnex.process_state_for(state, terminal: terminal),
142
+ "terminal" => terminal,
143
+ "task_complete" => task_complete,
144
+ "task_failed" => task_failed,
145
+ "done" => Harnex.work_done_for(state, task_complete: task_complete),
146
+ "work_state" => Harnex.work_state_for(state, task_complete: task_complete),
136
147
  "exit" => blank_to_nil(actual["exit"]),
137
148
  "exit_code" => actual["exit_code"],
138
149
  "summary_out" => summary_path,
@@ -164,12 +175,19 @@ module Harnex
164
175
  else
165
176
  "unknown"
166
177
  end
178
+ task_complete = record["terminal_event"].to_s == "task_complete"
179
+ task_failed = record["terminal_event"].to_s == "task_failed" || (state == "failed" && !task_complete)
180
+ terminal = state != "unknown"
167
181
  {
168
182
  "id" => record["id"].to_s,
169
183
  "repo_root" => fallback_repo_root,
170
184
  "state" => state,
171
- "terminal" => state != "unknown",
172
- "task_complete" => record["terminal_event"].to_s == "task_complete",
185
+ "process_state" => Harnex.process_state_for(state, terminal: terminal),
186
+ "terminal" => terminal,
187
+ "task_complete" => task_complete,
188
+ "task_failed" => task_failed,
189
+ "done" => Harnex.work_done_for(state, task_complete: task_complete),
190
+ "work_state" => Harnex.work_state_for(state, task_complete: task_complete),
173
191
  "exit" => history_exit(status),
174
192
  "exit_code" => nil,
175
193
  "summary_out" => blank_to_nil(record["summary_out_path"]),
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.7.5"
3
- RELEASE_DATE = "2026-05-26"
2
+ VERSION = "0.7.7"
3
+ RELEASE_DATE = "2026-06-12"
4
4
  end
@@ -19,7 +19,8 @@ harnex run codex --id cx-23 --tmux
19
19
  If the plan file is self-contained, reference it directly:
20
20
 
21
21
  ```bash
22
- harnex send --id cx-23 --message "Implement koder/plans/plan_23.md. Run tests when done." --wait-for-idle --timeout 1200
22
+ harnex send --id cx-23 --message "Implement koder/plans/plan_23.md. Run tests when done."
23
+ harnex wait --id cx-23 --until done --timeout 1200
23
24
  ```
24
25
 
25
26
  For tasks that need structured output, tell the worker to write a
@@ -32,12 +33,13 @@ Run the full test suite when done.
32
33
  Write a short summary to /tmp/impl-23.md.
33
34
  EOF
34
35
 
35
- harnex send --id cx-23 --message "Read and execute /tmp/task-cx-23.md" --wait-for-idle --timeout 1200
36
+ harnex send --id cx-23 --message "Read and execute /tmp/task-cx-23.md"
37
+ harnex wait --id cx-23 --until done --timeout 1200
36
38
  ```
37
39
 
38
40
  ### 3. Watch until done
39
41
 
40
- Use `--wait-for-idle` as the fence, then read the worker's screen:
42
+ Use `harnex wait --until done` as the work-level fence, then read the worker's screen:
41
43
 
42
44
  ```bash
43
45
  harnex pane --id cx-23 --lines 25
data/recipes/03_buddy.md CHANGED
@@ -45,8 +45,9 @@ Your job:
45
45
  2. If the worker appears stuck at a prompt for more than 10 minutes
46
46
  with no progress, nudge it:
47
47
  - `harnex send --id worker-42 --message "You appear to have stalled. Continue with your current task."`
48
- 3. If the worker has exited (status shows no session), report back:
49
- - `tmux send-keys -t "$HARNEX_SPAWNER_PANE" "worker-42 has exited. Check results." Enter`
48
+ 3. If `harnex status --id worker-42 --json` reports `done=true` or
49
+ `work_state=failed`, report back:
50
+ - `tmux send-keys -t "$HARNEX_SPAWNER_PANE" "worker-42 reached a work-level terminal state. Check results." Enter`
50
51
  4. Keep watching until the worker finishes or is stopped.
51
52
 
52
53
  Do not interfere with work in progress. Only nudge when clearly stalled.
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.5
4
+ version: 0.7.7
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-26 00:00:00.000000000 Z
11
+ date: 2026-06-12 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.