harnex 0.3.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/GUIDE.md +11 -0
- data/README.md +37 -5
- data/TECHNICAL.md +72 -16
- data/lib/harnex/cli.rb +7 -0
- data/lib/harnex/commands/events.rb +212 -0
- data/lib/harnex/commands/run.rb +128 -14
- data/lib/harnex/commands/status.rb +17 -3
- data/lib/harnex/commands/watch.rb +209 -0
- data/lib/harnex/commands/watch_presets.rb +17 -0
- data/lib/harnex/core.rb +33 -0
- data/lib/harnex/runtime/session.rb +75 -2
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +3 -0
- data/skills/harnex-buddy/SKILL.md +5 -0
- data/skills/harnex-dispatch/SKILL.md +16 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e4d7c194ba2ba10ee075e6e53aa3eaf291cbfc08eca1d89cd291955834230c01
|
|
4
|
+
data.tar.gz: e3bafc087ae74a484be3ee121cce0d8f351c6b381ce244d5da032e48c85da4db
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e3737c2aa49fb223c75cfc0d997a52bb72029b4c1a7a11affdd05a16c685e4edc42384ff0cfc03e63e4b4abcc9228c7f1d37c408c074538392c75d13be4e1e5e
|
|
7
|
+
data.tar.gz: 0b096fb9d95f22b812fc43fc69b4f8546e090d76c669383b8d71b34a048aa8eed46510f7ce9be27e0da3eab449a610c13fd6a338d8e1c202649d235df7052154
|
data/GUIDE.md
CHANGED
|
@@ -49,6 +49,17 @@ automatically when the agent is ready. You don't have to wait
|
|
|
49
49
|
or retry. Queueing exists, but the default workflow should still be
|
|
50
50
|
one task per fresh worker.
|
|
51
51
|
|
|
52
|
+
For unattended dispatch, prefer built-in monitoring over external poll loops:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
harnex run codex --id impl --tmux impl --watch --preset impl
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
This adds a foreground watcher that checks idle activity and performs bounded
|
|
59
|
+
force-resume nudges. For full flag behavior and event-stream consumers, see
|
|
60
|
+
[TECHNICAL.md](TECHNICAL.md) and the built-in monitoring section in
|
|
61
|
+
[README.md](README.md).
|
|
62
|
+
|
|
52
63
|
## Seeing what's running
|
|
53
64
|
|
|
54
65
|
```bash
|
data/README.md
CHANGED
|
@@ -105,12 +105,43 @@ Install skills so agents can use them:
|
|
|
105
105
|
harnex skills install
|
|
106
106
|
```
|
|
107
107
|
|
|
108
|
+
## Built-in dispatch monitoring
|
|
109
|
+
|
|
110
|
+
For unattended dispatches, use `--watch` instead of writing a bash poll loop:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
harnex run codex --id cx-impl-42 --tmux cx-impl-42 --watch --preset impl \
|
|
114
|
+
--context "Implement koder/plans/42_plan.md. Run tests and commit when done."
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`--watch` runs a foreground babysitter that checks session activity every 60s,
|
|
118
|
+
force-resumes on stall up to a cap, and exits when the target session exits or
|
|
119
|
+
the resume cap is reached.
|
|
120
|
+
|
|
121
|
+
Presets map to stall policy defaults:
|
|
122
|
+
|
|
123
|
+
- `impl` -> `--stall-after 8m --max-resumes 1`
|
|
124
|
+
- `plan` -> `--stall-after 3m --max-resumes 2`
|
|
125
|
+
- `gate` -> `--stall-after 15m --max-resumes 0`
|
|
126
|
+
|
|
127
|
+
Explicit `--stall-after` and `--max-resumes` flags override preset defaults.
|
|
128
|
+
|
|
129
|
+
For structured subscriptions, stream JSONL events:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
harnex events --id cx-impl-42 | jq -c '.'
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Schema details and compatibility policy are documented in
|
|
136
|
+
[docs/events.md](docs/events.md).
|
|
137
|
+
|
|
108
138
|
## Long-running and overnight work
|
|
109
139
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
140
|
+
For plain "force-resume on stall" recovery, use
|
|
141
|
+
`harnex run --watch --preset impl`.
|
|
142
|
+
|
|
143
|
+
A **buddy** is for richer reasoning: doc drift checks, semantic sanity checks,
|
|
144
|
+
and multi-session correlation. It's still just another harnex session.
|
|
114
145
|
|
|
115
146
|
### Example: keep a worker from stalling
|
|
116
147
|
|
|
@@ -168,12 +199,13 @@ See [recipes/03_buddy.md](recipes/03_buddy.md) for the full pattern.
|
|
|
168
199
|
|
|
169
200
|
| Command | What it does |
|
|
170
201
|
|---------|-------------|
|
|
171
|
-
| `harnex run <cli>` | Start an agent (`--tmux`
|
|
202
|
+
| `harnex run <cli>` | Start an agent (`--tmux` visible, `--detach` background, `--watch` built-in monitoring) |
|
|
172
203
|
| `harnex send --id <id>` | Send a message (queues if busy, `--wait-for-idle` to block until done) |
|
|
173
204
|
| `harnex stop --id <id>` | Send the agent's native exit sequence |
|
|
174
205
|
| `harnex status` | List running sessions (`--json` for full payloads) |
|
|
175
206
|
| `harnex pane --id <id>` | Capture the agent's tmux screen (`--follow` for live) |
|
|
176
207
|
| `harnex logs --id <id>` | Read session transcript (`--follow` to tail) |
|
|
208
|
+
| `harnex events --id <id>` | Stream structured session events (`--snapshot` for non-blocking dump) |
|
|
177
209
|
| `harnex wait --id <id>` | Block until exit or a target state |
|
|
178
210
|
| `harnex guide` | Getting started walkthrough |
|
|
179
211
|
| `harnex recipes` | Tested workflow patterns |
|
data/TECHNICAL.md
CHANGED
|
@@ -14,17 +14,21 @@ harnex run claude --id review
|
|
|
14
14
|
harnex run codex -- --cd ~/other/repo
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
| Flag
|
|
18
|
-
|
|
19
|
-
| `--id ID`
|
|
20
|
-
| `--description`
|
|
21
|
-
| `--detach`
|
|
22
|
-
| `--tmux [NAME]`
|
|
23
|
-
| `--host HOST`
|
|
24
|
-
| `--port PORT`
|
|
25
|
-
| `--
|
|
26
|
-
| `--
|
|
27
|
-
| `--
|
|
17
|
+
| Flag | What it does |
|
|
18
|
+
|---------------------|---------------------------------------------------------------------|
|
|
19
|
+
| `--id ID` | Name this session (default: random) |
|
|
20
|
+
| `--description` | Store a short session description |
|
|
21
|
+
| `--detach` | Run in background, no terminal |
|
|
22
|
+
| `--tmux [NAME]` | Run in a tmux window you can watch |
|
|
23
|
+
| `--host HOST` | Bind a specific API host |
|
|
24
|
+
| `--port PORT` | Force a specific API port |
|
|
25
|
+
| `--watch` | Enable blocking babysitter mode (foreground only) |
|
|
26
|
+
| `--stall-after DUR` | Idle threshold before force-resume (default: `480s`) |
|
|
27
|
+
| `--max-resumes N` | Max forced resumes before escalation (default: `1`) |
|
|
28
|
+
| `--preset NAME` | Watch preset (`impl`, `plan`, `gate`), requires `--watch` |
|
|
29
|
+
| `--watch-file PATH` | Auto-send a file-change hook (`--watch PATH`/`--watch=PATH` legacy) |
|
|
30
|
+
| `--context TXT` | Give the agent a task on startup |
|
|
31
|
+
| `--timeout SEC` | Wait budget for detached registration |
|
|
28
32
|
|
|
29
33
|
### `harnex send` — Talk to a running agent
|
|
30
34
|
|
|
@@ -61,12 +65,20 @@ failures for up to the given timeout.
|
|
|
61
65
|
### `harnex status` — See running agents
|
|
62
66
|
|
|
63
67
|
```
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
68
|
+
ID CLI PID PORT AGE IDLE STATE REPO DESC
|
|
69
|
+
worker codex 12345 43123 36s 12s prompt ~/... -
|
|
70
|
+
review claude 12346 43124 8s - busy ~/... -
|
|
67
71
|
```
|
|
68
72
|
|
|
69
|
-
|
|
73
|
+
Text mode includes an `IDLE` column derived from `log_idle_s` (`-` means no
|
|
74
|
+
transcript activity yet).
|
|
75
|
+
|
|
76
|
+
Use `--json` for full payloads. JSON includes:
|
|
77
|
+
|
|
78
|
+
- `log_mtime` (ISO8601 or `null`) — transcript file mtime
|
|
79
|
+
- `log_idle_s` (Integer or `null`) — seconds since last transcript write
|
|
80
|
+
|
|
81
|
+
Use `--all` for all repos.
|
|
70
82
|
|
|
71
83
|
### `harnex wait` — Wait for an agent to finish
|
|
72
84
|
|
|
@@ -160,12 +172,54 @@ harnex run codex --id impl-1 --tmux cx-p1 \
|
|
|
160
172
|
### File watching
|
|
161
173
|
|
|
162
174
|
```bash
|
|
163
|
-
harnex run codex --id worker --watch ./tmp/status.jsonl
|
|
175
|
+
harnex run codex --id worker --watch-file ./tmp/status.jsonl
|
|
164
176
|
```
|
|
165
177
|
|
|
166
178
|
Agent gets notified when the file changes. File doesn't need to
|
|
167
179
|
exist at startup.
|
|
168
180
|
|
|
181
|
+
Legacy compatibility: `--watch PATH` and `--watch=PATH` still configure
|
|
182
|
+
file-hook mode.
|
|
183
|
+
|
|
184
|
+
## harnex events
|
|
185
|
+
|
|
186
|
+
`harnex events` streams structured per-session JSONL for orchestrators and
|
|
187
|
+
monitoring tooling.
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
harnex events --id worker
|
|
191
|
+
harnex events --id worker --snapshot
|
|
192
|
+
harnex events --id worker --from 2026-04-29T10:00:00Z
|
|
193
|
+
harnex events --id worker | jq -c '.'
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
| Flag | What it does |
|
|
197
|
+
|------|---------------|
|
|
198
|
+
| `--id ID` | Session ID to inspect (required) |
|
|
199
|
+
| `--[no-]follow` | Stream appended events (default: follow) |
|
|
200
|
+
| `--snapshot` | Print current event file and exit (`--no-follow`) |
|
|
201
|
+
| `--from TS` | Replay floor (ISO-8601, inclusive; `ts >= from`) |
|
|
202
|
+
| `--repo PATH` | Resolve ID from a specific repo root |
|
|
203
|
+
| `--cli CLI` | Filter active-session resolution by CLI |
|
|
204
|
+
|
|
205
|
+
Exit codes:
|
|
206
|
+
|
|
207
|
+
- `0` — snapshot completed, or follow mode observed `type: "exited"`
|
|
208
|
+
- `1` — operational error (missing stream/session, invalid `--from`,
|
|
209
|
+
stream truncated/disappeared, lookup failure)
|
|
210
|
+
|
|
211
|
+
Transport file (append-only JSONL):
|
|
212
|
+
|
|
213
|
+
```
|
|
214
|
+
~/.local/state/harnex/events/<repo_hash>--<id>.jsonl
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Each row uses schema v1 with envelope fields `schema_version`, `seq`, `ts`,
|
|
218
|
+
`id`, and `type`. Emitted today: `started`, `send`, `exited`. `send.msg` is a
|
|
219
|
+
200-character preview with `msg_truncated` when shortened.
|
|
220
|
+
|
|
221
|
+
Schema details and compatibility guarantees are in [docs/events.md](docs/events.md).
|
|
222
|
+
|
|
169
223
|
## Architecture
|
|
170
224
|
|
|
171
225
|
```
|
|
@@ -218,6 +272,8 @@ When you run `harnex run codex --id worker`:
|
|
|
218
272
|
~/.local/state/harnex/sessions/<repo_hash>--<id>.json
|
|
219
273
|
and open transcript file:
|
|
220
274
|
~/.local/state/harnex/output/<repo_hash>--<id>.log
|
|
275
|
+
and open events file:
|
|
276
|
+
~/.local/state/harnex/events/<repo_hash>--<id>.jsonl
|
|
221
277
|
8. Start background threads:
|
|
222
278
|
- PTY reader (screen buffer)
|
|
223
279
|
- State machine (adapter parses screen for state)
|
data/lib/harnex/cli.rb
CHANGED
|
@@ -21,6 +21,8 @@ module Harnex
|
|
|
21
21
|
Status.new(@argv.drop(1)).run
|
|
22
22
|
when "logs"
|
|
23
23
|
Logs.new(@argv.drop(1)).run
|
|
24
|
+
when "events"
|
|
25
|
+
Events.new(@argv.drop(1)).run
|
|
24
26
|
when "pane"
|
|
25
27
|
Pane.new(@argv.drop(1)).run
|
|
26
28
|
when "recipes"
|
|
@@ -59,6 +61,8 @@ module Harnex
|
|
|
59
61
|
Status.usage
|
|
60
62
|
when "logs"
|
|
61
63
|
Logs.usage
|
|
64
|
+
when "events"
|
|
65
|
+
Events.usage
|
|
62
66
|
when "pane"
|
|
63
67
|
Pane.usage
|
|
64
68
|
when "recipes"
|
|
@@ -81,6 +85,7 @@ module Harnex
|
|
|
81
85
|
harnex stop --id ID [options]
|
|
82
86
|
harnex status [options]
|
|
83
87
|
harnex logs --id ID [options]
|
|
88
|
+
harnex events --id ID [options]
|
|
84
89
|
harnex pane --id ID [options]
|
|
85
90
|
harnex help [command]
|
|
86
91
|
|
|
@@ -91,6 +96,7 @@ module Harnex
|
|
|
91
96
|
stop Send the adapter stop sequence to a session
|
|
92
97
|
status List live sessions
|
|
93
98
|
logs Read session output transcripts
|
|
99
|
+
events Stream per-session JSONL runtime events
|
|
94
100
|
pane Capture the current tmux pane for a live session
|
|
95
101
|
recipes List and read workflow recipes
|
|
96
102
|
guide Show the getting started guide
|
|
@@ -109,6 +115,7 @@ module Harnex
|
|
|
109
115
|
harnex run codex -- --cd /path/to/repo
|
|
110
116
|
harnex status
|
|
111
117
|
harnex logs --id main --follow
|
|
118
|
+
harnex events --id main --snapshot
|
|
112
119
|
harnex pane --id main --lines 40
|
|
113
120
|
harnex send --id main --message "Summarize current progress."
|
|
114
121
|
harnex skills install
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "optparse"
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Harnex
|
|
6
|
+
class Events
|
|
7
|
+
POLL_INTERVAL = 0.1
|
|
8
|
+
|
|
9
|
+
def self.usage(program_name = "harnex events")
|
|
10
|
+
<<~TEXT
|
|
11
|
+
Usage: #{program_name} [options]
|
|
12
|
+
|
|
13
|
+
Options:
|
|
14
|
+
--id ID Session ID to inspect (required)
|
|
15
|
+
--repo PATH Resolve using PATH's repo root (default: current repo)
|
|
16
|
+
--cli CLI Filter the active session by CLI
|
|
17
|
+
--[no-]follow Keep streaming appended events (default: true)
|
|
18
|
+
--snapshot Print current events and exit (alias for --no-follow)
|
|
19
|
+
--from TS Replay floor (ISO-8601, inclusive)
|
|
20
|
+
-h, --help Show this help
|
|
21
|
+
TEXT
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(argv)
|
|
25
|
+
@argv = argv.dup
|
|
26
|
+
@options = {
|
|
27
|
+
id: nil,
|
|
28
|
+
repo_path: Dir.pwd,
|
|
29
|
+
cli: nil,
|
|
30
|
+
follow: true,
|
|
31
|
+
from: nil,
|
|
32
|
+
help: false
|
|
33
|
+
}
|
|
34
|
+
@tail_buffer = +""
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def run
|
|
38
|
+
parser.parse!(@argv)
|
|
39
|
+
if @options[:help]
|
|
40
|
+
puts self.class.usage
|
|
41
|
+
return 0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
raise "--id is required for harnex events" unless @options[:id]
|
|
45
|
+
|
|
46
|
+
target = resolve_target
|
|
47
|
+
return 1 unless target
|
|
48
|
+
|
|
49
|
+
offset = print_snapshot(target.fetch(:path))
|
|
50
|
+
return 0 unless @options[:follow] && target[:live]
|
|
51
|
+
|
|
52
|
+
follow(target.fetch(:path), offset)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def parser
|
|
58
|
+
@parser ||= OptionParser.new do |opts|
|
|
59
|
+
opts.banner = "Usage: harnex events [options]"
|
|
60
|
+
opts.on("--id ID", "Session ID to inspect") { |value| @options[:id] = Harnex.normalize_id(value) }
|
|
61
|
+
opts.on("--repo PATH", "Resolve using PATH's repo root") { |value| @options[:repo_path] = value }
|
|
62
|
+
opts.on("--cli CLI", "Filter the active session by CLI") { |value| @options[:cli] = value }
|
|
63
|
+
opts.on("--[no-]follow", "Keep streaming appended events") { |value| @options[:follow] = value }
|
|
64
|
+
opts.on("--snapshot", "Print current events and exit") { @options[:follow] = false }
|
|
65
|
+
opts.on("--from TS", "Replay floor (ISO-8601, inclusive)") { |value| @options[:from] = parse_from(value) }
|
|
66
|
+
opts.on("-h", "--help", "Show help") { @options[:help] = true }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def parse_from(value)
|
|
71
|
+
Time.iso8601(value.to_s)
|
|
72
|
+
rescue ArgumentError
|
|
73
|
+
raise OptionParser::InvalidArgument, "--from must be an ISO-8601 timestamp"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def resolve_target
|
|
77
|
+
repo_root = Harnex.resolve_repo_root(@options[:repo_path])
|
|
78
|
+
session = Harnex.read_registry(repo_root, @options[:id], cli: @options[:cli])
|
|
79
|
+
|
|
80
|
+
if session
|
|
81
|
+
path = session["events_log_path"].to_s
|
|
82
|
+
path = Harnex.events_log_path(repo_root, @options[:id]) if path.empty?
|
|
83
|
+
return event_target(path, live: true, repo_root: repo_root)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
if cli_filter_mismatch?(repo_root)
|
|
87
|
+
warn("harnex events: no active session found with id #{@options[:id].inspect} and cli #{@options[:cli].inspect}")
|
|
88
|
+
return nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
event_target(Harnex.events_log_path(repo_root, @options[:id]), live: false, repo_root: repo_root)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def cli_filter_mismatch?(repo_root)
|
|
95
|
+
return false unless @options[:cli]
|
|
96
|
+
|
|
97
|
+
session = Harnex.read_registry(repo_root, @options[:id])
|
|
98
|
+
return false unless session
|
|
99
|
+
|
|
100
|
+
Harnex.cli_key(Harnex.session_cli(session)) != Harnex.cli_key(@options[:cli])
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def event_target(path, live:, repo_root:)
|
|
104
|
+
return { path: path, live: live, repo_root: repo_root } if File.file?(path)
|
|
105
|
+
|
|
106
|
+
if live
|
|
107
|
+
warn("harnex events: stream not found at #{path}")
|
|
108
|
+
else
|
|
109
|
+
warn("harnex events: no session or event stream found with id #{@options[:id].inspect} for #{repo_root}")
|
|
110
|
+
end
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def print_snapshot(path)
|
|
115
|
+
offset = 0
|
|
116
|
+
File.open(path, "rb") do |file|
|
|
117
|
+
file.each_line do |line|
|
|
118
|
+
next unless emit_line?(line)
|
|
119
|
+
|
|
120
|
+
$stdout.write(line)
|
|
121
|
+
end
|
|
122
|
+
offset = file.pos
|
|
123
|
+
end
|
|
124
|
+
$stdout.flush
|
|
125
|
+
offset
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def follow(path, offset)
|
|
129
|
+
current_offset = offset
|
|
130
|
+
|
|
131
|
+
loop do
|
|
132
|
+
streamed = stream_growth(path, current_offset)
|
|
133
|
+
return streamed.fetch(:code) if streamed[:code]
|
|
134
|
+
|
|
135
|
+
current_offset = streamed.fetch(:offset)
|
|
136
|
+
sleep POLL_INTERVAL
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def stream_growth(path, offset)
|
|
141
|
+
unless File.file?(path)
|
|
142
|
+
warn("harnex events: stream source disappeared at #{path}")
|
|
143
|
+
return { code: 1, offset: offset }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
size = File.size(path)
|
|
147
|
+
if size < offset
|
|
148
|
+
warn("harnex events: stream source was truncated at #{path}")
|
|
149
|
+
return { code: 1, offset: size }
|
|
150
|
+
end
|
|
151
|
+
return { offset: offset } if size == offset
|
|
152
|
+
|
|
153
|
+
chunk = +""
|
|
154
|
+
File.open(path, "rb") do |file|
|
|
155
|
+
file.seek(offset)
|
|
156
|
+
chunk = file.read(size - offset).to_s
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
@tail_buffer << chunk
|
|
160
|
+
lines = @tail_buffer.split("\n", -1)
|
|
161
|
+
@tail_buffer = lines.pop || +""
|
|
162
|
+
wrote = false
|
|
163
|
+
|
|
164
|
+
lines.each do |line|
|
|
165
|
+
json_line = "#{line}\n"
|
|
166
|
+
event = parse_event(json_line)
|
|
167
|
+
if emit_line?(json_line, parsed: event)
|
|
168
|
+
$stdout.write(json_line)
|
|
169
|
+
wrote = true
|
|
170
|
+
end
|
|
171
|
+
return { code: 0, offset: size } if target_exited?(event)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
$stdout.flush if wrote
|
|
175
|
+
{ offset: size }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def emit_line?(line, parsed: nil)
|
|
179
|
+
return true unless @options[:from]
|
|
180
|
+
|
|
181
|
+
event = parsed || parse_event(line)
|
|
182
|
+
return false unless event
|
|
183
|
+
|
|
184
|
+
timestamp = event_timestamp(event)
|
|
185
|
+
return false unless timestamp
|
|
186
|
+
|
|
187
|
+
timestamp >= @options[:from]
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def parse_event(line)
|
|
191
|
+
JSON.parse(line)
|
|
192
|
+
rescue JSON::ParserError
|
|
193
|
+
nil
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def event_timestamp(event)
|
|
197
|
+
value = event["ts"].to_s
|
|
198
|
+
return nil if value.empty?
|
|
199
|
+
|
|
200
|
+
Time.iso8601(value)
|
|
201
|
+
rescue ArgumentError
|
|
202
|
+
nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def target_exited?(event)
|
|
206
|
+
return false unless event
|
|
207
|
+
return false unless event["type"] == "exited"
|
|
208
|
+
|
|
209
|
+
Harnex.id_key(event["id"].to_s) == Harnex.id_key(@options[:id])
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
data/lib/harnex/commands/run.rb
CHANGED
|
@@ -6,10 +6,12 @@ module Harnex
|
|
|
6
6
|
class Runner
|
|
7
7
|
DEFAULT_TIMEOUT = 5.0
|
|
8
8
|
KNOWN_FLAGS = %w[
|
|
9
|
-
--id --description --detach --tmux --host --port --watch --
|
|
9
|
+
--id --description --detach --tmux --host --port --watch --watch-file
|
|
10
|
+
--stall-after --max-resumes --preset --context --timeout --inbox-ttl --help
|
|
10
11
|
].freeze
|
|
11
12
|
VALUE_FLAGS = %w[
|
|
12
|
-
--id --description --host --port --watch --
|
|
13
|
+
--id --description --host --port --watch --watch-file --stall-after
|
|
14
|
+
--max-resumes --preset --context --timeout --inbox-ttl
|
|
13
15
|
].freeze
|
|
14
16
|
|
|
15
17
|
def self.usage(program_name = "harnex run")
|
|
@@ -23,13 +25,20 @@ module Harnex
|
|
|
23
25
|
--tmux [NAME] Run in a tmux window (implies --detach)
|
|
24
26
|
--host HOST Bind host for the local API (default: #{DEFAULT_HOST})
|
|
25
27
|
--port PORT Force a specific local API port
|
|
26
|
-
--watch
|
|
28
|
+
--watch Enable blocking babysitter mode (foreground only)
|
|
29
|
+
--stall-after DUR Force-resume threshold (default: #{RunWatcher::DEFAULT_STALL_AFTER_S.to_i}s)
|
|
30
|
+
--max-resumes N Max forced resumes before escalation (default: #{RunWatcher::DEFAULT_MAX_RESUMES})
|
|
31
|
+
--preset NAME Watch preset: impl, plan, gate (requires --watch)
|
|
32
|
+
--watch-file PATH Auto-send a file-change hook on modification
|
|
27
33
|
--context TEXT Inject as the initial prompt (prepends session header)
|
|
28
34
|
--timeout SECS Max seconds to wait for detached registration (default: #{DEFAULT_TIMEOUT})
|
|
29
35
|
--inbox-ttl SECS Expire queued inbox messages after SECS (default: #{Inbox::DEFAULT_TTL})
|
|
30
36
|
-h, --help Show this help
|
|
31
37
|
|
|
32
38
|
Notes:
|
|
39
|
+
Compatibility: `--watch PATH` and `--watch=PATH` still configure file-hook mode.
|
|
40
|
+
Bare `--watch` enables the babysitter.
|
|
41
|
+
Explicit --stall-after/--max-resumes values override --preset defaults.
|
|
33
42
|
CLIs with smart prompt detection: #{Adapters.known.join(', ')}
|
|
34
43
|
Any other CLI name is launched with generic wrapping.
|
|
35
44
|
Wrapper options may appear before or after <cli>.
|
|
@@ -43,6 +52,12 @@ module Harnex
|
|
|
43
52
|
description: nil,
|
|
44
53
|
host: DEFAULT_HOST,
|
|
45
54
|
port: nil,
|
|
55
|
+
watch_enabled: false,
|
|
56
|
+
stall_after_s: RunWatcher::DEFAULT_STALL_AFTER_S,
|
|
57
|
+
stall_after_explicit: false,
|
|
58
|
+
max_resumes: RunWatcher::DEFAULT_MAX_RESUMES,
|
|
59
|
+
max_resumes_explicit: false,
|
|
60
|
+
preset: nil,
|
|
46
61
|
watch: nil,
|
|
47
62
|
context: nil,
|
|
48
63
|
detach: false,
|
|
@@ -69,8 +84,12 @@ module Harnex
|
|
|
69
84
|
effective_child_args = apply_context(child_args)
|
|
70
85
|
adapter = Harnex.build_adapter(cli_name, effective_child_args)
|
|
71
86
|
@options[:detach] = true if @options[:tmux]
|
|
87
|
+
validate_watch_mode!
|
|
88
|
+
resolve_watch_preset!
|
|
72
89
|
|
|
73
|
-
if @options[:
|
|
90
|
+
if @options[:watch_enabled]
|
|
91
|
+
run_watch_mode(adapter, repo_root)
|
|
92
|
+
elsif @options[:detach]
|
|
74
93
|
run_detached(adapter, cli_name, child_args, repo_root)
|
|
75
94
|
else
|
|
76
95
|
run_foreground(adapter, repo_root)
|
|
@@ -90,10 +109,25 @@ module Harnex
|
|
|
90
109
|
if @options[:tmux]
|
|
91
110
|
run_in_tmux(cli_name, child_args, repo_root)
|
|
92
111
|
else
|
|
93
|
-
run_headless(adapter, repo_root)
|
|
112
|
+
result = run_headless(adapter, repo_root)
|
|
113
|
+
result[:exit_code]
|
|
94
114
|
end
|
|
95
115
|
end
|
|
96
116
|
|
|
117
|
+
def run_watch_mode(adapter, repo_root)
|
|
118
|
+
Session.validate_binary!(adapter.build_command)
|
|
119
|
+
|
|
120
|
+
result = run_headless(adapter, repo_root, emit_payload: false)
|
|
121
|
+
return result[:exit_code] unless result[:ok]
|
|
122
|
+
|
|
123
|
+
RunWatcher.new(
|
|
124
|
+
id: @options[:id],
|
|
125
|
+
repo_root: repo_root,
|
|
126
|
+
stall_after_s: @options[:stall_after_s],
|
|
127
|
+
max_resumes: @options[:max_resumes]
|
|
128
|
+
).run
|
|
129
|
+
end
|
|
130
|
+
|
|
97
131
|
def run_in_tmux(cli_name, child_args, repo_root)
|
|
98
132
|
harnex_bin = File.expand_path("../../../bin/harnex", __dir__)
|
|
99
133
|
tmux_cmd = [harnex_bin, "run", cli_name]
|
|
@@ -101,7 +135,7 @@ module Harnex
|
|
|
101
135
|
tmux_cmd += ["--description", @options[:description]] if @options[:description]
|
|
102
136
|
tmux_cmd += ["--host", @options[:host]]
|
|
103
137
|
tmux_cmd += ["--port", @options[:port].to_s] if @options[:port]
|
|
104
|
-
tmux_cmd += ["--watch", @options[:watch]] if @options[:watch]
|
|
138
|
+
tmux_cmd += ["--watch-file", @options[:watch]] if @options[:watch]
|
|
105
139
|
tmux_cmd += ["--context", @options[:context]] if @options[:context]
|
|
106
140
|
tmux_cmd += ["--inbox-ttl", @options[:inbox_ttl].to_s]
|
|
107
141
|
tmux_cmd += ["--"] + child_args unless child_args.empty?
|
|
@@ -137,7 +171,7 @@ module Harnex
|
|
|
137
171
|
0
|
|
138
172
|
end
|
|
139
173
|
|
|
140
|
-
def run_headless(adapter, repo_root)
|
|
174
|
+
def run_headless(adapter, repo_root, emit_payload: true)
|
|
141
175
|
log_dir = File.join(Harnex::STATE_DIR, "logs")
|
|
142
176
|
FileUtils.mkdir_p(log_dir)
|
|
143
177
|
log_path = File.join(log_dir, "#{@options[:id]}.log")
|
|
@@ -159,7 +193,7 @@ module Harnex
|
|
|
159
193
|
Process.detach(child_pid)
|
|
160
194
|
|
|
161
195
|
registry = wait_for_registration(repo_root)
|
|
162
|
-
return registration_timeout(@options[:id]) unless registry
|
|
196
|
+
return { ok: false, exit_code: registration_timeout(@options[:id]) } unless registry
|
|
163
197
|
|
|
164
198
|
payload = {
|
|
165
199
|
ok: true,
|
|
@@ -172,12 +206,19 @@ module Harnex
|
|
|
172
206
|
output_log_path: Harnex.output_log_path(repo_root, @options[:id])
|
|
173
207
|
}
|
|
174
208
|
payload[:description] = @options[:description] if @options[:description]
|
|
175
|
-
puts JSON.generate(payload)
|
|
176
|
-
0
|
|
209
|
+
puts JSON.generate(payload) if emit_payload
|
|
210
|
+
{ ok: true, exit_code: 0, registry: registry, payload: payload }
|
|
177
211
|
end
|
|
178
212
|
|
|
179
213
|
private
|
|
180
214
|
|
|
215
|
+
def validate_watch_mode!
|
|
216
|
+
return unless @options[:watch_enabled]
|
|
217
|
+
return unless @options[:detach]
|
|
218
|
+
|
|
219
|
+
raise OptionParser::InvalidOption, "--watch is only supported in foreground mode"
|
|
220
|
+
end
|
|
221
|
+
|
|
181
222
|
def validate_unique_id!(repo_root)
|
|
182
223
|
existing = Harnex.read_registry(repo_root, @options[:id])
|
|
183
224
|
return unless existing
|
|
@@ -294,10 +335,51 @@ module Harnex
|
|
|
294
335
|
when /\A--port=(.+)\z/
|
|
295
336
|
@options[:port] = Integer(required_option_value("--port", Regexp.last_match(1)))
|
|
296
337
|
when "--watch"
|
|
297
|
-
index
|
|
298
|
-
|
|
338
|
+
value = argv[index + 1]
|
|
339
|
+
if value.nil? || value == "--" || wrapper_option_token?(value)
|
|
340
|
+
@options[:watch_enabled] = true
|
|
341
|
+
else
|
|
342
|
+
index += 1
|
|
343
|
+
@options[:watch] = required_option_value(arg, argv[index])
|
|
344
|
+
end
|
|
299
345
|
when /\A--watch=(.+)\z/
|
|
300
346
|
@options[:watch] = required_option_value("--watch", Regexp.last_match(1))
|
|
347
|
+
when "--watch-file"
|
|
348
|
+
index += 1
|
|
349
|
+
@options[:watch] = required_option_value(arg, argv[index])
|
|
350
|
+
when /\A--watch-file=(.+)\z/
|
|
351
|
+
@options[:watch] = required_option_value("--watch-file", Regexp.last_match(1))
|
|
352
|
+
when "--stall-after"
|
|
353
|
+
index += 1
|
|
354
|
+
@options[:stall_after_s] = Harnex.parse_duration_seconds(
|
|
355
|
+
required_option_value(arg, argv[index]),
|
|
356
|
+
option_name: "--stall-after"
|
|
357
|
+
)
|
|
358
|
+
@options[:stall_after_explicit] = true
|
|
359
|
+
when /\A--stall-after=(.+)\z/
|
|
360
|
+
@options[:stall_after_s] = Harnex.parse_duration_seconds(
|
|
361
|
+
required_option_value("--stall-after", Regexp.last_match(1)),
|
|
362
|
+
option_name: "--stall-after"
|
|
363
|
+
)
|
|
364
|
+
@options[:stall_after_explicit] = true
|
|
365
|
+
when "--max-resumes"
|
|
366
|
+
index += 1
|
|
367
|
+
@options[:max_resumes] = parse_non_negative_integer(
|
|
368
|
+
required_option_value(arg, argv[index]),
|
|
369
|
+
option_name: "--max-resumes"
|
|
370
|
+
)
|
|
371
|
+
@options[:max_resumes_explicit] = true
|
|
372
|
+
when /\A--max-resumes=(.+)\z/
|
|
373
|
+
@options[:max_resumes] = parse_non_negative_integer(
|
|
374
|
+
required_option_value("--max-resumes", Regexp.last_match(1)),
|
|
375
|
+
option_name: "--max-resumes"
|
|
376
|
+
)
|
|
377
|
+
@options[:max_resumes_explicit] = true
|
|
378
|
+
when "--preset"
|
|
379
|
+
index += 1
|
|
380
|
+
@options[:preset] = required_option_value(arg, argv[index])
|
|
381
|
+
when /\A--preset=(.+)\z/
|
|
382
|
+
@options[:preset] = required_option_value("--preset", Regexp.last_match(1))
|
|
301
383
|
when "--context"
|
|
302
384
|
index += 1
|
|
303
385
|
@options[:context] = required_option_value(arg, argv[index])
|
|
@@ -358,7 +440,9 @@ module Harnex
|
|
|
358
440
|
nil
|
|
359
441
|
when *VALUE_FLAGS
|
|
360
442
|
index += 1
|
|
361
|
-
when /\A--(?:id|description|host|port|watch|context|timeout|inbox-ttl)=/
|
|
443
|
+
when /\A--(?:id|description|host|port|watch|watch-file|stall-after|max-resumes|context|timeout|inbox-ttl)=/
|
|
444
|
+
nil
|
|
445
|
+
when /\A--preset=/
|
|
362
446
|
nil
|
|
363
447
|
else
|
|
364
448
|
return true
|
|
@@ -372,7 +456,37 @@ module Harnex
|
|
|
372
456
|
def wrapper_option_token?(arg)
|
|
373
457
|
KNOWN_FLAGS.include?(arg) ||
|
|
374
458
|
arg == "-h" ||
|
|
375
|
-
arg.start_with?(
|
|
459
|
+
arg.start_with?(
|
|
460
|
+
"--id=", "--description=", "--tmux=", "--host=", "--port=", "--watch=", "--watch-file=",
|
|
461
|
+
"--stall-after=", "--max-resumes=", "--preset=", "--context=", "--timeout=", "--inbox-ttl="
|
|
462
|
+
)
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def resolve_watch_preset!
|
|
466
|
+
preset_name = @options[:preset]
|
|
467
|
+
return if preset_name.nil?
|
|
468
|
+
|
|
469
|
+
unless @options[:watch_enabled]
|
|
470
|
+
raise "harnex run: --preset requires --watch"
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
preset = WatchPresets.fetch(preset_name)
|
|
474
|
+
unless preset
|
|
475
|
+
valid = WatchPresets.valid_names.join(", ")
|
|
476
|
+
raise "harnex run: unknown --preset #{preset_name.inspect} (valid: #{valid})"
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
@options[:stall_after_s] = preset[:stall_after_s] unless @options[:stall_after_explicit]
|
|
480
|
+
@options[:max_resumes] = preset[:max_resumes] unless @options[:max_resumes_explicit]
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def parse_non_negative_integer(value, option_name:)
|
|
484
|
+
integer = Integer(value)
|
|
485
|
+
raise OptionParser::InvalidArgument, "#{option_name} must be 0 or greater" if integer.negative?
|
|
486
|
+
|
|
487
|
+
integer
|
|
488
|
+
rescue ArgumentError
|
|
489
|
+
raise OptionParser::InvalidArgument, "#{option_name} must be an integer"
|
|
376
490
|
end
|
|
377
491
|
|
|
378
492
|
def default_inbox_ttl
|
|
@@ -98,7 +98,7 @@ module Harnex
|
|
|
98
98
|
end
|
|
99
99
|
|
|
100
100
|
def render_table(sessions)
|
|
101
|
-
columns = ["ID", "CLI", "PID", "PORT", "AGE", "STATE", "REPO", "DESC"]
|
|
101
|
+
columns = ["ID", "CLI", "PID", "PORT", "AGE", "IDLE", "STATE", "REPO", "DESC"]
|
|
102
102
|
|
|
103
103
|
rows = sessions.map { |session| table_row(session, columns) }
|
|
104
104
|
widths = columns.to_h { |column| [column, ([column.length] + rows.map { |row| row.fetch(column).length }).max] }
|
|
@@ -117,6 +117,7 @@ module Harnex
|
|
|
117
117
|
"PID" => session["pid"].to_s,
|
|
118
118
|
"PORT" => session["port"].to_s,
|
|
119
119
|
"AGE" => timeago(session["started_at"]),
|
|
120
|
+
"IDLE" => format_idle(session["log_idle_s"]),
|
|
120
121
|
"STATE" => session.dig("input_state", "state").to_s.empty? ? "-" : session.dig("input_state", "state").to_s,
|
|
121
122
|
"DESC" => truncate(session["description"])
|
|
122
123
|
}
|
|
@@ -133,7 +134,22 @@ module Harnex
|
|
|
133
134
|
|
|
134
135
|
seconds = (Time.now - Time.parse(timestamp.to_s)).to_i
|
|
135
136
|
seconds = 0 if seconds.negative?
|
|
137
|
+
compact_duration(seconds)
|
|
138
|
+
rescue StandardError
|
|
139
|
+
timestamp.to_s
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def format_idle(idle_seconds)
|
|
143
|
+
return "-" if idle_seconds.nil?
|
|
144
|
+
|
|
145
|
+
seconds = Integer(idle_seconds)
|
|
146
|
+
seconds = 0 if seconds.negative?
|
|
147
|
+
compact_duration(seconds)
|
|
148
|
+
rescue StandardError
|
|
149
|
+
"-"
|
|
150
|
+
end
|
|
136
151
|
|
|
152
|
+
def compact_duration(seconds)
|
|
137
153
|
case seconds
|
|
138
154
|
when 0...60
|
|
139
155
|
"#{seconds}s"
|
|
@@ -144,8 +160,6 @@ module Harnex
|
|
|
144
160
|
else
|
|
145
161
|
"#{seconds / 86_400}d"
|
|
146
162
|
end
|
|
147
|
-
rescue StandardError
|
|
148
|
-
timestamp.to_s
|
|
149
163
|
end
|
|
150
164
|
|
|
151
165
|
def truncate(value)
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Harnex
|
|
6
|
+
class RunWatcher
|
|
7
|
+
DEFAULT_STALL_AFTER_S = 8 * 60.0
|
|
8
|
+
DEFAULT_MAX_RESUMES = 1
|
|
9
|
+
POLL_INTERVAL_S = 60.0
|
|
10
|
+
MAX_STATUS_ERRORS = 3
|
|
11
|
+
RESUME_TEXT = "resume"
|
|
12
|
+
|
|
13
|
+
def initialize(
|
|
14
|
+
id:,
|
|
15
|
+
repo_root:,
|
|
16
|
+
stall_after_s: DEFAULT_STALL_AFTER_S,
|
|
17
|
+
max_resumes: DEFAULT_MAX_RESUMES,
|
|
18
|
+
poll_interval_s: POLL_INTERVAL_S,
|
|
19
|
+
sleeper: nil,
|
|
20
|
+
monotonic_clock: nil,
|
|
21
|
+
out: $stdout,
|
|
22
|
+
err: $stderr
|
|
23
|
+
)
|
|
24
|
+
@id = Harnex.normalize_id(id)
|
|
25
|
+
@repo_root = repo_root
|
|
26
|
+
@stall_after_s = Float(stall_after_s)
|
|
27
|
+
@max_resumes = Integer(max_resumes)
|
|
28
|
+
@poll_interval_s = Float(poll_interval_s)
|
|
29
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
30
|
+
@monotonic_clock = monotonic_clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
31
|
+
@out = out
|
|
32
|
+
@err = err
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def run
|
|
36
|
+
polls = 0
|
|
37
|
+
resumes = 0
|
|
38
|
+
final_state = "unknown"
|
|
39
|
+
outcome = :error
|
|
40
|
+
status_errors = 0
|
|
41
|
+
start_at = now
|
|
42
|
+
|
|
43
|
+
@out.puts(
|
|
44
|
+
"harnex watch: id=#{@id} stall-after=#{format_duration(@stall_after_s)} " \
|
|
45
|
+
"max-resumes=#{@max_resumes} poll=#{format_duration(@poll_interval_s)}"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
loop do
|
|
49
|
+
polls += 1
|
|
50
|
+
snapshot = fetch_snapshot
|
|
51
|
+
|
|
52
|
+
case snapshot[:kind]
|
|
53
|
+
when :exited
|
|
54
|
+
final_state = "exited"
|
|
55
|
+
outcome = :exited
|
|
56
|
+
@out.puts("harnex watch: session exited")
|
|
57
|
+
break
|
|
58
|
+
when :error
|
|
59
|
+
if snapshot[:fatal]
|
|
60
|
+
@err.puts("harnex watch: #{snapshot[:error]}")
|
|
61
|
+
outcome = :error
|
|
62
|
+
break
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
status_errors += 1
|
|
66
|
+
if status_errors >= MAX_STATUS_ERRORS
|
|
67
|
+
@err.puts("harnex watch: #{snapshot[:error]} (status retry limit reached)")
|
|
68
|
+
outcome = :error
|
|
69
|
+
break
|
|
70
|
+
end
|
|
71
|
+
when :status
|
|
72
|
+
status_errors = 0
|
|
73
|
+
final_state = snapshot[:agent_state]
|
|
74
|
+
|
|
75
|
+
if snapshot[:stalled]
|
|
76
|
+
if resumes < @max_resumes
|
|
77
|
+
send_resume(snapshot[:registry])
|
|
78
|
+
resumes += 1
|
|
79
|
+
@out.puts(
|
|
80
|
+
"harnex watch: resume #{resumes}/#{@max_resumes} " \
|
|
81
|
+
"(idle=#{format_duration(snapshot[:idle_seconds])}, state=#{final_state})"
|
|
82
|
+
)
|
|
83
|
+
else
|
|
84
|
+
outcome = :escalated
|
|
85
|
+
@out.puts("harnex watch: max resumes reached, escalating")
|
|
86
|
+
break
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
@sleeper.call(@poll_interval_s)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
elapsed = (now - start_at).round(1)
|
|
95
|
+
@out.puts(
|
|
96
|
+
"harnex watch: summary id=#{@id} polls=#{polls} resumes=#{resumes} " \
|
|
97
|
+
"final_state=#{final_state} outcome=#{outcome} elapsed_s=#{elapsed}"
|
|
98
|
+
)
|
|
99
|
+
outcome_to_exit_code(outcome)
|
|
100
|
+
rescue StandardError => e
|
|
101
|
+
@err.puts("harnex watch: #{e.message}")
|
|
102
|
+
1
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def fetch_snapshot
|
|
108
|
+
registry = Harnex.read_registry(@repo_root, @id)
|
|
109
|
+
return { kind: :exited } unless registry
|
|
110
|
+
|
|
111
|
+
status = fetch_status(registry)
|
|
112
|
+
return status if status[:kind] == :error
|
|
113
|
+
|
|
114
|
+
payload = status[:payload]
|
|
115
|
+
unless payload.key?("log_idle_s")
|
|
116
|
+
return {
|
|
117
|
+
kind: :error,
|
|
118
|
+
fatal: true,
|
|
119
|
+
error: "status payload missing log_idle_s; upgrade to a Layer-1+ harnex build"
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
agent_state = payload["agent_state"].to_s.strip
|
|
124
|
+
return { kind: :exited } if agent_state == "exited"
|
|
125
|
+
|
|
126
|
+
idle_seconds = parse_idle_seconds(payload["log_idle_s"])
|
|
127
|
+
{
|
|
128
|
+
kind: :status,
|
|
129
|
+
registry: registry,
|
|
130
|
+
agent_state: agent_state.empty? ? "unknown" : agent_state,
|
|
131
|
+
idle_seconds: idle_seconds,
|
|
132
|
+
stalled: !idle_seconds.nil? && idle_seconds >= @stall_after_s
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def fetch_status(registry)
|
|
137
|
+
uri = URI("http://#{registry.fetch('host')}:#{registry.fetch('port')}/status")
|
|
138
|
+
request = Net::HTTP::Get.new(uri)
|
|
139
|
+
request["Authorization"] = "Bearer #{registry['token']}" if registry["token"]
|
|
140
|
+
|
|
141
|
+
response = Net::HTTP.start(uri.host, uri.port, open_timeout: 1, read_timeout: 1) do |http|
|
|
142
|
+
http.request(request)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
146
|
+
return { kind: :error, error: "status request failed with HTTP #{response.code} for session #{@id}" }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
{ kind: :status_payload, payload: JSON.parse(response.body) }
|
|
150
|
+
rescue StandardError => e
|
|
151
|
+
{ kind: :error, error: "status request failed for session #{@id}: #{e.message}" }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def send_resume(registry)
|
|
155
|
+
uri = URI("http://#{registry.fetch('host')}:#{registry.fetch('port')}/send")
|
|
156
|
+
request = Net::HTTP::Post.new(uri)
|
|
157
|
+
request["Authorization"] = "Bearer #{registry['token']}" if registry["token"]
|
|
158
|
+
request["Content-Type"] = "application/json"
|
|
159
|
+
request.body = JSON.generate(
|
|
160
|
+
text: RESUME_TEXT,
|
|
161
|
+
submit: true,
|
|
162
|
+
enter_only: false,
|
|
163
|
+
force: true
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
response = Net::HTTP.start(uri.host, uri.port, open_timeout: 1, read_timeout: 1) do |http|
|
|
167
|
+
http.request(request)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
return if response.is_a?(Net::HTTPSuccess)
|
|
171
|
+
|
|
172
|
+
raise "resume send failed with HTTP #{response.code} for session #{@id}"
|
|
173
|
+
rescue StandardError => e
|
|
174
|
+
raise "resume send failed for session #{@id}: #{e.message}"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def parse_idle_seconds(value)
|
|
178
|
+
return nil if value.nil?
|
|
179
|
+
|
|
180
|
+
seconds = Integer(value)
|
|
181
|
+
seconds.negative? ? 0 : seconds
|
|
182
|
+
rescue StandardError
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def outcome_to_exit_code(outcome)
|
|
187
|
+
case outcome
|
|
188
|
+
when :exited
|
|
189
|
+
0
|
|
190
|
+
when :escalated
|
|
191
|
+
2
|
|
192
|
+
else
|
|
193
|
+
1
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def format_duration(seconds)
|
|
198
|
+
value = seconds.to_f
|
|
199
|
+
return "#{value.round(1)}s" if value < 60
|
|
200
|
+
return "#{(value / 60).round(1)}m" if value < 3600
|
|
201
|
+
|
|
202
|
+
"#{(value / 3600).round(1)}h"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def now
|
|
206
|
+
@monotonic_clock.call
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Harnex
|
|
2
|
+
module WatchPresets
|
|
3
|
+
TABLE = {
|
|
4
|
+
"impl" => { stall_after_s: 8 * 60.0, max_resumes: 1 }.freeze,
|
|
5
|
+
"plan" => { stall_after_s: 3 * 60.0, max_resumes: 2 }.freeze,
|
|
6
|
+
"gate" => { stall_after_s: 15 * 60.0, max_resumes: 0 }.freeze
|
|
7
|
+
}.freeze
|
|
8
|
+
|
|
9
|
+
def self.fetch(name)
|
|
10
|
+
TABLE[name]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.valid_names
|
|
14
|
+
TABLE.keys
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/harnex/core.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "digest"
|
|
2
2
|
require "fileutils"
|
|
3
|
+
require "optparse"
|
|
3
4
|
require "securerandom"
|
|
4
5
|
require "set"
|
|
5
6
|
require "socket"
|
|
@@ -37,6 +38,32 @@ module Harnex
|
|
|
37
38
|
File.expand_path(path)
|
|
38
39
|
end
|
|
39
40
|
|
|
41
|
+
def parse_duration_seconds(value, option_name:)
|
|
42
|
+
text = value.to_s.strip
|
|
43
|
+
raise OptionParser::InvalidArgument, "#{option_name} requires a value" if text.empty?
|
|
44
|
+
|
|
45
|
+
match = text.match(/\A([0-9]+(?:\.[0-9]+)?)([smhSMH]?)\z/)
|
|
46
|
+
unless match
|
|
47
|
+
raise OptionParser::InvalidArgument,
|
|
48
|
+
"#{option_name} must be a positive duration (examples: 30, 30s, 5m, 2h)"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
amount = Float(match[1])
|
|
52
|
+
multiplier =
|
|
53
|
+
case match[2].downcase
|
|
54
|
+
when "", "s" then 1.0
|
|
55
|
+
when "m" then 60.0
|
|
56
|
+
when "h" then 3600.0
|
|
57
|
+
else
|
|
58
|
+
raise OptionParser::InvalidArgument, "#{option_name} has an unsupported duration suffix"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
seconds = amount * multiplier
|
|
62
|
+
raise OptionParser::InvalidArgument, "#{option_name} must be greater than 0" if seconds <= 0.0
|
|
63
|
+
|
|
64
|
+
seconds
|
|
65
|
+
end
|
|
66
|
+
|
|
40
67
|
def repo_key(repo_root)
|
|
41
68
|
Digest::SHA256.hexdigest(repo_root)[0, 16]
|
|
42
69
|
end
|
|
@@ -113,6 +140,12 @@ module Harnex
|
|
|
113
140
|
File.join(output_dir, "#{session_file_slug(repo_root, id)}.log")
|
|
114
141
|
end
|
|
115
142
|
|
|
143
|
+
def events_log_path(repo_root, id)
|
|
144
|
+
events_dir = File.join(STATE_DIR, "events")
|
|
145
|
+
FileUtils.mkdir_p(events_dir)
|
|
146
|
+
File.join(events_dir, "#{session_file_slug(repo_root, id)}.jsonl")
|
|
147
|
+
end
|
|
148
|
+
|
|
116
149
|
def session_file_slug(repo_root, id)
|
|
117
150
|
slug = id_key(id)
|
|
118
151
|
slug = "default" if slug.empty?
|
|
@@ -6,7 +6,7 @@ module Harnex
|
|
|
6
6
|
class Session
|
|
7
7
|
OUTPUT_BUFFER_LIMIT = 64 * 1024
|
|
8
8
|
|
|
9
|
-
attr_reader :repo_root, :host, :port, :session_id, :token, :command, :pid, :id, :adapter, :watch, :inbox, :description, :output_log_path
|
|
9
|
+
attr_reader :repo_root, :host, :port, :session_id, :token, :command, :pid, :id, :adapter, :watch, :inbox, :description, :output_log_path, :events_log_path
|
|
10
10
|
|
|
11
11
|
def initialize(adapter:, command:, repo_root:, host:, port: nil, id: DEFAULT_ID, watch: nil, description: nil, inbox_ttl: Inbox::DEFAULT_TTL)
|
|
12
12
|
@adapter = adapter
|
|
@@ -19,17 +19,21 @@ module Harnex
|
|
|
19
19
|
@description = nil if @description.empty?
|
|
20
20
|
@registry_path = Harnex.registry_path(repo_root, @id)
|
|
21
21
|
@output_log_path = Harnex.output_log_path(repo_root, @id)
|
|
22
|
+
@events_log_path = Harnex.events_log_path(repo_root, @id)
|
|
22
23
|
@session_id = SecureRandom.hex(8)
|
|
23
24
|
@token = SecureRandom.hex(16)
|
|
24
25
|
@port = Harnex.allocate_port(repo_root, @id, port, host: host)
|
|
25
26
|
@mutex = Mutex.new
|
|
26
27
|
@inject_mutex = Mutex.new
|
|
28
|
+
@events_mutex = Mutex.new
|
|
27
29
|
@injected_count = 0
|
|
28
30
|
@last_injected_at = nil
|
|
29
31
|
@started_at = Time.now
|
|
30
32
|
@server = nil
|
|
31
33
|
@reader = nil
|
|
32
34
|
@output_log = nil
|
|
35
|
+
@events_log = nil
|
|
36
|
+
@events_log_seq = 0
|
|
33
37
|
@writer = nil
|
|
34
38
|
@pid = nil
|
|
35
39
|
@term_signal = nil
|
|
@@ -60,8 +64,10 @@ module Harnex
|
|
|
60
64
|
def run(validate_binary: true)
|
|
61
65
|
validate_binary! if validate_binary
|
|
62
66
|
prepare_output_log
|
|
67
|
+
prepare_events_log
|
|
63
68
|
@reader, @writer, @pid = PTY.spawn(child_env, *command)
|
|
64
69
|
@writer.sync = true
|
|
70
|
+
emit_event("started", pid: @pid)
|
|
65
71
|
|
|
66
72
|
install_signal_handlers
|
|
67
73
|
sync_window_size
|
|
@@ -78,6 +84,7 @@ module Harnex
|
|
|
78
84
|
_, status = Process.wait2(pid)
|
|
79
85
|
@term_signal = status.signaled? ? status.termsig : nil
|
|
80
86
|
@exit_code = status.exited? ? status.exitstatus : 128 + status.termsig
|
|
87
|
+
emit_exit_event
|
|
81
88
|
|
|
82
89
|
output_thread.join(1)
|
|
83
90
|
input_thread&.kill
|
|
@@ -91,6 +98,7 @@ module Harnex
|
|
|
91
98
|
cleanup_registry
|
|
92
99
|
@reader&.close unless @reader&.closed?
|
|
93
100
|
@output_log&.close unless @output_log&.closed?
|
|
101
|
+
@events_log&.close unless @events_log&.closed?
|
|
94
102
|
@writer&.close unless @writer&.closed?
|
|
95
103
|
end
|
|
96
104
|
|
|
@@ -109,8 +117,10 @@ module Harnex
|
|
|
109
117
|
started_at: @started_at.iso8601,
|
|
110
118
|
last_injected_at: @last_injected_at&.iso8601,
|
|
111
119
|
injected_count: @injected_count,
|
|
112
|
-
output_log_path: output_log_path
|
|
120
|
+
output_log_path: output_log_path,
|
|
121
|
+
events_log_path: events_log_path
|
|
113
122
|
}
|
|
123
|
+
payload.merge!(log_activity_snapshot)
|
|
114
124
|
payload[:description] = description if description
|
|
115
125
|
|
|
116
126
|
if watch
|
|
@@ -168,6 +178,7 @@ module Harnex
|
|
|
168
178
|
input_state: payload[:input_state],
|
|
169
179
|
force: payload[:force]
|
|
170
180
|
)
|
|
181
|
+
.tap { emit_send_event(text, force: payload[:force]) }
|
|
171
182
|
end
|
|
172
183
|
|
|
173
184
|
def sync_window_size
|
|
@@ -327,6 +338,14 @@ module Harnex
|
|
|
327
338
|
@output_log_failed = false
|
|
328
339
|
end
|
|
329
340
|
|
|
341
|
+
def prepare_events_log
|
|
342
|
+
@events_log&.close unless @events_log&.closed?
|
|
343
|
+
@events_log = File.open(events_log_path, "ab")
|
|
344
|
+
@events_log.sync = true
|
|
345
|
+
@events_log_failed = false
|
|
346
|
+
@events_log_seq = 0
|
|
347
|
+
end
|
|
348
|
+
|
|
330
349
|
def install_signal_handlers
|
|
331
350
|
%w[INT TERM HUP QUIT].each do |signal_name|
|
|
332
351
|
Signal.trap(signal_name) { forward_signal(signal_name) }
|
|
@@ -364,6 +383,60 @@ module Harnex
|
|
|
364
383
|
warn("harnex: failed to write output log #{output_log_path}: #{e.message}")
|
|
365
384
|
end
|
|
366
385
|
|
|
386
|
+
def emit_send_event(text, force:)
|
|
387
|
+
compact = text.to_s
|
|
388
|
+
truncated = compact.length > 200
|
|
389
|
+
preview = truncated ? "#{compact[0, 200]}…" : compact
|
|
390
|
+
emit_event("send", msg: preview, msg_truncated: truncated, forced: !!force)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def emit_exit_event
|
|
394
|
+
payload = { code: @exit_code }
|
|
395
|
+
payload[:signal] = @term_signal if @term_signal
|
|
396
|
+
emit_event("exited", **payload)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def emit_event(type, **payload)
|
|
400
|
+
@events_mutex.synchronize do
|
|
401
|
+
return unless @events_log
|
|
402
|
+
|
|
403
|
+
@events_log_seq += 1
|
|
404
|
+
event = {
|
|
405
|
+
schema_version: 1,
|
|
406
|
+
seq: @events_log_seq,
|
|
407
|
+
ts: Time.now.utc.iso8601,
|
|
408
|
+
id: id,
|
|
409
|
+
type: type
|
|
410
|
+
}.merge(payload)
|
|
411
|
+
@events_log.write(JSON.generate(event))
|
|
412
|
+
@events_log.write("\n")
|
|
413
|
+
@events_log.flush
|
|
414
|
+
end
|
|
415
|
+
rescue StandardError => e
|
|
416
|
+
return if defined?(@events_log_failed) && @events_log_failed
|
|
417
|
+
|
|
418
|
+
@events_log_failed = true
|
|
419
|
+
warn("harnex: failed to write events log #{events_log_path}: #{e.message}")
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def log_activity_snapshot
|
|
423
|
+
return { log_mtime: nil, log_idle_s: nil } unless File.file?(output_log_path)
|
|
424
|
+
return { log_mtime: nil, log_idle_s: nil } if File.size?(output_log_path).nil?
|
|
425
|
+
|
|
426
|
+
mtime = File.mtime(output_log_path)
|
|
427
|
+
idle_seconds = (Time.now - mtime).to_i
|
|
428
|
+
idle_seconds = 0 if idle_seconds.negative?
|
|
429
|
+
{
|
|
430
|
+
log_mtime: mtime.iso8601,
|
|
431
|
+
log_idle_s: idle_seconds
|
|
432
|
+
}
|
|
433
|
+
rescue StandardError
|
|
434
|
+
{
|
|
435
|
+
log_mtime: nil,
|
|
436
|
+
log_idle_s: nil
|
|
437
|
+
}
|
|
438
|
+
end
|
|
439
|
+
|
|
367
440
|
def screen_snapshot
|
|
368
441
|
@mutex.synchronize { @output_buffer.dup }
|
|
369
442
|
end
|
data/lib/harnex/version.rb
CHANGED
data/lib/harnex.rb
CHANGED
|
@@ -12,12 +12,15 @@ require_relative "harnex/runtime/inbox"
|
|
|
12
12
|
require_relative "harnex/runtime/file_change_hook"
|
|
13
13
|
require_relative "harnex/runtime/api_server"
|
|
14
14
|
require_relative "harnex/runtime/session"
|
|
15
|
+
require_relative "harnex/commands/watch"
|
|
16
|
+
require_relative "harnex/commands/watch_presets"
|
|
15
17
|
require_relative "harnex/commands/run"
|
|
16
18
|
require_relative "harnex/commands/send"
|
|
17
19
|
require_relative "harnex/commands/wait"
|
|
18
20
|
require_relative "harnex/commands/stop"
|
|
19
21
|
require_relative "harnex/commands/status"
|
|
20
22
|
require_relative "harnex/commands/logs"
|
|
23
|
+
require_relative "harnex/commands/events"
|
|
21
24
|
require_relative "harnex/commands/pane"
|
|
22
25
|
require_relative "harnex/commands/recipes"
|
|
23
26
|
require_relative "harnex/commands/guide"
|
|
@@ -8,6 +8,11 @@ description: Spawn an accountability partner for long-running harnex sessions. U
|
|
|
8
8
|
For any long-running or unattended work, spawn a **buddy** — a second harnex
|
|
9
9
|
agent that watches the worker and nudges it if it stalls.
|
|
10
10
|
|
|
11
|
+
For plain stall recovery (force-resume on inactivity), prefer
|
|
12
|
+
`harnex run --watch --preset impl`. Use a buddy when you need reasoning that
|
|
13
|
+
policy checks cannot provide (doc drift, semantic checks, multi-session
|
|
14
|
+
correlation).
|
|
15
|
+
|
|
11
16
|
The buddy is an LLM, so it has intelligence for free. It reads the worker's
|
|
12
17
|
screen, reasons about whether it's stuck, and composes a meaningful nudge.
|
|
13
18
|
|
|
@@ -119,11 +119,27 @@ harnex run codex --id cx-impl-NN --tmux cx-impl-NN \
|
|
|
119
119
|
--context "Read and execute /tmp/task-impl-NN.md"
|
|
120
120
|
```
|
|
121
121
|
|
|
122
|
+
### Built-in monitoring (`--watch`)
|
|
123
|
+
|
|
124
|
+
For unattended implementation runs where you only need stall policy (not
|
|
125
|
+
Claude-side reasoning), bundle dispatch and monitoring in one command:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
harnex run codex --id cx-impl-42 --tmux cx-impl-42 --watch --preset impl
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`--preset impl` applies the standard 8m stall threshold with one forced resume.
|
|
132
|
+
Trade-off: `--watch` is foreground-blocking and policy-only (`stall-after` +
|
|
133
|
+
`max-resumes`). Use pane polling (and buddy when needed) for richer reasoning.
|
|
134
|
+
|
|
122
135
|
## 2. Watch
|
|
123
136
|
|
|
124
137
|
Poll the agent's screen with `harnex pane`. Checking is cheap — a 20-line
|
|
125
138
|
tail is a few hundred bytes.
|
|
126
139
|
|
|
140
|
+
For structured orchestration, prefer `harnex events --id <id>` over pane-text
|
|
141
|
+
scraping.
|
|
142
|
+
|
|
127
143
|
**Default: poll every 30 seconds.** This is fine for most work. The check
|
|
128
144
|
itself costs almost nothing and catches completion quickly.
|
|
129
145
|
|
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.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jikku Jose
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-04-
|
|
11
|
+
date: 2026-04-29 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: A local PTY harness that wraps terminal AI agents (Claude, Codex) and
|
|
14
14
|
adds a control plane for discovery, messaging, and coordination.
|
|
@@ -32,6 +32,7 @@ files:
|
|
|
32
32
|
- lib/harnex/adapters/codex.rb
|
|
33
33
|
- lib/harnex/adapters/generic.rb
|
|
34
34
|
- lib/harnex/cli.rb
|
|
35
|
+
- lib/harnex/commands/events.rb
|
|
35
36
|
- lib/harnex/commands/guide.rb
|
|
36
37
|
- lib/harnex/commands/logs.rb
|
|
37
38
|
- lib/harnex/commands/pane.rb
|
|
@@ -42,6 +43,8 @@ files:
|
|
|
42
43
|
- lib/harnex/commands/status.rb
|
|
43
44
|
- lib/harnex/commands/stop.rb
|
|
44
45
|
- lib/harnex/commands/wait.rb
|
|
46
|
+
- lib/harnex/commands/watch.rb
|
|
47
|
+
- lib/harnex/commands/watch_presets.rb
|
|
45
48
|
- lib/harnex/core.rb
|
|
46
49
|
- lib/harnex/runtime/api_server.rb
|
|
47
50
|
- lib/harnex/runtime/file_change_hook.rb
|