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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a9186e91db61d509bafd0c44d8ad023ed208614ad8cbfe749d3b49b0bbf10772
4
- data.tar.gz: 151a5ad5cda6dc92256a7f2f604249858c04666c23a5a6ce85c9347dbf4640f4
3
+ metadata.gz: 973228979e65843573a7dc67bc91566a22478ca762f3751babdbd269e0960ca9
4
+ data.tar.gz: c4b09f84c60c2bfb225294d56c4bfa4abded7ced7ecc75fe830ecc8987e239ee
5
5
  SHA512:
6
- metadata.gz: 8f6a6ef440d4404df3868ddc1e416032a09a1b59eb36e9d9a9317f214939677a5c58a724ec8cebd10bf88a7d5d1f412671f724be0957dd54c938b54cf06a1c44
7
- data.tar.gz: 6dd4dae6d026e1167dcb7225188f7b5e73fd9fbfc4fde1625e1988daf4fb4922b2e8a477e5f142c68aad6032a806512fc6506ab0ad05eb71aed144627965e137
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 route: every route refuses it per-route
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 2 route(s):
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
- - One result line per route; a route's success never hides another route's
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?(TOKEN_PATTERN)
52
- raise InvalidAddress, "address panel '#{panel}' is not a valid token"
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
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Hitl
5
- VERSION = "0.23.1"
5
+ VERSION = "0.23.2"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-hitl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.23.1
4
+ version: 0.23.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz