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