harnex 0.7.6 → 0.7.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/README.md +30 -14
- data/guides/01_dispatch.md +13 -7
- data/guides/03_buddy.md +8 -1
- data/guides/04_monitoring.md +36 -38
- data/lib/harnex/adapters/codex_appserver.rb +1 -1
- data/lib/harnex/cli.rb +7 -0
- data/lib/harnex/codex/app_server/client.rb +1 -2
- data/lib/harnex/commands/status.rb +10 -2
- data/lib/harnex/commands/wait.rb +36 -13
- data/lib/harnex/commands/watch.rb +204 -0
- data/lib/harnex/dispatch_history.rb +1 -0
- data/lib/harnex/runtime/session.rb +140 -30
- data/lib/harnex/terminal_status.rb +5 -0
- data/lib/harnex/version.rb +2 -2
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 64a1ddf83ef070cc2b418c1a70ae3e1fc2c8b5fd671e8fa3e5a30536184728ca
|
|
4
|
+
data.tar.gz: 6a076faed04db3eddbaf4bf2fefbee95426cd8ee8008499e3474fbfa1b5c62ed
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3508afcbddc0e9afaf17372ca98cb3146d5edc128fb54c6217f8b80be7f81baae0ab7f50feba4b513a9ba46165323f8fb95ddd243df5bd19921b198beb89bbc6
|
|
7
|
+
data.tar.gz: 25419186825ac14d2f6cd844497d6dc356e88785f23784f30492284b80c384781ab834011f394d27c93078821fc394fed3ac97b8bff818f7b60fc280be83a560
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.7.8] - 2026-06-13 | 08:45 PM | IST
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `harnex watch --id <id> --until done` now provides a native work-terminal
|
|
10
|
+
watcher for existing visible/detached sessions. It exits `0` for successful
|
|
11
|
+
work, non-zero for `task_failed` / failed terminal telemetry, `124` for
|
|
12
|
+
wall-clock timeout, and can write optional done/fail marker files for legacy
|
|
13
|
+
queue integrations.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Monitoring docs now recommend native `harnex watch` for unattended
|
|
18
|
+
single-dispatch monitoring and reserve `harnex run --watch` for foreground
|
|
19
|
+
launch-and-stall-recovery.
|
|
20
|
+
|
|
21
|
+
## [0.7.7] - 2026-06-12 | 10:48 AM | IST
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Codex app-server failed turns now emit `task_failed` instead of being
|
|
26
|
+
misreported as successful `task_complete` work. `harnex wait --until done`
|
|
27
|
+
returns non-zero for failed-turn events, dispatch history records
|
|
28
|
+
`terminal_event=task_failed`, and auto-stop terminates structured sessions
|
|
29
|
+
without sending a stale `turn/interrupt` after the turn is already complete.
|
|
30
|
+
- Codex app-server nested error notifications now preserve the real Codex error
|
|
31
|
+
message (for example missing provider credentials) without counting them as
|
|
32
|
+
transport disconnects.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- Refreshed the pinned Codex app-server JSON Schema fixtures to
|
|
37
|
+
`codex-cli 0.139.0` and taught the test schema validator `minLength`.
|
|
38
|
+
|
|
5
39
|
## [0.7.6] - 2026-06-09 | 12:59 AM | IST
|
|
6
40
|
|
|
7
41
|
### Added
|
data/README.md
CHANGED
|
@@ -68,8 +68,9 @@ job, watch it work, stop it when done.
|
|
|
68
68
|
`harnex events` streams structured JSONL lifecycle events.
|
|
69
69
|
|
|
70
70
|
- **You don't want to babysit.** Use `--context --auto-stop` for
|
|
71
|
-
one-shot work,
|
|
72
|
-
`--wait-for-idle`
|
|
71
|
+
one-shot work, `harnex watch` for existing visible/detached dispatches,
|
|
72
|
+
`run --watch` for bounded foreground stall recovery, or `--wait-for-idle`
|
|
73
|
+
as a send fence.
|
|
73
74
|
|
|
74
75
|
- **You want local-only orchestration.** Everything runs on your
|
|
75
76
|
machine. No cloud services, no API keys beyond what the agents need.
|
|
@@ -150,18 +151,27 @@ harnex agents-guide monitoring
|
|
|
150
151
|
|
|
151
152
|
## Built-in dispatch monitoring
|
|
152
153
|
|
|
153
|
-
For unattended dispatches, use
|
|
154
|
+
For unattended visible/background dispatches, use `harnex watch` instead of
|
|
155
|
+
writing a bash poll loop around `harnex wait`:
|
|
154
156
|
|
|
155
157
|
```bash
|
|
156
|
-
harnex run pi --id pi-impl-42 --
|
|
158
|
+
harnex run pi --id pi-impl-42 --tmux pi-impl-42 \
|
|
157
159
|
--context "Implement koder/plans/42_plan.md. Run tests and commit when done."
|
|
160
|
+
harnex watch --id pi-impl-42 --until done --max-wait 90m \
|
|
161
|
+
--done-marker /tmp/pi-impl-42-done.json \
|
|
162
|
+
--fail-marker /tmp/pi-impl-42-failed.json
|
|
158
163
|
```
|
|
159
164
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
+
`harnex watch --until done` is the safe work-terminal watcher for existing
|
|
166
|
+
`--tmux` or detached sessions. It exits `0` for `task_complete`/done, non-zero
|
|
167
|
+
for `task_failed` or failed terminal summaries, and `124` for `--max-wait`
|
|
168
|
+
timeouts. It does not keep pane/status polling after a terminal failure signal.
|
|
169
|
+
|
|
170
|
+
`harnex run --watch` is a separate foreground babysitter that checks session
|
|
171
|
+
activity every 60s, force-resumes on stall up to a cap, and exits when the
|
|
172
|
+
target session exits or the resume cap is reached. It is foreground-only; use
|
|
173
|
+
`--tmux` or `--detach` for visible/background sessions, and `run --watch` when
|
|
174
|
+
the current command should launch and monitor one worker.
|
|
165
175
|
|
|
166
176
|
Presets map to stall policy defaults:
|
|
167
177
|
|
|
@@ -180,17 +190,22 @@ and stops the session after the first task completion or PTY prompt return.
|
|
|
180
190
|
|
|
181
191
|
## Completion and waiting
|
|
182
192
|
|
|
183
|
-
Choose the wait predicate that matches how you launched the worker:
|
|
193
|
+
Choose the wait/watch predicate that matches how you launched the worker:
|
|
184
194
|
|
|
185
|
-
- `harnex
|
|
186
|
-
|
|
187
|
-
|
|
195
|
+
- `harnex watch --id ID --until done --max-wait DUR` is the safest unattended
|
|
196
|
+
monitor for an existing visible or detached dispatch. It wraps the work-level
|
|
197
|
+
fence, preserves the timeout/failure distinction, and can write done/fail
|
|
198
|
+
marker files for legacy queue integrations.
|
|
199
|
+
- `harnex wait --id ID --until done --timeout SECS` is the primitive work fence.
|
|
200
|
+
It returns when Harnex sees `task_complete`, `task_failed`, or a terminal
|
|
201
|
+
exit, whichever comes first; failed work returns non-zero.
|
|
188
202
|
- `harnex wait --id ID` waits for the wrapped process to exit. This is right
|
|
189
203
|
for already-exited sessions and terminal-summary recovery, but interactive
|
|
190
204
|
agents can stay open after finishing a turn.
|
|
191
205
|
- For structured Pi RPC and Codex app-server sessions, use
|
|
192
206
|
`harnex wait --id ID --until task_complete --timeout SECS` when you need the
|
|
193
|
-
exact turn
|
|
207
|
+
exact successful-turn event instead of terminal-exit fallback. Use
|
|
208
|
+
`--until task_failed` to wait specifically for a failed structured turn.
|
|
194
209
|
- `harnex send --wait-for-idle` is an atomic send fence for PTY-style
|
|
195
210
|
interactions. It proves the turn returned to an idle/prompt state, not that
|
|
196
211
|
your acceptance criteria passed.
|
|
@@ -312,6 +327,7 @@ See [recipes/03_buddy.md](recipes/03_buddy.md) for the full pattern.
|
|
|
312
327
|
| `harnex send --id <id>` | Send a message (queues if busy, `--wait-for-idle` to block until the turn returns idle) |
|
|
313
328
|
| `harnex stop --id <id>` | Send the agent's native exit sequence |
|
|
314
329
|
| `harnex status` | List running sessions; with `--id ID --json`, terminal summaries can classify completed/failed sessions after exit |
|
|
330
|
+
| `harnex watch --id <id>` | Safely monitor existing visible/detached work until `done`, `task_failed`, or timeout; optional done/fail markers |
|
|
315
331
|
| `harnex pane --id <id>` | Capture a tmux-backed session's screen (`--follow` for live) |
|
|
316
332
|
| `harnex logs --id <id>` | Read session transcript (`--follow` to tail) |
|
|
317
333
|
| `harnex events --id <id>` | Stream structured session events (`--snapshot` for non-blocking dump) |
|
data/guides/01_dispatch.md
CHANGED
|
@@ -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
|
|
76
|
+
for i in 1 2 3; do harnex watch --id w-$i --until done --max-wait 90m & done
|
|
77
77
|
wait
|
|
78
78
|
```
|
|
79
79
|
|
|
@@ -125,18 +125,24 @@ 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
|
-
|
|
|
129
|
-
|
|
|
128
|
+
| Existing-session work monitor | `harnex watch --id pi-i-NN --until done --max-wait 90m` |
|
|
129
|
+
| Primitive work completion/failure fence | `harnex wait --id pi-i-NN --until done` |
|
|
130
|
+
| Native successful-turn completion | `harnex wait --id pi-i-NN --until task_complete` |
|
|
130
131
|
|
|
131
|
-
For
|
|
132
|
+
For visible `--tmux` or detached dispatches, prefer `harnex watch --id`: it
|
|
133
|
+
returns `0` on done, non-zero on `task_failed`/failed terminal summaries, and
|
|
134
|
+
`124` on `--max-wait` timeout. Use `--done-marker` / `--fail-marker` only as
|
|
135
|
+
compatibility outputs for older queue scripts.
|
|
136
|
+
|
|
137
|
+
For foreground launch-and-stall-recovery, use `harnex run --watch`:
|
|
132
138
|
|
|
133
139
|
```bash
|
|
134
140
|
harnex run pi --id pi-i-NN --watch --preset impl --context "Read /tmp/task-impl-NN.md"
|
|
135
141
|
```
|
|
136
142
|
|
|
137
|
-
|
|
138
|
-
and monitor the worker. Use pane/log/event polling or a buddy when you
|
|
139
|
-
interpretation
|
|
143
|
+
`run --watch` is foreground-blocking. Use it when a single process should
|
|
144
|
+
launch and monitor the worker. Use pane/log/event polling or a buddy when you
|
|
145
|
+
need interpretation across multiple sessions.
|
|
140
146
|
|
|
141
147
|
## Verify And Stop
|
|
142
148
|
|
data/guides/03_buddy.md
CHANGED
|
@@ -4,7 +4,14 @@ A buddy is a second harnex session that watches one or more workers and nudges
|
|
|
4
4
|
them if they stall. Use a buddy when the work is long-running, unattended, or
|
|
5
5
|
needs interpretation that simple stall policy cannot provide.
|
|
6
6
|
|
|
7
|
-
For simple
|
|
7
|
+
For simple work-terminal monitoring of an existing visible/detached session,
|
|
8
|
+
prefer the native watcher:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
harnex watch --id pi-i-NN --until done --max-wait 90m
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
For foreground launch-and-inactivity recovery, use built-in run watch mode:
|
|
8
15
|
|
|
9
16
|
```bash
|
|
10
17
|
harnex run pi --id pi-i-NN --watch --preset impl --context "Read /tmp/task-impl-NN.md"
|
data/guides/04_monitoring.md
CHANGED
|
@@ -17,11 +17,15 @@ 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 unattended monitors
|
|
21
|
-
work-level `task_complete`
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
For unattended monitors on existing visible/detached sessions, prefer
|
|
21
|
+
`harnex watch --until done`: it returns on the work-level `task_complete` or
|
|
22
|
+
`task_failed` signal, or terminal exit, whichever comes first. Successful work
|
|
23
|
+
exits `0`, failed work exits non-zero, and wall-clock caps exit `124`. For
|
|
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),
|
|
26
|
+
`harnex wait --until task_complete` remains the exact successful-turn fence.
|
|
27
|
+
None of these know your acceptance criteria; verify the expected artifact or
|
|
28
|
+
tests afterward.
|
|
25
29
|
|
|
26
30
|
## Completion Test
|
|
27
31
|
|
|
@@ -29,14 +33,19 @@ For unattended work, first gate on harnex work completion, then verify the task
|
|
|
29
33
|
artifact and repo health:
|
|
30
34
|
|
|
31
35
|
```bash
|
|
32
|
-
harnex
|
|
36
|
+
harnex watch --id pi-i-NN --until done --max-wait 90m \
|
|
37
|
+
--done-marker /tmp/pi-i-NN-done.json \
|
|
38
|
+
--fail-marker /tmp/pi-i-NN-failed.json &&
|
|
33
39
|
test -f path/to/expected-artifact &&
|
|
34
40
|
test -z "$(git status --short)"
|
|
35
41
|
```
|
|
36
42
|
|
|
37
|
-
`harnex
|
|
38
|
-
|
|
39
|
-
|
|
43
|
+
`harnex watch --until done` wraps the `harnex wait --until done` work fence:
|
|
44
|
+
it succeeds from `task_complete` or durable successful terminal telemetry
|
|
45
|
+
(`--summary-out` / `.harnex/dispatch.jsonl` / exit status), returns non-zero for
|
|
46
|
+
`task_failed` / failed terminal telemetry, returns `124` for `--max-wait`, and
|
|
47
|
+
only writes done/fail markers as compatibility outputs after harnex has seen a
|
|
48
|
+
terminal work signal.
|
|
40
49
|
|
|
41
50
|
Adjust the artifact path to the task. The point is to avoid declaring done while
|
|
42
51
|
a worker is between edits or between commits.
|
|
@@ -74,42 +83,29 @@ harnex events --id pi-i-NN
|
|
|
74
83
|
For task completion:
|
|
75
84
|
|
|
76
85
|
```bash
|
|
86
|
+
harnex watch --id pi-i-NN --until done --max-wait 15m
|
|
87
|
+
# Primitive equivalent when a script wants raw wait semantics:
|
|
77
88
|
harnex wait --id pi-i-NN --until done --timeout 900
|
|
78
|
-
# Or, when you specifically need the structured turn event:
|
|
89
|
+
# Or, when you specifically need the structured successful-turn event:
|
|
79
90
|
harnex wait --id pi-i-NN --until task_complete --timeout 900
|
|
80
91
|
```
|
|
81
92
|
|
|
82
93
|
## Background Sweeper
|
|
83
94
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
95
|
+
Avoid custom shell loops that repeatedly call `harnex wait`/`harnex status` and
|
|
96
|
+
then accidentally swallow a failed work result. For a single unattended
|
|
97
|
+
visible/detached dispatch, use the native watcher with a hard wall-clock cap:
|
|
87
98
|
|
|
88
99
|
```bash
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
while :; do
|
|
93
|
-
if test "$(($(date +%s) - start))" -gt "$max_wait"; then
|
|
94
|
-
echo "wall-clock cap hit for pi-i-NN" >&2
|
|
95
|
-
exit 2
|
|
96
|
-
fi
|
|
97
|
-
|
|
98
|
-
row=$(harnex status --id pi-i-NN --json | ruby -rjson -e 'rows=JSON.parse(STDIN.read); print JSON.generate(rows.first || {})')
|
|
99
|
-
done=$(printf '%s' "$row" | ruby -rjson -e 'print(JSON.parse(STDIN.read)["done"] ? "true" : "false")')
|
|
100
|
-
work_state=$(printf '%s' "$row" | ruby -rjson -e 'print(JSON.parse(STDIN.read)["work_state"].to_s)')
|
|
101
|
-
state=$(printf '%s' "$row" | ruby -rjson -e 'print(JSON.parse(STDIN.read)["state"].to_s)')
|
|
102
|
-
|
|
103
|
-
case "$done:$work_state" in
|
|
104
|
-
true:*) echo "pi-i-NN work completed"; break ;;
|
|
105
|
-
false:failed) echo "pi-i-NN work failed; process state: $state" >&2; exit 1 ;;
|
|
106
|
-
*) harnex pane --id pi-i-NN --lines 20 ;;
|
|
107
|
-
esac
|
|
108
|
-
|
|
109
|
-
sleep 60
|
|
110
|
-
done
|
|
100
|
+
harnex watch --id pi-i-NN --until done --max-wait 90m \
|
|
101
|
+
--done-marker /tmp/pi-i-NN-done.json \
|
|
102
|
+
--fail-marker /tmp/pi-i-NN-failed.json
|
|
111
103
|
```
|
|
112
104
|
|
|
105
|
+
If that exits `124`, inspect the pane/logs/events and decide whether to nudge,
|
|
106
|
+
stop, or continue. If it exits any other non-zero code, treat the work as
|
|
107
|
+
failed; do not continue polling the same task as though it were still running.
|
|
108
|
+
|
|
113
109
|
Recommended caps:
|
|
114
110
|
|
|
115
111
|
| Work type | Cap |
|
|
@@ -118,17 +114,18 @@ Recommended caps:
|
|
|
118
114
|
| Medium implementation | 90 minutes |
|
|
119
115
|
| Large unattended phase | 3 hours |
|
|
120
116
|
|
|
121
|
-
## Built-In
|
|
117
|
+
## Built-In Stall Babysitter
|
|
122
118
|
|
|
123
119
|
Use `harnex run --watch` when one foreground process should launch the worker
|
|
124
|
-
and apply bounded stall recovery
|
|
120
|
+
and apply bounded stall recovery. This is different from `harnex watch --id`,
|
|
121
|
+
which watches an existing session's work-terminal state:
|
|
125
122
|
|
|
126
123
|
```bash
|
|
127
124
|
harnex run pi --id pi-i-NN --watch --preset impl \
|
|
128
125
|
--context "Read /tmp/task-impl-NN.md"
|
|
129
126
|
```
|
|
130
127
|
|
|
131
|
-
|
|
128
|
+
`run --watch` exits with:
|
|
132
129
|
|
|
133
130
|
| Code | Meaning |
|
|
134
131
|
| --- | --- |
|
|
@@ -143,6 +140,7 @@ interpretation.
|
|
|
143
140
|
|
|
144
141
|
- Polling `state=completed` alone and missing live sessions with `task_complete=true`.
|
|
145
142
|
- Polling `state=prompt` alone and calling it done.
|
|
143
|
+
- Wrapping `harnex wait` in loops that swallow non-zero `task_failed` results.
|
|
146
144
|
- Blocking orchestrators on `/tmp/*-done.txt` as the only completion signal.
|
|
147
145
|
- Letting an unattended loop run with no wall-clock cap.
|
|
148
146
|
- Reading raw tmux panes instead of `harnex pane`.
|
data/lib/harnex/cli.rb
CHANGED
|
@@ -15,6 +15,8 @@ module Harnex
|
|
|
15
15
|
Sender.new(@argv.drop(1)).run
|
|
16
16
|
when "wait"
|
|
17
17
|
Waiter.new(@argv.drop(1)).run
|
|
18
|
+
when "watch"
|
|
19
|
+
WatchCommand.new(@argv.drop(1)).run
|
|
18
20
|
when "stop"
|
|
19
21
|
Stopper.new(@argv.drop(1)).run
|
|
20
22
|
when "status"
|
|
@@ -59,6 +61,8 @@ module Harnex
|
|
|
59
61
|
Sender.usage
|
|
60
62
|
when "wait"
|
|
61
63
|
Waiter.usage
|
|
64
|
+
when "watch"
|
|
65
|
+
WatchCommand.usage
|
|
62
66
|
when "stop"
|
|
63
67
|
Stopper.usage
|
|
64
68
|
when "status"
|
|
@@ -90,6 +94,7 @@ module Harnex
|
|
|
90
94
|
harnex run <cli> [options] [--] [cli-args...]
|
|
91
95
|
harnex send --id ID [options] [text...]
|
|
92
96
|
harnex wait --id ID [options]
|
|
97
|
+
harnex watch --id ID [options]
|
|
93
98
|
harnex stop --id ID [options]
|
|
94
99
|
harnex status [options]
|
|
95
100
|
harnex logs --id ID [options]
|
|
@@ -104,6 +109,7 @@ module Harnex
|
|
|
104
109
|
run Start a wrapped interactive session and local API
|
|
105
110
|
send Send text to an active session
|
|
106
111
|
wait Block until a session exits or reaches a state
|
|
112
|
+
watch Safely watch existing work until done/task_failed/timeout
|
|
107
113
|
stop Send the adapter stop sequence to a session
|
|
108
114
|
status List live sessions
|
|
109
115
|
logs Read session output transcripts
|
|
@@ -129,6 +135,7 @@ module Harnex
|
|
|
129
135
|
harnex run aider --id blue-cat
|
|
130
136
|
harnex run codex -- --cd /path/to/repo
|
|
131
137
|
harnex status
|
|
138
|
+
harnex watch --id main --until done --max-wait 15m
|
|
132
139
|
harnex logs --id main --follow
|
|
133
140
|
harnex events --id main --snapshot
|
|
134
141
|
harnex history --limit 20
|
|
@@ -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(
|
|
269
|
-
signal_disconnect(message["error"])
|
|
268
|
+
pending.push(StandardError.new(err_msg))
|
|
270
269
|
else
|
|
271
270
|
pending.push(message["result"] || {})
|
|
272
271
|
end
|
|
@@ -102,14 +102,17 @@ module Harnex
|
|
|
102
102
|
end
|
|
103
103
|
|
|
104
104
|
def normalize_live_status(session)
|
|
105
|
-
|
|
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)
|
|
106
108
|
session.merge(
|
|
107
109
|
"state" => "running",
|
|
108
110
|
"process_state" => "running",
|
|
109
111
|
"terminal" => false,
|
|
110
112
|
"task_complete" => task_complete,
|
|
113
|
+
"task_failed" => task_failed,
|
|
111
114
|
"done" => Harnex.work_done_for("running", task_complete: task_complete),
|
|
112
|
-
"work_state" =>
|
|
115
|
+
"work_state" => work_state,
|
|
113
116
|
"exit" => nil,
|
|
114
117
|
"exit_code" => nil,
|
|
115
118
|
"summary_out" => nil,
|
|
@@ -123,6 +126,11 @@ module Harnex
|
|
|
123
126
|
!session["last_completed_at"].to_s.empty?
|
|
124
127
|
end
|
|
125
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
|
+
|
|
126
134
|
def load_live_status(session)
|
|
127
135
|
uri = URI("http://#{session.fetch('host')}:#{session.fetch('port')}/status")
|
|
128
136
|
request = Net::HTTP::Get.new(uri)
|
data/lib/harnex/commands/wait.rb
CHANGED
|
@@ -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,10 +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
|
-
terminal exit,
|
|
24
|
+
done (work fence — task_complete,
|
|
25
|
+
task_failed, or terminal exit,
|
|
26
|
+
whichever comes first)
|
|
26
27
|
task_complete (events JSONL — fires on
|
|
27
|
-
turn
|
|
28
|
+
successful turn completion)
|
|
29
|
+
task_failed (events JSONL — fires on
|
|
30
|
+
failed turn completion)
|
|
28
31
|
<other> (agent_state HTTP poll, e.g.
|
|
29
32
|
"prompt", "busy")
|
|
30
33
|
Without --until, waits for session exit (default).
|
|
@@ -40,7 +43,7 @@ module Harnex
|
|
|
40
43
|
|
|
41
44
|
Gotchas:
|
|
42
45
|
done is the safest work-level fence for monitors.
|
|
43
|
-
task_complete
|
|
46
|
+
task_complete/task_failed are event predicates; prompt/busy are live state polls.
|
|
44
47
|
Prompt state alone does not prove work acceptance. Verify artifacts/tests.
|
|
45
48
|
Exit waits can resolve from terminal summary rows when live registry/
|
|
46
49
|
exit-status files are already gone.
|
|
@@ -140,7 +143,7 @@ module Harnex
|
|
|
140
143
|
event = parse_event(line)
|
|
141
144
|
next unless event
|
|
142
145
|
|
|
143
|
-
task_complete_seen = true if event_type(event)
|
|
146
|
+
task_complete_seen = true if %w[task_complete task_failed].include?(event_type(event))
|
|
144
147
|
if matches?(event, predicate, task_complete_seen)
|
|
145
148
|
return [emit_event_match(event, start_time, predicate), f.pos, task_complete_seen]
|
|
146
149
|
end
|
|
@@ -173,8 +176,12 @@ module Harnex
|
|
|
173
176
|
def matches?(event, predicate, task_complete_seen)
|
|
174
177
|
type = event_type(event)
|
|
175
178
|
case predicate
|
|
176
|
-
when "task_complete"
|
|
179
|
+
when "task_complete"
|
|
177
180
|
type == "task_complete"
|
|
181
|
+
when "task_failed"
|
|
182
|
+
type == "task_failed"
|
|
183
|
+
when "done"
|
|
184
|
+
%w[task_complete task_failed].include?(type)
|
|
178
185
|
when "prompt"
|
|
179
186
|
type == "task_complete" ||
|
|
180
187
|
(task_complete_seen && type == "agent_state" && event["state"] == "prompt")
|
|
@@ -183,6 +190,13 @@ module Harnex
|
|
|
183
190
|
end
|
|
184
191
|
end
|
|
185
192
|
|
|
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
|
+
|
|
186
200
|
def emit_event_match(event, start_time, predicate)
|
|
187
201
|
waited = (Time.now - start_time).round(1)
|
|
188
202
|
payload = {
|
|
@@ -193,17 +207,22 @@ module Harnex
|
|
|
193
207
|
waited_seconds: waited
|
|
194
208
|
}
|
|
195
209
|
if predicate == "done"
|
|
210
|
+
failed = done_event_failed?(event)
|
|
196
211
|
payload.merge!(
|
|
197
|
-
|
|
212
|
+
ok: !failed,
|
|
213
|
+
status: failed ? "failed" : "done",
|
|
198
214
|
state: "running",
|
|
199
215
|
process_state: "running",
|
|
200
216
|
terminal: false,
|
|
201
|
-
task_complete:
|
|
202
|
-
done:
|
|
203
|
-
work_state: "completed"
|
|
217
|
+
task_complete: !failed,
|
|
218
|
+
done: !failed,
|
|
219
|
+
work_state: failed ? "failed" : "completed"
|
|
204
220
|
)
|
|
221
|
+
payload[:last_error] = event["message"] || event["error"] if failed
|
|
205
222
|
end
|
|
206
223
|
puts JSON.generate(payload)
|
|
224
|
+
return 1 if predicate == "done" && done_event_failed?(event)
|
|
225
|
+
|
|
207
226
|
0
|
|
208
227
|
end
|
|
209
228
|
|
|
@@ -431,7 +450,8 @@ module Harnex
|
|
|
431
450
|
data = JSON.parse(File.read(exit_path))
|
|
432
451
|
exit_code = data["exit_code"]
|
|
433
452
|
task_complete = data["task_complete"] == true || data["task_complete"].to_s == "true"
|
|
434
|
-
|
|
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)
|
|
435
455
|
state = exit_success ? "completed" : "failed"
|
|
436
456
|
done = task_complete || exit_success
|
|
437
457
|
payload = data.merge(
|
|
@@ -441,6 +461,7 @@ module Harnex
|
|
|
441
461
|
"process_state" => "exited",
|
|
442
462
|
"terminal" => true,
|
|
443
463
|
"task_complete" => task_complete,
|
|
464
|
+
"task_failed" => task_failed,
|
|
444
465
|
"done" => done,
|
|
445
466
|
"work_state" => Harnex.work_state_for(state, task_complete: task_complete)
|
|
446
467
|
)
|
|
@@ -486,6 +507,7 @@ module Harnex
|
|
|
486
507
|
|
|
487
508
|
def terminal_payload(status)
|
|
488
509
|
task_complete = !!status["task_complete"]
|
|
510
|
+
task_failed = !!status["task_failed"]
|
|
489
511
|
work_state = status["work_state"] || Harnex.work_state_for(status["state"], task_complete: task_complete)
|
|
490
512
|
done = status.key?("done") ? !!status["done"] : work_state == "completed"
|
|
491
513
|
{
|
|
@@ -495,6 +517,7 @@ module Harnex
|
|
|
495
517
|
process_state: status["process_state"] || Harnex.process_state_for(status["state"], terminal: true),
|
|
496
518
|
terminal: status.key?("terminal") ? !!status["terminal"] : true,
|
|
497
519
|
task_complete: task_complete,
|
|
520
|
+
task_failed: task_failed,
|
|
498
521
|
done: done,
|
|
499
522
|
work_state: work_state,
|
|
500
523
|
exit: status["exit"],
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
require "fileutils"
|
|
1
2
|
require "json"
|
|
2
3
|
require "net/http"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require "stringio"
|
|
3
6
|
require "uri"
|
|
4
7
|
|
|
5
8
|
module Harnex
|
|
@@ -206,4 +209,205 @@ module Harnex
|
|
|
206
209
|
@monotonic_clock.call
|
|
207
210
|
end
|
|
208
211
|
end
|
|
212
|
+
|
|
213
|
+
class TerminalWatcher
|
|
214
|
+
TIMEOUT_EXIT_CODE = 124
|
|
215
|
+
|
|
216
|
+
def initialize(
|
|
217
|
+
id:,
|
|
218
|
+
repo_path: Dir.pwd,
|
|
219
|
+
until_state: "done",
|
|
220
|
+
max_wait: nil,
|
|
221
|
+
done_marker: nil,
|
|
222
|
+
fail_marker: nil,
|
|
223
|
+
stop_on_terminal: false,
|
|
224
|
+
out: $stdout,
|
|
225
|
+
err: $stderr
|
|
226
|
+
)
|
|
227
|
+
@id = Harnex.normalize_id(id)
|
|
228
|
+
@repo_path = repo_path
|
|
229
|
+
@until_state = until_state.to_s.strip.empty? ? "done" : until_state.to_s
|
|
230
|
+
@max_wait = max_wait
|
|
231
|
+
@done_marker = done_marker
|
|
232
|
+
@fail_marker = fail_marker
|
|
233
|
+
@stop_on_terminal = stop_on_terminal
|
|
234
|
+
@out = out
|
|
235
|
+
@err = err
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def run
|
|
239
|
+
raise "harnex watch: only --until done is supported" unless @until_state == "done"
|
|
240
|
+
|
|
241
|
+
output, warnings, exit_code = capture_wait
|
|
242
|
+
@err.write(warnings) unless warnings.empty?
|
|
243
|
+
@out.write(output) unless output.empty?
|
|
244
|
+
|
|
245
|
+
payload = parse_payload(output)
|
|
246
|
+
outcome = classify(exit_code, payload)
|
|
247
|
+
case outcome
|
|
248
|
+
when :success
|
|
249
|
+
write_marker(@done_marker, payload, outcome: outcome, exit_code: exit_code)
|
|
250
|
+
when :failed
|
|
251
|
+
write_marker(@fail_marker, payload, outcome: outcome, exit_code: exit_code)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
stop_session if @stop_on_terminal && outcome != :timeout
|
|
255
|
+
exit_code
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
private
|
|
259
|
+
|
|
260
|
+
def capture_wait
|
|
261
|
+
argv = ["--id", @id, "--repo", @repo_path, "--until", @until_state]
|
|
262
|
+
argv += ["--timeout", @max_wait.to_s] if @max_wait
|
|
263
|
+
|
|
264
|
+
out_buffer = StringIO.new
|
|
265
|
+
err_buffer = StringIO.new
|
|
266
|
+
original_stdout = $stdout
|
|
267
|
+
original_stderr = $stderr
|
|
268
|
+
$stdout = out_buffer
|
|
269
|
+
$stderr = err_buffer
|
|
270
|
+
exit_code = Waiter.new(argv).run
|
|
271
|
+
[out_buffer.string, err_buffer.string, exit_code]
|
|
272
|
+
ensure
|
|
273
|
+
$stdout = original_stdout
|
|
274
|
+
$stderr = original_stderr
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def parse_payload(output)
|
|
278
|
+
line = output.to_s.lines.reverse.find { |candidate| !candidate.strip.empty? }
|
|
279
|
+
return {} unless line
|
|
280
|
+
|
|
281
|
+
parsed = JSON.parse(line)
|
|
282
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
283
|
+
rescue JSON::ParserError
|
|
284
|
+
{}
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def classify(exit_code, payload)
|
|
288
|
+
return :timeout if exit_code == TIMEOUT_EXIT_CODE || payload["status"].to_s == "timeout"
|
|
289
|
+
return :success if exit_code.to_i.zero? && (payload.empty? || payload["ok"] != false)
|
|
290
|
+
|
|
291
|
+
:failed
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def write_marker(path, payload, outcome:, exit_code:)
|
|
295
|
+
marker_path = path.to_s.strip
|
|
296
|
+
return if marker_path.empty?
|
|
297
|
+
|
|
298
|
+
expanded_path = File.expand_path(marker_path)
|
|
299
|
+
FileUtils.mkdir_p(File.dirname(expanded_path))
|
|
300
|
+
marker_payload = {
|
|
301
|
+
ok: outcome == :success,
|
|
302
|
+
id: @id,
|
|
303
|
+
outcome: outcome.to_s,
|
|
304
|
+
exit_code: exit_code,
|
|
305
|
+
status: payload["status"],
|
|
306
|
+
work_state: payload["work_state"],
|
|
307
|
+
task_complete: payload["task_complete"] || payload["event"] == "task_complete",
|
|
308
|
+
task_failed: payload["task_failed"] || payload["event"] == "task_failed",
|
|
309
|
+
done: payload["done"],
|
|
310
|
+
terminal: payload["terminal"],
|
|
311
|
+
source: "harnex watch"
|
|
312
|
+
}.compact
|
|
313
|
+
File.write(expanded_path, JSON.generate(marker_payload) + "\n")
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def stop_session
|
|
317
|
+
repo_root = Harnex.resolve_repo_root(@repo_path)
|
|
318
|
+
registry = Harnex.read_registry(repo_root, @id)
|
|
319
|
+
return unless registry
|
|
320
|
+
|
|
321
|
+
uri = URI("http://#{registry.fetch('host')}:#{registry.fetch('port')}/stop")
|
|
322
|
+
request = Net::HTTP::Post.new(uri)
|
|
323
|
+
request["Authorization"] = "Bearer #{registry['token']}" if registry["token"]
|
|
324
|
+
|
|
325
|
+
response = Net::HTTP.start(uri.host, uri.port, open_timeout: 1, read_timeout: 2) do |http|
|
|
326
|
+
http.request(request)
|
|
327
|
+
end
|
|
328
|
+
@err.puts("harnex watch: stop-on-terminal failed with HTTP #{response.code}") unless response.is_a?(Net::HTTPSuccess)
|
|
329
|
+
rescue StandardError => e
|
|
330
|
+
@err.puts("harnex watch: stop-on-terminal failed: #{e.message}")
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
class WatchCommand
|
|
335
|
+
def self.usage(program_name = "harnex watch")
|
|
336
|
+
<<~TEXT
|
|
337
|
+
Usage: #{program_name} --id ID [options]
|
|
338
|
+
|
|
339
|
+
Options:
|
|
340
|
+
--id ID Existing session ID to watch (required)
|
|
341
|
+
--until done Watch work-level terminal state (default: done)
|
|
342
|
+
--repo PATH Resolve session using PATH's repo root (default: current repo)
|
|
343
|
+
--max-wait DUR Wall-clock cap before returning timeout (examples: 900, 15m, 2h)
|
|
344
|
+
--timeout DUR Alias for --max-wait
|
|
345
|
+
--done-marker PATH Write a JSON marker when work completes successfully
|
|
346
|
+
--fail-marker PATH Write a JSON marker when work fails
|
|
347
|
+
--stop-on-terminal Stop the live session after success/failure (not on timeout)
|
|
348
|
+
-h, --help Show this help
|
|
349
|
+
|
|
350
|
+
`harnex watch` is the safe watcher for existing --tmux or detached
|
|
351
|
+
dispatches. It exits 0 for task_complete/done, non-zero for task_failed
|
|
352
|
+
or failed terminal summaries, and 124 for --max-wait timeouts.
|
|
353
|
+
|
|
354
|
+
For launch-and-babysit stall recovery, use `harnex run --watch`.
|
|
355
|
+
TEXT
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def initialize(argv)
|
|
359
|
+
@argv = argv.dup
|
|
360
|
+
@options = {
|
|
361
|
+
id: nil,
|
|
362
|
+
repo_path: Dir.pwd,
|
|
363
|
+
until_state: "done",
|
|
364
|
+
max_wait: nil,
|
|
365
|
+
done_marker: nil,
|
|
366
|
+
fail_marker: nil,
|
|
367
|
+
stop_on_terminal: false,
|
|
368
|
+
help: false
|
|
369
|
+
}
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def run
|
|
373
|
+
parser.parse!(@argv)
|
|
374
|
+
if @options[:help]
|
|
375
|
+
puts self.class.usage
|
|
376
|
+
return 0
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
raise "--id is required for harnex watch" unless @options[:id]
|
|
380
|
+
|
|
381
|
+
TerminalWatcher.new(
|
|
382
|
+
id: @options[:id],
|
|
383
|
+
repo_path: @options[:repo_path],
|
|
384
|
+
until_state: @options[:until_state],
|
|
385
|
+
max_wait: @options[:max_wait],
|
|
386
|
+
done_marker: @options[:done_marker],
|
|
387
|
+
fail_marker: @options[:fail_marker],
|
|
388
|
+
stop_on_terminal: @options[:stop_on_terminal]
|
|
389
|
+
).run
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
private
|
|
393
|
+
|
|
394
|
+
def parser
|
|
395
|
+
@parser ||= OptionParser.new do |opts|
|
|
396
|
+
opts.banner = "Usage: harnex watch --id ID [options]"
|
|
397
|
+
opts.on("--id ID", "Existing session ID to watch") { |value| @options[:id] = Harnex.normalize_id(value) }
|
|
398
|
+
opts.on("--until STATE", "Watch until terminal state") { |value| @options[:until_state] = value }
|
|
399
|
+
opts.on("--repo PATH", "Resolve session using PATH's repo root") { |value| @options[:repo_path] = value }
|
|
400
|
+
opts.on("--max-wait DUR", "Wall-clock cap") do |value|
|
|
401
|
+
@options[:max_wait] = Harnex.parse_duration_seconds(value, option_name: "--max-wait")
|
|
402
|
+
end
|
|
403
|
+
opts.on("--timeout DUR", "Alias for --max-wait") do |value|
|
|
404
|
+
@options[:max_wait] = Harnex.parse_duration_seconds(value, option_name: "--timeout")
|
|
405
|
+
end
|
|
406
|
+
opts.on("--done-marker PATH", "Write marker on successful completion") { |value| @options[:done_marker] = value }
|
|
407
|
+
opts.on("--fail-marker PATH", "Write marker on failed completion") { |value| @options[:fail_marker] = value }
|
|
408
|
+
opts.on("--stop-on-terminal", "Stop live session after success/failure") { @options[:stop_on_terminal] = true }
|
|
409
|
+
opts.on("-h", "--help", "Show help") { @options[:help] = true }
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|
|
209
413
|
end
|
|
@@ -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,14 +224,19 @@ module Harnex
|
|
|
221
224
|
end
|
|
222
225
|
|
|
223
226
|
payload[:input_state] = adapter.input_state(screen_snapshot) if include_input_state
|
|
224
|
-
task_complete =
|
|
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)
|
|
225
230
|
payload[:agent_state] = @state_machine.to_s
|
|
226
231
|
payload[:process_state] = "running"
|
|
227
232
|
payload[:inbox] = @inbox.stats
|
|
228
233
|
payload[:last_completed_at] = @last_completed_at&.iso8601
|
|
234
|
+
payload[:last_failed_at] = @last_failed_at&.iso8601
|
|
229
235
|
payload[:task_complete] = task_complete
|
|
236
|
+
payload[:task_failed] = task_failed
|
|
230
237
|
payload[:done] = Harnex.work_done_for("running", task_complete: task_complete)
|
|
231
|
-
payload[:work_state] =
|
|
238
|
+
payload[:work_state] = work_state
|
|
239
|
+
payload[:last_error] = @last_error
|
|
232
240
|
payload[:model] = summary_model
|
|
233
241
|
payload[:effort] = meta_hash["effort"]
|
|
234
242
|
payload[:auto_disconnects] = @event_counters.snapshot[:disconnections]
|
|
@@ -236,7 +244,11 @@ module Harnex
|
|
|
236
244
|
end
|
|
237
245
|
|
|
238
246
|
def task_complete?
|
|
239
|
-
!!@last_completed_at
|
|
247
|
+
!!@last_completed_at && !task_failed?
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def task_failed?
|
|
251
|
+
!!@last_failed_at
|
|
240
252
|
end
|
|
241
253
|
|
|
242
254
|
def git_start
|
|
@@ -257,7 +269,7 @@ module Harnex
|
|
|
257
269
|
inject_sequence([{ text: text, newline: newline }])
|
|
258
270
|
end
|
|
259
271
|
|
|
260
|
-
def inject_stop(turn_id: nil)
|
|
272
|
+
def inject_stop(turn_id: nil, interrupt: true)
|
|
261
273
|
unless structured_transport?
|
|
262
274
|
raise "session is not running" unless pid && Harnex.alive_pid?(pid)
|
|
263
275
|
end
|
|
@@ -274,15 +286,21 @@ module Harnex
|
|
|
274
286
|
end
|
|
275
287
|
end
|
|
276
288
|
end
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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!
|
|
282
297
|
end
|
|
283
|
-
|
|
298
|
+
return { ok: true, signal: "interrupt_sent" }
|
|
284
299
|
end
|
|
285
|
-
|
|
300
|
+
|
|
301
|
+
@state_machine.force_busy!
|
|
302
|
+
signal_rpc_done! unless @pid
|
|
303
|
+
return { ok: true, signal: "terminate_sent" }
|
|
286
304
|
end
|
|
287
305
|
|
|
288
306
|
@inject_mutex.synchronize do
|
|
@@ -336,7 +354,12 @@ module Harnex
|
|
|
336
354
|
|
|
337
355
|
turn_id = nil
|
|
338
356
|
@inject_mutex.synchronize do
|
|
339
|
-
|
|
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
|
|
340
363
|
@state_machine.force_busy!
|
|
341
364
|
@injected_count += 1
|
|
342
365
|
@last_injected_at = Time.now
|
|
@@ -460,14 +483,25 @@ module Harnex
|
|
|
460
483
|
@state_machine.force_busy!
|
|
461
484
|
emit_event("turn_started", turnId: params.dig("turn", "id"))
|
|
462
485
|
when "turn/completed"
|
|
463
|
-
@last_completed_at = Time.now
|
|
464
486
|
@state_machine.force_prompt!
|
|
465
487
|
turn = params["turn"] || {}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
payload
|
|
469
|
-
|
|
470
|
-
|
|
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)
|
|
471
505
|
when "item/completed"
|
|
472
506
|
emit_event("item_completed", item: params["item"])
|
|
473
507
|
@event_counters.record_item(params["item"])
|
|
@@ -487,15 +521,70 @@ module Harnex
|
|
|
487
521
|
when "account/rateLimits/updated"
|
|
488
522
|
@rate_limits = params
|
|
489
523
|
when "error"
|
|
490
|
-
|
|
524
|
+
message = extract_error_notification_message(params)
|
|
525
|
+
@last_error = message unless message.to_s.empty?
|
|
491
526
|
@state_machine.force_busy!
|
|
492
|
-
emit_event(
|
|
493
|
-
|
|
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?
|
|
494
537
|
end
|
|
495
538
|
rescue StandardError => e
|
|
496
539
|
warn("harnex: rpc notification handler error: #{e.message}")
|
|
497
540
|
end
|
|
498
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
|
+
|
|
499
588
|
def handle_jsonl_notification(message)
|
|
500
589
|
event_type = message["type"].to_s
|
|
501
590
|
|
|
@@ -509,7 +598,7 @@ module Harnex
|
|
|
509
598
|
@state_machine.force_prompt!
|
|
510
599
|
emit_event("task_complete")
|
|
511
600
|
adapter.request_session_stats_async if adapter.respond_to?(:request_session_stats_async)
|
|
512
|
-
schedule_auto_stop("task_complete")
|
|
601
|
+
schedule_auto_stop("task_complete", interrupt: false)
|
|
513
602
|
when "message_start"
|
|
514
603
|
@pi_streamed_text_by_message[pi_message_key(message["message"])] = false
|
|
515
604
|
when "message_update"
|
|
@@ -578,12 +667,21 @@ module Harnex
|
|
|
578
667
|
|
|
579
668
|
def handle_rpc_disconnect(error)
|
|
580
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
|
+
|
|
581
675
|
@last_error = msg.to_s unless msg.to_s.empty?
|
|
582
676
|
@state_machine.force_busy!
|
|
583
677
|
emit_event("disconnected", source: "transport", message: msg) rescue nil
|
|
584
678
|
signal_rpc_done!
|
|
585
679
|
end
|
|
586
680
|
|
|
681
|
+
def normal_auto_stop_disconnect?(message)
|
|
682
|
+
message.to_s.empty? && @auto_stop_fired && (task_complete? || task_failed?)
|
|
683
|
+
end
|
|
684
|
+
|
|
587
685
|
def dispatch_initial_prompt
|
|
588
686
|
return unless adapter.respond_to?(:initial_prompt)
|
|
589
687
|
|
|
@@ -738,10 +836,11 @@ module Harnex
|
|
|
738
836
|
return unless defined?(@exit_code) && !@exit_code.nil?
|
|
739
837
|
|
|
740
838
|
exit_path = Harnex.exit_status_path(repo_root, id)
|
|
741
|
-
task_complete =
|
|
742
|
-
|
|
839
|
+
task_complete = task_complete?
|
|
840
|
+
task_failed = task_failed?
|
|
841
|
+
state = task_failed || @exit_code.to_i != 0 ? "failed" : "completed"
|
|
743
842
|
payload = {
|
|
744
|
-
ok:
|
|
843
|
+
ok: !task_failed && state == "completed",
|
|
745
844
|
id: id,
|
|
746
845
|
cli: adapter.key,
|
|
747
846
|
session_id: session_id,
|
|
@@ -750,6 +849,7 @@ module Harnex
|
|
|
750
849
|
state: state,
|
|
751
850
|
process_state: "exited",
|
|
752
851
|
task_complete: task_complete,
|
|
852
|
+
task_failed: task_failed,
|
|
753
853
|
done: Harnex.work_done_for(state, task_complete: task_complete),
|
|
754
854
|
work_state: Harnex.work_state_for(state, task_complete: task_complete),
|
|
755
855
|
started_at: @started_at.iso8601,
|
|
@@ -955,7 +1055,7 @@ module Harnex
|
|
|
955
1055
|
schedule_auto_stop("prompt_after_busy") if seen_busy && new_state == :prompt
|
|
956
1056
|
end
|
|
957
1057
|
|
|
958
|
-
def schedule_auto_stop(reason, turn_id: nil)
|
|
1058
|
+
def schedule_auto_stop(reason, turn_id: nil, interrupt: true)
|
|
959
1059
|
return unless @auto_stop
|
|
960
1060
|
|
|
961
1061
|
should_fire = @auto_stop_mutex.synchronize do
|
|
@@ -970,7 +1070,7 @@ module Harnex
|
|
|
970
1070
|
|
|
971
1071
|
thread = Thread.new do
|
|
972
1072
|
begin
|
|
973
|
-
inject_stop(turn_id: turn_id)
|
|
1073
|
+
inject_stop(turn_id: turn_id, interrupt: interrupt)
|
|
974
1074
|
rescue StandardError => e
|
|
975
1075
|
warn("harnex: auto-stop failed after #{reason}: #{e.message}")
|
|
976
1076
|
end
|
|
@@ -1016,17 +1116,26 @@ module Harnex
|
|
|
1016
1116
|
|
|
1017
1117
|
def normalize_auto_stop_exit_code!
|
|
1018
1118
|
return unless @auto_stop
|
|
1019
|
-
return unless @last_completed_at
|
|
1020
1119
|
return unless @auto_stop_fired
|
|
1021
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
|
+
|
|
1022
1129
|
@exit_code = 0
|
|
1023
1130
|
@term_signal = nil
|
|
1024
1131
|
end
|
|
1025
1132
|
|
|
1026
1133
|
def classify_exit
|
|
1027
1134
|
return "timeout" if @exit_code == 124
|
|
1028
|
-
return "success" if @exit_code == 0 && session_summary_present?
|
|
1029
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?
|
|
1030
1139
|
return "failure" unless @exit_code == 0
|
|
1031
1140
|
|
|
1032
1141
|
"disconnected"
|
|
@@ -1110,7 +1219,7 @@ module Harnex
|
|
|
1110
1219
|
files_changed: @git_end[:files_changed],
|
|
1111
1220
|
commits: @git_end[:commits],
|
|
1112
1221
|
exit: @exit_reason,
|
|
1113
|
-
task_complete:
|
|
1222
|
+
task_complete: task_complete?,
|
|
1114
1223
|
signal: @term_signal,
|
|
1115
1224
|
exit_code: @exit_code,
|
|
1116
1225
|
last_error: @last_error,
|
|
@@ -1256,6 +1365,7 @@ module Harnex
|
|
|
1256
1365
|
@event_counters.record(type)
|
|
1257
1366
|
@events_mutex.synchronize do
|
|
1258
1367
|
return unless @events_log
|
|
1368
|
+
return if @events_log.closed?
|
|
1259
1369
|
|
|
1260
1370
|
@events_log_seq += 1
|
|
1261
1371
|
event = {
|
|
@@ -45,6 +45,7 @@ module Harnex
|
|
|
45
45
|
"process_state" => "unknown",
|
|
46
46
|
"terminal" => false,
|
|
47
47
|
"task_complete" => false,
|
|
48
|
+
"task_failed" => false,
|
|
48
49
|
"done" => false,
|
|
49
50
|
"work_state" => "unknown",
|
|
50
51
|
"exit" => nil,
|
|
@@ -131,6 +132,7 @@ module Harnex
|
|
|
131
132
|
actual = record["actual"] || {}
|
|
132
133
|
state = classify_summary_state(actual)
|
|
133
134
|
task_complete = !!actual["task_complete"]
|
|
135
|
+
task_failed = state == "failed" && !task_complete
|
|
134
136
|
terminal = state != "unknown"
|
|
135
137
|
{
|
|
136
138
|
"id" => meta["id"].to_s,
|
|
@@ -139,6 +141,7 @@ module Harnex
|
|
|
139
141
|
"process_state" => Harnex.process_state_for(state, terminal: terminal),
|
|
140
142
|
"terminal" => terminal,
|
|
141
143
|
"task_complete" => task_complete,
|
|
144
|
+
"task_failed" => task_failed,
|
|
142
145
|
"done" => Harnex.work_done_for(state, task_complete: task_complete),
|
|
143
146
|
"work_state" => Harnex.work_state_for(state, task_complete: task_complete),
|
|
144
147
|
"exit" => blank_to_nil(actual["exit"]),
|
|
@@ -173,6 +176,7 @@ module Harnex
|
|
|
173
176
|
"unknown"
|
|
174
177
|
end
|
|
175
178
|
task_complete = record["terminal_event"].to_s == "task_complete"
|
|
179
|
+
task_failed = record["terminal_event"].to_s == "task_failed" || (state == "failed" && !task_complete)
|
|
176
180
|
terminal = state != "unknown"
|
|
177
181
|
{
|
|
178
182
|
"id" => record["id"].to_s,
|
|
@@ -181,6 +185,7 @@ module Harnex
|
|
|
181
185
|
"process_state" => Harnex.process_state_for(state, terminal: terminal),
|
|
182
186
|
"terminal" => terminal,
|
|
183
187
|
"task_complete" => task_complete,
|
|
188
|
+
"task_failed" => task_failed,
|
|
184
189
|
"done" => Harnex.work_done_for(state, task_complete: task_complete),
|
|
185
190
|
"work_state" => Harnex.work_state_for(state, task_complete: task_complete),
|
|
186
191
|
"exit" => history_exit(status),
|
data/lib/harnex/version.rb
CHANGED
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
|
+
version: 0.7.8
|
|
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-06-
|
|
11
|
+
date: 2026-06-13 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.
|