ace-hitl 0.23.1 → 0.23.2
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 +16 -0
- data/docs/usage.md +14 -4
- data/lib/ace/hitl/atoms/hitl_address.rb +3 -2
- data/lib/ace/hitl/molecules/hitl_route_loader.rb +30 -0
- data/lib/ace/hitl/organisms/hitl_manager.rb +22 -1
- data/lib/ace/hitl/organisms/hitl_sender.rb +1 -0
- data/lib/ace/hitl/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 973228979e65843573a7dc67bc91566a22478ca762f3751babdbd269e0960ca9
|
|
4
|
+
data.tar.gz: c4b09f84c60c2bfb225294d56c4bfa4abded7ced7ecc75fe830ecc8987e239ee
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f08442f7582db2b088cbaa5f0af24d9d81bc7caccdf5e2bdfc9de5feb6a9ee9f5125f541f02e9585f4921933fa08a712fe47e80e8c668dd89330084654e13984
|
|
7
|
+
data.tar.gz: c3ad787393cc3c045efda84016b9e2783fa8fb5f3696e4a9da9e072840dfb99e12f706c79bafc6b655f90f4ccd35f661d0f3b05932a96d00d17ed563a63b27c1
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.23.2] - 2026-10-20
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Select addressed-event routes by configured recipient names resolved to the
|
|
15
|
+
exact stored address. Unrelated terminal/Telegram routes no longer send or
|
|
16
|
+
create false delivery failures. Zero matches fail before adapter IO.
|
|
17
|
+
**Configuration migration:** add `recipients: [name]` to each addressed route;
|
|
18
|
+
names must exist in `hitl.recipients`. Recipient-less local events retain their
|
|
19
|
+
explicitly configured route set.
|
|
20
|
+
- Accept native Herdr panel handles such as `w7:p1` without changing machine or
|
|
21
|
+
folder validation.
|
|
22
|
+
- Provide first-answer-wins `HitlManager#accept_answer` for native transport
|
|
23
|
+
imports; exact replays preserve bytes and conflicting answers cannot replace
|
|
24
|
+
an existing answer under the case write lock.
|
|
25
|
+
|
|
10
26
|
## [0.23.1] - 2026-10-18
|
|
11
27
|
|
|
12
28
|
### Fixed
|
data/docs/usage.md
CHANGED
|
@@ -88,7 +88,7 @@ ace-hitl create "Blocked on auth" --kind decision --to overseer --task 8xg.t.sbz
|
|
|
88
88
|
original case and the right agent — task/assignment/step are context,
|
|
89
89
|
never an ID substitute.
|
|
90
90
|
- Route delivery (`send`) requires a panel-form recipient. A folder-form
|
|
91
|
-
recipient has no shipped delivery
|
|
91
|
+
recipient has no shipped delivery adapter: selected adapters refuse it per-route
|
|
92
92
|
with a clear `failed` reason (the event file already sitting in a folder
|
|
93
93
|
is a local write, not a delivery confirmation). Refusal semantics are
|
|
94
94
|
strict on both axes of the address: the terminal adapter delivers only
|
|
@@ -159,11 +159,13 @@ hitl:
|
|
|
159
159
|
adapters:
|
|
160
160
|
routes:
|
|
161
161
|
- route: overseer-terminal # display name; defaults to the adapter name
|
|
162
|
+
recipients: [overseer] # configured names resolved to the stored destination
|
|
162
163
|
adapter: terminal # built-in: any ace-runtime terminal
|
|
163
164
|
runtime: herdr # tmux | herdr
|
|
164
165
|
pane: p5 # opaque pane handle (runtime-owned)
|
|
165
166
|
machine: lab01 # defaults to hitl.machine; recipient machine must match
|
|
166
167
|
- route: captain-telegram
|
|
168
|
+
recipients: [captain]
|
|
167
169
|
adapter: hermes # ships in ace-hitl-hermes
|
|
168
170
|
channel: project
|
|
169
171
|
machine: lab01 # defaults to hitl.machine
|
|
@@ -176,14 +178,20 @@ ace-hitl send 8xhkep
|
|
|
176
178
|
```
|
|
177
179
|
|
|
178
180
|
```
|
|
179
|
-
HITL event 8xhkep sent through
|
|
181
|
+
HITL event 8xhkep sent through 1 route(s):
|
|
180
182
|
overseer-terminal (terminal): delivered - text pushed to herdr pane p5
|
|
181
|
-
captain-telegram (hermes): unavailable - ...
|
|
182
183
|
```
|
|
183
184
|
|
|
184
185
|
Rules:
|
|
185
186
|
|
|
186
|
-
-
|
|
187
|
+
- For an addressed event, only routes whose `recipients` resolve to its exact
|
|
188
|
+
stored machine and panel/folder are selected. Changing a configured alias does
|
|
189
|
+
not redirect an existing case. Missing selectors do not match addressed events;
|
|
190
|
+
no match fails before adapter invocation or delivery-state writes. Recipient-less
|
|
191
|
+
events use the explicitly configured route set.
|
|
192
|
+
- Several matching routes deliver to the same intended recipient independently.
|
|
193
|
+
Unmatched routes are not invoked or recorded as failures. One result line per
|
|
194
|
+
selected route; a route's success never hides another selected route's
|
|
187
195
|
failure. Exit 0 only when every route is `delivered`; exit 1 on any
|
|
188
196
|
`failed`/`unavailable` route, no configured routes (concrete error naming
|
|
189
197
|
`hitl.adapters.routes`), or unknown/ambiguous ref. An unknown adapter on
|
|
@@ -199,6 +207,8 @@ Rules:
|
|
|
199
207
|
on questions only, task/assignment/step context.
|
|
200
208
|
- `delivered` means accepted by the route's transport. An unavailable
|
|
201
209
|
adapter never reroutes to another recipient.
|
|
210
|
+
- Native terminal handles such as `w7:p1` are accepted unchanged as `panel`.
|
|
211
|
+
This does not relax the machine-token or folder-traversal rules.
|
|
202
212
|
- Adding an adapter requires no ace-hitl change: a gem ships
|
|
203
213
|
`lib/ace/hitl/adapters/<name>.rb` calling `Ace::Hitl::Adapters.register`
|
|
204
214
|
(duck contract: `factory.call(route)` → adapter with
|
|
@@ -11,6 +11,7 @@ module Ace
|
|
|
11
11
|
# Display strings align with the hermes <machine>/<name>/<id> shape.
|
|
12
12
|
class HitlAddress
|
|
13
13
|
TOKEN_PATTERN = /\A[a-z0-9][a-z0-9_-]*\z/i.freeze
|
|
14
|
+
PANEL_PATTERN = /\A[a-z0-9][a-z0-9_:-]{0,127}\z/i.freeze
|
|
14
15
|
|
|
15
16
|
class InvalidAddress < StandardError; end
|
|
16
17
|
|
|
@@ -48,8 +49,8 @@ module Ace
|
|
|
48
49
|
address["id"] = id.to_s
|
|
49
50
|
end
|
|
50
51
|
else
|
|
51
|
-
unless panel.to_s.match?(
|
|
52
|
-
raise InvalidAddress, "address panel '#{panel}' is not a valid
|
|
52
|
+
unless panel.to_s.match?(PANEL_PATTERN)
|
|
53
|
+
raise InvalidAddress, "address panel '#{panel}' is not a valid terminal handle"
|
|
53
54
|
end
|
|
54
55
|
|
|
55
56
|
address["panel"] = panel.to_s
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "hitl_address_resolver"
|
|
4
|
+
|
|
3
5
|
module Ace
|
|
4
6
|
module Hitl
|
|
5
7
|
module Molecules
|
|
@@ -64,9 +66,37 @@ module Ace
|
|
|
64
66
|
|
|
65
67
|
normalized["route"] = route
|
|
66
68
|
normalized["adapter"] = adapter
|
|
69
|
+
if normalized.key?("recipients") &&
|
|
70
|
+
(!normalized["recipients"].is_a?(Array) || normalized["recipients"].empty? ||
|
|
71
|
+
!normalized["recipients"].all? { |name| name.is_a?(String) && !name.strip.empty? })
|
|
72
|
+
raise InvalidRouteError, "route '#{route}' recipients must be a non-empty list of configured recipient names"
|
|
73
|
+
end
|
|
67
74
|
normalized
|
|
68
75
|
end
|
|
69
76
|
|
|
77
|
+
# Selection happens before adapter construction or delivery-state writes.
|
|
78
|
+
# Names are configuration references, not labels copied onto an event:
|
|
79
|
+
# compare their resolved addresses to the event's immutable destination.
|
|
80
|
+
def self.for_recipient(routes, recipient:, config:)
|
|
81
|
+
return routes if recipient.nil?
|
|
82
|
+
|
|
83
|
+
resolver = HitlAddressResolver.new(config: config)
|
|
84
|
+
destination = recipient.reject { |key, _| key == "id" }
|
|
85
|
+
selected = routes.select do |route|
|
|
86
|
+
addresses = Array(route["recipients"]).map do |name|
|
|
87
|
+
resolver.resolve(name).reject { |key, _| key == "id" }
|
|
88
|
+
end
|
|
89
|
+
addresses.include?(destination)
|
|
90
|
+
end
|
|
91
|
+
if selected.empty?
|
|
92
|
+
raise NoRoutesError, "no adapter route matches recipient #{Atoms::HitlAddress.to_s(recipient)}; " \
|
|
93
|
+
"set route recipients to configured recipient names in #{CONFIG_LOCATION}"
|
|
94
|
+
end
|
|
95
|
+
selected
|
|
96
|
+
rescue HitlAddressResolver::UnknownRecipientError, HitlAddressResolver::InvalidAddressError => e
|
|
97
|
+
raise InvalidRouteError, e.message
|
|
98
|
+
end
|
|
99
|
+
|
|
70
100
|
def self.stringified(entry)
|
|
71
101
|
entry.each_with_object({}) do |(key, value), memo|
|
|
72
102
|
memo[key.to_s] = value
|
|
@@ -27,6 +27,7 @@ module Ace
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
class NotAnswerableError < StandardError; end
|
|
30
|
+
class AnswerConflictError < StandardError; end
|
|
30
31
|
|
|
31
32
|
attr_reader :root_dir, :last_list_total, :last_folder_counts, :config
|
|
32
33
|
|
|
@@ -153,6 +154,21 @@ module Ace
|
|
|
153
154
|
loader.load(current_path, id: event.id, special_folder: current_special)
|
|
154
155
|
end
|
|
155
156
|
|
|
157
|
+
# Transport imports are first-answer-wins. The case's own write lock
|
|
158
|
+
# checks the answer again, so replay cannot overwrite a human answer.
|
|
159
|
+
# Explicit `update --answer` remains an intentional editing operation.
|
|
160
|
+
def accept_answer(ref, answer:, scope: nil)
|
|
161
|
+
resolved = resolve_for_mutation(ref, scope: scope)
|
|
162
|
+
return nil unless resolved
|
|
163
|
+
event = resolved[:event]
|
|
164
|
+
raise NotAnswerableError, "reports do not await an answer" if event.report?
|
|
165
|
+
raise NotAnswerableError, "managed requests require their authenticated lifecycle" if event.metadata["lab_request_id"]
|
|
166
|
+
raise ArgumentError, "answer is empty" if answer.to_s.strip.empty?
|
|
167
|
+
apply_field_and_answer_updates(event.file_path, set: {}, add: {}, remove: {},
|
|
168
|
+
answer: answer, first_answer: true)
|
|
169
|
+
Molecules::HitlLoader.new.load(event.path, id: event.id, special_folder: event.special_folder)
|
|
170
|
+
end
|
|
171
|
+
|
|
156
172
|
def wait_for_answer(ref, scope: nil, poll_every: 600, timeout: 14_400, waiter: {}, now_proc: nil, sleeper: nil)
|
|
157
173
|
poll_every = normalize_poll_seconds(poll_every)
|
|
158
174
|
timeout = normalize_timeout_seconds(timeout)
|
|
@@ -408,7 +424,7 @@ module Ace
|
|
|
408
424
|
File.unlink(tmp_path) if tmp_path && File.exist?(tmp_path)
|
|
409
425
|
end
|
|
410
426
|
|
|
411
|
-
def apply_field_and_answer_updates(file_path, set:, add:, remove:, answer:)
|
|
427
|
+
def apply_field_and_answer_updates(file_path, set:, add:, remove:, answer:, first_answer: false)
|
|
412
428
|
File.open(file_path, File::RDWR) do |file|
|
|
413
429
|
file.flock(File::LOCK_EX)
|
|
414
430
|
file.rewind
|
|
@@ -416,6 +432,11 @@ module Ace
|
|
|
416
432
|
content = file.read
|
|
417
433
|
frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
|
|
418
434
|
body = body.sub(/\A\n/, "")
|
|
435
|
+
previous = body.match(/^## Answer[ \t]*(?:\n(.*))?\z/m)&.[](1).to_s.strip
|
|
436
|
+
if first_answer && (frontmatter["answered"] == true || !previous.empty?)
|
|
437
|
+
raise AnswerConflictError, "case already has a different answer" unless previous == answer.to_s.strip
|
|
438
|
+
return
|
|
439
|
+
end
|
|
419
440
|
|
|
420
441
|
Ace::Support::Items::Molecules::FieldUpdater.apply_set(frontmatter, set)
|
|
421
442
|
Ace::Support::Items::Molecules::FieldUpdater.apply_add(frontmatter, add)
|
|
@@ -57,6 +57,7 @@ module Ace
|
|
|
57
57
|
raise Adapters::Error, "HITL event '#{ref}' not found" unless current
|
|
58
58
|
|
|
59
59
|
event = current[:event]
|
|
60
|
+
routes = Molecules::HitlRouteLoader.for_recipient(routes, recipient: event.recipient, config: @config)
|
|
60
61
|
text = Atoms::HitlEventText.call(event)
|
|
61
62
|
delivery = event.metadata["delivery"]
|
|
62
63
|
|
data/lib/ace/hitl/version.rb
CHANGED