ace-hitl 0.8.10 → 0.9.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 +6 -0
- data/README.md +2 -0
- data/docs/usage.md +48 -0
- data/lib/ace/hitl/atoms/hitl_effect_validator.rb +117 -0
- data/lib/ace/hitl/cli/commands/ask.rb +109 -0
- data/lib/ace/hitl/cli/commands/wait.rb +12 -0
- data/lib/ace/hitl/cli.rb +4 -0
- data/lib/ace/hitl/molecules/lab_projection_observer.rb +89 -0
- data/lib/ace/hitl/molecules/lab_request_submitter.rb +82 -0
- data/lib/ace/hitl/organisms/hitl_manager.rb +51 -4
- data/lib/ace/hitl/version.rb +1 -1
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6c36ea0129ebd4da1a3b54f5f6cb2f1701e38a1c4176403aead696aacc954dae
|
|
4
|
+
data.tar.gz: 4c46c595bd2dbe61968429240b6a1770895ea9f531fe84c4fe472c6146bb06df
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 54cf3972cd8ada223070d61b8d09ae1fbd6c7f033ad202831e07d2d7fd902f465e424947eb558c85f6aee4eb493d754b54f11c4f6a032a2787b4054b12ebea55
|
|
7
|
+
data.tar.gz: 98ffa471017dae7e4671958411e2a4d36b573dfaaa5ef363a39153353466919ff06c76fd6f69108289cb3d35a5781db02f3e7d6855031c38fdc340b2427d2a35
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.0] - 2026-09-23
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`ace-hitl ask`**: requester-side effect-callback API. Creates the local HITL event, binds it to a Lab HITL request via `--ace-hitl-id`, and passes effect declarations (`--effect-match`, `--effect-arg`, `--effect-cwd`, `--effect-timeout-s`) through verbatim after client-side bounds mirroring (match <= 200 chars and compilable; argv 1..16 x 1..512 chars using the lab's strip-then-bounds check so whitespace-only elements fail fast, valid values pass through verbatim; at least one element when any effect flag is present; cwd absolute and existing; timeout 1..600). Prints both the event id and the Lab request id, records `lab_request_effect: declared|none` on the event, and — when the Lab submit fails after the event was created — surfaces the orphan event id in the error.
|
|
14
|
+
- **`ace-hitl wait` Lab awareness**: while waiting on the event answer, the waiter also observes the Lab request public projection (`/run/lab/hitl/public/<id>.json`, overridable via `ACE_HITL_LAB_PUBLIC_DIR`) across BOTH schema fields — the lifecycle `state` (created / answer-delivered / consumed / cancelled) and the separate `effect_state` (callback-pending-with-answer / callback-ok / callback-escalated). Terminal semantics are effect-aware: requests without a declared effect terminate on lifecycle states; effect-declaring requests keep waiting until the callback verdict appears instead of ending at answer delivery, and `callback-escalated` output points at `lab-hitl duty`. The event's `lab_request_state` records the effective state and never claims plain `answer-delivered` while an effect outcome exists. Relay consumption stays the agent's choice (`lab-hitl consume`).
|
|
15
|
+
|
|
10
16
|
|
|
11
17
|
## [0.8.10] - 2026-09-02
|
|
12
18
|
|
data/README.md
CHANGED
|
@@ -10,6 +10,7 @@ Canonical workflow and skill for agents:
|
|
|
10
10
|
## Commands
|
|
11
11
|
|
|
12
12
|
- `ace-hitl create` creates a HITL event
|
|
13
|
+
- `ace-hitl ask` asks a human via HITL and forwards the request to the Lab (`--work`, effect callback flags)
|
|
13
14
|
- `ace-hitl list` lists HITL events with filters (`--scope current|all`, all statuses by default)
|
|
14
15
|
- `ace-hitl show` renders event details, path, or raw content (`--scope current|all`)
|
|
15
16
|
- `ace-hitl update` updates frontmatter, answer content, and folder location
|
|
@@ -28,6 +29,7 @@ Use `ace-overseer status` for a global worktree dashboard.
|
|
|
28
29
|
ace-hitl list
|
|
29
30
|
ace-hitl list --scope all
|
|
30
31
|
ace-hitl create "Which auth strategy?" --kind decision --question "JWT or sessions?"
|
|
32
|
+
ace-hitl ask "Proceed with deploy?" --work W685 --effect-arg /bin/false --effect-cwd /tmp
|
|
31
33
|
ace-hitl show abc123 --content
|
|
32
34
|
ace-hitl show abc123 --scope current
|
|
33
35
|
ace-hitl update abc123 --answer "Use JWT with server-side refresh tokens."
|
data/docs/usage.md
CHANGED
|
@@ -102,6 +102,37 @@ ace-hitl update abc123 --move-to next
|
|
|
102
102
|
ace-hitl update abc123 --answer "close the assignment" --resume
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
+
## Ask (Lab request with effect callback)
|
|
106
|
+
|
|
107
|
+
`ace-hitl ask` creates the local HITL event, forwards the question to the Lab
|
|
108
|
+
as a HITL request bound to the event via `--ace-hitl-id`, and prints both ids.
|
|
109
|
+
Effect declarations are validated client-side (exact bounds) and passed through
|
|
110
|
+
verbatim; the Lab tool remains the authority.
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
ace-hitl ask "Proceed with deploy?" \
|
|
114
|
+
--work W685 \
|
|
115
|
+
--effect-arg /usr/bin/notify-send "{answer}" \
|
|
116
|
+
--effect-cwd /tmp
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
- `--attempt` defaults to `LAB_ATTEMPT_ID`; `--project` to `ace`;
|
|
120
|
+
`--harness` to `lab-admin`; `--plan` to `ace-hitl ask`.
|
|
121
|
+
- Effect flags: `--effect-match` (regex, <= 200 chars, must compile),
|
|
122
|
+
`--effect-arg` (repeatable, 1..16 x 1..512 chars after the lab's
|
|
123
|
+
strip-then-bounds check; whitespace-only elements fail fast, valid
|
|
124
|
+
values pass through verbatim; `{answer}` substituted lab-side),
|
|
125
|
+
`--effect-cwd` (absolute, must exist), `--effect-timeout-s` (1..600).
|
|
126
|
+
- Whether an effect was declared is recorded on the event as
|
|
127
|
+
`lab_request_effect: declared|none` so `wait` can apply the right
|
|
128
|
+
terminal semantics.
|
|
129
|
+
- The answer is always relayed unchanged; consume it with
|
|
130
|
+
`lab-hitl consume <request-id>` when ready.
|
|
131
|
+
- If the Lab request fails after the local event was created, the error
|
|
132
|
+
surfaces the event id as an orphan (created but never bound to a Lab
|
|
133
|
+
request); inspect it with `ace-hitl show <id>` and delete or re-ask
|
|
134
|
+
as needed.
|
|
135
|
+
|
|
105
136
|
## Wait (Polling Default)
|
|
106
137
|
|
|
107
138
|
Wait only for a specific HITL id. This is the default reliability path for the requester agent.
|
|
@@ -112,6 +143,23 @@ ace-hitl wait abc123 --poll-every 600 --timeout 14400
|
|
|
112
143
|
ace-hitl wait abc123 --scope current
|
|
113
144
|
```
|
|
114
145
|
|
|
146
|
+
When the event carries a Lab request (`lab_request_id`), wait also observes
|
|
147
|
+
the Lab public projection instead of hanging blind. Both projection fields
|
|
148
|
+
are observed: the lifecycle `state` (created / answer-delivered / consumed /
|
|
149
|
+
cancelled) and the separate `effect_state` (callback-pending-with-answer /
|
|
150
|
+
callback-ok / callback-escalated). Terminal semantics are effect-aware:
|
|
151
|
+
|
|
152
|
+
- Requests without a declared effect terminate on lifecycle states
|
|
153
|
+
(`answer-delivered`, `consumed`, `cancelled`).
|
|
154
|
+
- Effect-declaring requests keep waiting until the callback verdict
|
|
155
|
+
(`callback-ok` or `callback-escalated`) appears — they never end
|
|
156
|
+
silently at answer delivery; `callback-escalated` output points at
|
|
157
|
+
`lab-hitl duty` for the escalation.
|
|
158
|
+
- The event's `lab_request_state` records the effective state, so it
|
|
159
|
+
never claims plain `answer-delivered` while an effect outcome exists.
|
|
160
|
+
|
|
161
|
+
Relay consumption stays the agent's choice (`lab-hitl consume`).
|
|
162
|
+
|
|
115
163
|
## Lifecycle Event Names
|
|
116
164
|
|
|
117
165
|
Canonical namespace for HITL lifecycle signaling:
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Hitl
|
|
5
|
+
module Atoms
|
|
6
|
+
# Client-side mirror of the Lab effect-declaration bounds so invalid
|
|
7
|
+
# requests fail fast, before any external call. Declarations are never
|
|
8
|
+
# rewritten or "fixed": values pass through verbatim or fail.
|
|
9
|
+
class HitlEffectValidator
|
|
10
|
+
MAX_MATCH_LENGTH = 200
|
|
11
|
+
MIN_ARGV_ELEMENTS = 1
|
|
12
|
+
MAX_ARGV_ELEMENTS = 16
|
|
13
|
+
MAX_ARG_LENGTH = 512
|
|
14
|
+
MIN_TIMEOUT = 1
|
|
15
|
+
MAX_TIMEOUT = 600
|
|
16
|
+
|
|
17
|
+
class ValidationError < StandardError; end
|
|
18
|
+
|
|
19
|
+
def self.validate!(match: nil, effect_args: [], effect_cwd: nil, effect_timeout: nil)
|
|
20
|
+
new(
|
|
21
|
+
match: match,
|
|
22
|
+
effect_args: effect_args,
|
|
23
|
+
effect_cwd: effect_cwd,
|
|
24
|
+
effect_timeout: effect_timeout
|
|
25
|
+
).validate!
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(match:, effect_args:, effect_cwd:, effect_timeout:)
|
|
29
|
+
@match = present?(match) ? match.to_s : nil
|
|
30
|
+
@effect_args = Array(effect_args)
|
|
31
|
+
@effect_cwd = present?(effect_cwd) ? effect_cwd.to_s : nil
|
|
32
|
+
@effect_timeout = present?(effect_timeout) ? effect_timeout.to_s : nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def validate!
|
|
36
|
+
validate_match
|
|
37
|
+
validate_effect_args
|
|
38
|
+
validate_effect_cwd
|
|
39
|
+
validate_effect_timeout
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def present?(value)
|
|
46
|
+
!(value.nil? || (value.respond_to?(:strip) ? value.strip.empty? : value.empty?))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def any_effect_flag?
|
|
50
|
+
@match || @effect_cwd || @effect_timeout || !@effect_args.empty?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def validate_match
|
|
54
|
+
return if @match.nil?
|
|
55
|
+
|
|
56
|
+
if @match.length > MAX_MATCH_LENGTH
|
|
57
|
+
raise ValidationError, "--effect-match exceeds #{MAX_MATCH_LENGTH} characters (got #{@match.length})"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
begin
|
|
61
|
+
Regexp.new(@match)
|
|
62
|
+
rescue RegexpError => e
|
|
63
|
+
raise ValidationError, "--effect-match does not compile: #{e.message}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def validate_effect_args
|
|
68
|
+
if any_effect_flag? && @effect_args.empty?
|
|
69
|
+
raise ValidationError, "at least one --effect-arg is required when any effect flag is present"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
if @effect_args.length > MAX_ARGV_ELEMENTS
|
|
73
|
+
raise ValidationError, "too many --effect-arg values (#{@effect_args.length}); max #{MAX_ARGV_ELEMENTS}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
@effect_args.each_with_index do |arg, index|
|
|
77
|
+
# Mirror the lab's strip-then-bounds check; the value itself
|
|
78
|
+
# still passes through verbatim.
|
|
79
|
+
stripped = arg.to_s.strip
|
|
80
|
+
if stripped.empty?
|
|
81
|
+
raise ValidationError, "--effect-arg ##{index + 1} is empty"
|
|
82
|
+
end
|
|
83
|
+
if stripped.length > MAX_ARG_LENGTH
|
|
84
|
+
raise ValidationError, "--effect-arg ##{index + 1} exceeds #{MAX_ARG_LENGTH} characters (got #{stripped.length})"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def validate_effect_cwd
|
|
90
|
+
return if @effect_cwd.nil?
|
|
91
|
+
|
|
92
|
+
unless Pathname.new(@effect_cwd).absolute?
|
|
93
|
+
raise ValidationError, "--effect-cwd must be an absolute path (got '#{@effect_cwd}')"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
return if File.directory?(@effect_cwd)
|
|
97
|
+
|
|
98
|
+
raise ValidationError, "--effect-cwd does not exist: #{@effect_cwd}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def validate_effect_timeout
|
|
102
|
+
return if @effect_timeout.nil?
|
|
103
|
+
|
|
104
|
+
seconds = begin
|
|
105
|
+
Integer(@effect_timeout, 10)
|
|
106
|
+
rescue ArgumentError
|
|
107
|
+
raise ValidationError, "--effect-timeout-s must be an integer (got '#{@effect_timeout}')"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
return if seconds.between?(MIN_TIMEOUT, MAX_TIMEOUT)
|
|
111
|
+
|
|
112
|
+
raise ValidationError, "--effect-timeout-s must be between #{MIN_TIMEOUT} and #{MAX_TIMEOUT} (got #{@effect_timeout})"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/cli"
|
|
4
|
+
require_relative "../../atoms/hitl_effect_validator"
|
|
5
|
+
require_relative "../../molecules/lab_request_submitter"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Hitl
|
|
9
|
+
module CLI
|
|
10
|
+
module Commands
|
|
11
|
+
class Ask < Ace::Support::Cli::Command
|
|
12
|
+
include Ace::Support::Cli::Base
|
|
13
|
+
|
|
14
|
+
desc "Ask a human via HITL and forward the request to the Lab"
|
|
15
|
+
|
|
16
|
+
argument :question, required: true, desc: "Question text for the human"
|
|
17
|
+
|
|
18
|
+
option :title, type: :string, desc: "Local HITL event title (defaults to the question)"
|
|
19
|
+
option :work, type: :string, desc: "Lab Work id (W...)"
|
|
20
|
+
option :attempt, type: :string, desc: "Lab Attempt id (A-...); defaults to LAB_ATTEMPT_ID"
|
|
21
|
+
option :project, type: :string, desc: "Lab project label (default: ace)"
|
|
22
|
+
option :harness, type: :string, desc: "Lab harness label (default: lab-admin)"
|
|
23
|
+
option :plan, type: :string, desc: "Lab plan label (default: ace-hitl ask)"
|
|
24
|
+
|
|
25
|
+
option :"effect-match", type: :string, desc: "Effect callback regex gate on the answer (<= 200 chars)"
|
|
26
|
+
option :"effect-arg", type: :string, repeat: true, desc: "Effect callback argv element, repeatable (1..16 x 1..512 chars)"
|
|
27
|
+
option :"effect-cwd", type: :string, desc: "Effect callback working directory (absolute, must exist)"
|
|
28
|
+
option :"effect-timeout-s", type: :string, desc: "Effect callback timeout in seconds (1..600)"
|
|
29
|
+
|
|
30
|
+
option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
|
|
31
|
+
option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
|
|
32
|
+
option :debug, type: :boolean, aliases: %w[-d], desc: "Show debug output"
|
|
33
|
+
|
|
34
|
+
def call(question:, **options)
|
|
35
|
+
effect = {
|
|
36
|
+
match: options[:"effect-match"],
|
|
37
|
+
effect_args: Array(options[:"effect-arg"]),
|
|
38
|
+
effect_cwd: options[:"effect-cwd"],
|
|
39
|
+
effect_timeout: options[:"effect-timeout-s"]
|
|
40
|
+
}
|
|
41
|
+
begin
|
|
42
|
+
Atoms::HitlEffectValidator.validate!(**effect)
|
|
43
|
+
rescue Atoms::HitlEffectValidator::ValidationError => e
|
|
44
|
+
raise_cli_error(e.message)
|
|
45
|
+
end
|
|
46
|
+
effect_declared = !!(effect[:match] || effect[:effect_cwd] || effect[:effect_timeout] ||
|
|
47
|
+
Array(effect[:effect_args]).any?)
|
|
48
|
+
|
|
49
|
+
work = require_work!(options)
|
|
50
|
+
attempt = options[:attempt] || ENV["LAB_ATTEMPT_ID"]
|
|
51
|
+
unless attempt && !attempt.strip.empty?
|
|
52
|
+
raise_cli_error("--attempt required (or set LAB_ATTEMPT_ID)")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
submitter = Molecules::LabRequestSubmitter.new
|
|
56
|
+
request_id = submitter.generate_request_id
|
|
57
|
+
|
|
58
|
+
manager = Ace::Hitl::Organisms::HitlManager.new
|
|
59
|
+
event = manager.create(
|
|
60
|
+
options[:title] || question,
|
|
61
|
+
questions: [question]
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
argv = submitter.build_argv(
|
|
65
|
+
request_id: request_id,
|
|
66
|
+
work: work,
|
|
67
|
+
attempt: attempt,
|
|
68
|
+
project: options[:project] || "ace",
|
|
69
|
+
harness: options[:harness] || "lab-admin",
|
|
70
|
+
plan: options[:plan] || "ace-hitl ask",
|
|
71
|
+
question: question,
|
|
72
|
+
ace_hitl_id: event.id,
|
|
73
|
+
**effect
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
begin
|
|
77
|
+
lab_request_id = submitter.submit(argv)
|
|
78
|
+
rescue Molecules::LabRequestSubmitter::SubmissionError => e
|
|
79
|
+
raise_cli_error(
|
|
80
|
+
"#{e.message}; local HITL event #{event.id} was created but never bound to a " \
|
|
81
|
+
"Lab request (orphan) - inspect it with ace-hitl show #{event.id} and delete " \
|
|
82
|
+
"or re-ask as needed"
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
manager.update(event.id, set: {
|
|
87
|
+
"lab_request_id" => lab_request_id,
|
|
88
|
+
"lab_request_state" => "created",
|
|
89
|
+
"lab_request_effect" => effect_declared ? "declared" : "none"
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
puts "HITL event: #{event.id}"
|
|
93
|
+
puts "Lab request: #{lab_request_id}"
|
|
94
|
+
puts "Answer relay: lab-hitl consume #{lab_request_id}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def require_work!(options)
|
|
100
|
+
work = options[:work]
|
|
101
|
+
raise_cli_error("--work required (Lab Work id, e.g. W685)") if work.nil? || work.strip.empty?
|
|
102
|
+
|
|
103
|
+
work
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -47,8 +47,20 @@ module Ace
|
|
|
47
47
|
event = result[:event]
|
|
48
48
|
puts "HITL event answered: #{event.id} #{event.title}"
|
|
49
49
|
puts "Answer: #{event.answer}"
|
|
50
|
+
puts "Lab request: #{event.metadata["lab_request_id"]} (#{result[:lab_state]})" if result[:lab_state]
|
|
50
51
|
resume = event.metadata["resume_instructions"]
|
|
51
52
|
puts "Resume: #{resume}" if resume
|
|
53
|
+
when :lab_delivered
|
|
54
|
+
event = result[:event]
|
|
55
|
+
lab_request_id = event.metadata["lab_request_id"]
|
|
56
|
+
puts "Lab request delivered: #{event.id} (#{result[:lab_state]})"
|
|
57
|
+
puts "Lab request: #{lab_request_id}"
|
|
58
|
+
puts "Consume the answer when ready: lab-hitl consume #{lab_request_id}"
|
|
59
|
+
if result[:lab_state] == "callback-ok"
|
|
60
|
+
puts "Effect callback: ok"
|
|
61
|
+
elsif result[:lab_state] == "callback-escalated"
|
|
62
|
+
puts "Effect callback: escalated; inspect with: lab-hitl duty"
|
|
63
|
+
end
|
|
52
64
|
when :timeout
|
|
53
65
|
raise Ace::Support::Cli::Error.new("Timed out waiting for HITL event '#{ref}'")
|
|
54
66
|
else
|
data/lib/ace/hitl/cli.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "ace/support/cli"
|
|
4
4
|
require_relative "../hitl/version"
|
|
5
5
|
require_relative "cli/commands/create"
|
|
6
|
+
require_relative "cli/commands/ask"
|
|
6
7
|
require_relative "cli/commands/show"
|
|
7
8
|
require_relative "cli/commands/list"
|
|
8
9
|
require_relative "cli/commands/update"
|
|
@@ -17,6 +18,7 @@ module Ace
|
|
|
17
18
|
|
|
18
19
|
REGISTERED_COMMANDS = [
|
|
19
20
|
["create", "Create HITL event"],
|
|
21
|
+
["ask", "Ask a human via HITL and forward the request to the Lab"],
|
|
20
22
|
["show", "Show HITL event details"],
|
|
21
23
|
["list", "List HITL events"],
|
|
22
24
|
["update", "Update HITL event metadata or answer"],
|
|
@@ -27,11 +29,13 @@ module Ace
|
|
|
27
29
|
"ace-hitl list --status pending",
|
|
28
30
|
"ace-hitl show abc123 --content",
|
|
29
31
|
"ace-hitl create \"Which auth strategy?\" --kind decision",
|
|
32
|
+
"ace-hitl ask \"Proceed with deploy?\" --work W685 --effect-arg /bin/false --effect-cwd /tmp",
|
|
30
33
|
"ace-hitl update abc123 --answer \"Use JWT with refresh tokens\"",
|
|
31
34
|
"ace-hitl wait abc123 --poll-every 600 --timeout 14400"
|
|
32
35
|
].freeze
|
|
33
36
|
|
|
34
37
|
register "create", CLI::Commands::Create
|
|
38
|
+
register "ask", CLI::Commands::Ask
|
|
35
39
|
register "show", CLI::Commands::Show
|
|
36
40
|
register "list", CLI::Commands::List
|
|
37
41
|
register "update", CLI::Commands::Update
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Hitl
|
|
7
|
+
module Molecules
|
|
8
|
+
# Observes the Lab request public projection
|
|
9
|
+
# (`<public-dir>/<request-id>.json`) so waiters can surface lab-side
|
|
10
|
+
# states instead of hanging blind. Read-only; consumption of the
|
|
11
|
+
# answer relay remains the caller's choice.
|
|
12
|
+
#
|
|
13
|
+
# Projection schema (deployed lab-hitl, lab-config 8wl.t.ga9): the
|
|
14
|
+
# lifecycle field `state` carries created / answer-delivered /
|
|
15
|
+
# consumed / cancelled, while effect-callback outcomes live in the
|
|
16
|
+
# SEPARATE `effect_state` field (callback-pending-with-answer /
|
|
17
|
+
# callback-ok / callback-escalated). Both fields are observed here.
|
|
18
|
+
class LabProjectionObserver
|
|
19
|
+
DEFAULT_PUBLIC_DIR = "/run/lab/hitl/public"
|
|
20
|
+
PUBLIC_DIR_ENV = "ACE_HITL_LAB_PUBLIC_DIR"
|
|
21
|
+
|
|
22
|
+
# Lifecycle (`state`) terminal values: the answer was delivered to
|
|
23
|
+
# the relay, consumed, or the request was cancelled.
|
|
24
|
+
LIFECYCLE_TERMINAL_STATES = %w[answer-delivered consumed cancelled].freeze
|
|
25
|
+
# Effect (`effect_state`) terminal values: the callback reached a
|
|
26
|
+
# verdict. `callback-pending-with-answer` keeps a waiter holding.
|
|
27
|
+
EFFECT_TERMINAL_STATES = %w[callback-ok callback-escalated].freeze
|
|
28
|
+
|
|
29
|
+
# Both observed projection fields; either may be nil while the lab
|
|
30
|
+
# has not written it yet.
|
|
31
|
+
Snapshot = Struct.new(:state, :effect_state)
|
|
32
|
+
|
|
33
|
+
def initialize(public_dir: nil)
|
|
34
|
+
@public_dir = public_dir
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Reads the projection and returns a Snapshot with the lifecycle
|
|
38
|
+
# `state` and the separate `effect_state`, or nil when the
|
|
39
|
+
# projection is missing or unreadable.
|
|
40
|
+
def snapshot_for(request_id)
|
|
41
|
+
path = projection_path(request_id)
|
|
42
|
+
return nil unless File.file?(path)
|
|
43
|
+
|
|
44
|
+
projection = JSON.parse(File.read(path))
|
|
45
|
+
Snapshot.new(
|
|
46
|
+
state: projection["state"],
|
|
47
|
+
effect_state: projection["effect_state"]
|
|
48
|
+
)
|
|
49
|
+
rescue
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The state a waiter should report: the effect outcome when one
|
|
54
|
+
# exists (so `lab_request_state` never claims plain
|
|
55
|
+
# `answer-delivered` while an effect outcome is present), the
|
|
56
|
+
# lifecycle state otherwise.
|
|
57
|
+
def effective_state(snapshot)
|
|
58
|
+
return nil unless snapshot
|
|
59
|
+
|
|
60
|
+
snapshot.effect_state || snapshot.state
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Terminality depends on whether the request declared an effect
|
|
64
|
+
# callback: effect-declaring requests terminate only on an effect
|
|
65
|
+
# verdict (callback-ok / callback-escalated), never at answer
|
|
66
|
+
# delivery; other requests terminate on lifecycle terminal states.
|
|
67
|
+
def terminal?(snapshot, effect_declared: false)
|
|
68
|
+
return false unless snapshot
|
|
69
|
+
|
|
70
|
+
if effect_declared
|
|
71
|
+
EFFECT_TERMINAL_STATES.include?(snapshot.effect_state)
|
|
72
|
+
else
|
|
73
|
+
LIFECYCLE_TERMINAL_STATES.include?(snapshot.state)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def projection_path(request_id)
|
|
78
|
+
File.join(public_dir, "#{request_id}.json")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def public_dir
|
|
84
|
+
@public_dir || ENV.fetch(PUBLIC_DIR_ENV, DEFAULT_PUBLIC_DIR)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Hitl
|
|
9
|
+
module Molecules
|
|
10
|
+
# Builds and runs the `lab-hitl request` invocation on behalf of a
|
|
11
|
+
# requester. The argv contract is stable: existing required flags
|
|
12
|
+
# first, effect flags in a fixed order (match, args, cwd, timeout).
|
|
13
|
+
# Values pass through verbatim; no answer content or effect argv is
|
|
14
|
+
# ever logged here.
|
|
15
|
+
class LabRequestSubmitter
|
|
16
|
+
DEFAULT_LAB_BIN = "/usr/local/bin/lab-hitl"
|
|
17
|
+
LAB_BIN_ENV = "ACE_HITL_LAB_BIN"
|
|
18
|
+
|
|
19
|
+
class SubmissionError < StandardError; end
|
|
20
|
+
|
|
21
|
+
def initialize(bin: nil, runner: nil, id_generator: nil)
|
|
22
|
+
@bin = bin || ENV.fetch(LAB_BIN_ENV, DEFAULT_LAB_BIN)
|
|
23
|
+
@runner = runner || ->(argv) { Open3.capture3(*argv) }
|
|
24
|
+
@id_generator = id_generator || -> { "hitl-#{SecureRandom.hex(8)}" }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def generate_request_id
|
|
28
|
+
@id_generator.call
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def build_argv(request_id:, work:, attempt:, project:, harness:, plan:, question:, ace_hitl_id:,
|
|
32
|
+
match: nil, effect_args: [], effect_cwd: nil, effect_timeout: nil)
|
|
33
|
+
argv = [
|
|
34
|
+
@bin, "request",
|
|
35
|
+
"--id", request_id,
|
|
36
|
+
"--work", work,
|
|
37
|
+
"--attempt", attempt,
|
|
38
|
+
"--project", project,
|
|
39
|
+
"--harness", harness,
|
|
40
|
+
"--plan", plan,
|
|
41
|
+
"--question", question,
|
|
42
|
+
"--ace-hitl-id", ace_hitl_id
|
|
43
|
+
]
|
|
44
|
+
argv += ["--effect-match", match] if match
|
|
45
|
+
Array(effect_args).each { |arg| argv += ["--effect-arg", arg] }
|
|
46
|
+
argv += ["--effect-cwd", effect_cwd] if effect_cwd
|
|
47
|
+
argv += ["--effect-timeout-s", effect_timeout] if effect_timeout
|
|
48
|
+
argv
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def submit(argv)
|
|
52
|
+
stdout, stderr, status = run(argv)
|
|
53
|
+
unless status.success?
|
|
54
|
+
raise SubmissionError, "lab-hitl request failed (exit #{status.exitstatus}): #{stderr.to_s.strip}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
parse_request_id(stdout)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Distinguishes could-not-execute (missing/unreadable binary,
|
|
63
|
+
# Errno::*) from could-not-parse so failures are actionable.
|
|
64
|
+
def run(argv)
|
|
65
|
+
@runner.call(argv)
|
|
66
|
+
rescue => e
|
|
67
|
+
raise SubmissionError, "could not execute #{@bin}: #{e.class}: #{e.message}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def parse_request_id(stdout)
|
|
71
|
+
value = JSON.parse(stdout)
|
|
72
|
+
id = value["id"]
|
|
73
|
+
raise SubmissionError, "lab-hitl request returned no id" if id.nil? || id.to_s.strip.empty?
|
|
74
|
+
|
|
75
|
+
id
|
|
76
|
+
rescue JSON::ParserError => e
|
|
77
|
+
raise SubmissionError, "could not parse lab-hitl request output as JSON: #{e.message}"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -8,6 +8,7 @@ require_relative "../molecules/hitl_config_loader"
|
|
|
8
8
|
require_relative "../molecules/hitl_scanner"
|
|
9
9
|
require_relative "../molecules/hitl_loader"
|
|
10
10
|
require_relative "../molecules/hitl_creator"
|
|
11
|
+
require_relative "../molecules/lab_projection_observer"
|
|
11
12
|
require_relative "../molecules/hitl_answer_editor"
|
|
12
13
|
require_relative "../molecules/resume_dispatcher"
|
|
13
14
|
require_relative "../molecules/worktree_scope_resolver"
|
|
@@ -121,7 +122,7 @@ module Ace
|
|
|
121
122
|
loader.load(current_path, id: event.id, special_folder: current_special)
|
|
122
123
|
end
|
|
123
124
|
|
|
124
|
-
def wait_for_answer(ref, scope: nil, poll_every: 600, timeout: 14_400, waiter: {}, now_proc: nil, sleeper: nil)
|
|
125
|
+
def wait_for_answer(ref, scope: nil, poll_every: 600, timeout: 14_400, waiter: {}, now_proc: nil, sleeper: nil, lab_observer: nil)
|
|
125
126
|
poll_every = normalize_poll_seconds(poll_every)
|
|
126
127
|
timeout = normalize_timeout_seconds(timeout)
|
|
127
128
|
now_proc ||= -> { Time.now.utc }
|
|
@@ -148,7 +149,20 @@ module Ace
|
|
|
148
149
|
scope: scope
|
|
149
150
|
)
|
|
150
151
|
|
|
151
|
-
|
|
152
|
+
observer = lab_observer_for(lab_observer)
|
|
153
|
+
lab_snapshot = observe_lab_state(event, observer: observer, scope: scope)
|
|
154
|
+
lab_state = observer.effective_state(lab_snapshot)
|
|
155
|
+
|
|
156
|
+
# An effect-declaring request keeps waiting until the callback
|
|
157
|
+
# verdict (callback-ok / callback-escalated) is visible; answer
|
|
158
|
+
# delivery alone never ends the wait. When the projection is
|
|
159
|
+
# unreadable there is nothing to hold on, so the wait falls
|
|
160
|
+
# back to the event/lifecycle behavior (timeout stays the
|
|
161
|
+
# backstop).
|
|
162
|
+
hold_for_effect = effect_declared?(event) && lab_snapshot &&
|
|
163
|
+
!observer.terminal?(lab_snapshot, effect_declared: true)
|
|
164
|
+
|
|
165
|
+
if event.answered? && !hold_for_effect
|
|
152
166
|
update(event.id,
|
|
153
167
|
set: {
|
|
154
168
|
"waiter_state" => "answered",
|
|
@@ -157,12 +171,25 @@ module Ace
|
|
|
157
171
|
scope: scope
|
|
158
172
|
)
|
|
159
173
|
refreshed = show(event.id, scope: scope)&.dig(:event) || event
|
|
160
|
-
return {status: :answered, event: refreshed}
|
|
174
|
+
return {status: :answered, event: refreshed, lab_state: lab_state}
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
if observer.terminal?(lab_snapshot, effect_declared: effect_declared?(event))
|
|
178
|
+
update(
|
|
179
|
+
event.id,
|
|
180
|
+
set: {
|
|
181
|
+
"waiter_state" => "lab_delivered",
|
|
182
|
+
"waiter_last_seen_at" => now.iso8601,
|
|
183
|
+
"lab_request_state" => lab_state
|
|
184
|
+
},
|
|
185
|
+
scope: scope
|
|
186
|
+
)
|
|
187
|
+
return {status: :lab_delivered, event: event, lab_state: lab_state}
|
|
161
188
|
end
|
|
162
189
|
|
|
163
190
|
if now >= deadline
|
|
164
191
|
update(event.id, set: {"waiter_state" => "timed_out"}, scope: scope)
|
|
165
|
-
return {status: :timeout, event: event}
|
|
192
|
+
return {status: :timeout, event: event, lab_state: lab_state}
|
|
166
193
|
end
|
|
167
194
|
|
|
168
195
|
sleep_seconds = [poll_every, (deadline - now).ceil].min
|
|
@@ -216,6 +243,26 @@ module Ace
|
|
|
216
243
|
|
|
217
244
|
private
|
|
218
245
|
|
|
246
|
+
def lab_observer_for(lab_observer)
|
|
247
|
+
lab_observer || Molecules::LabProjectionObserver.new
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def effect_declared?(event)
|
|
251
|
+
event.metadata["lab_request_effect"] == "declared"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def observe_lab_state(event, observer:, scope:)
|
|
255
|
+
request_id = event.metadata["lab_request_id"]
|
|
256
|
+
return nil if request_id.nil? || request_id.to_s.strip.empty?
|
|
257
|
+
|
|
258
|
+
snapshot = observer.snapshot_for(request_id)
|
|
259
|
+
state = observer.effective_state(snapshot)
|
|
260
|
+
if state && state != event.metadata["lab_request_state"]
|
|
261
|
+
update(event.id, set: {"lab_request_state" => state}, scope: scope)
|
|
262
|
+
end
|
|
263
|
+
snapshot
|
|
264
|
+
end
|
|
265
|
+
|
|
219
266
|
def load_config
|
|
220
267
|
Molecules::HitlConfigLoader.load
|
|
221
268
|
end
|
data/lib/ace/hitl/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ace-hitl
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michal Czyz
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-09-
|
|
10
|
+
date: 2026-09-23 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: ace-support-core
|
|
@@ -116,9 +116,11 @@ files:
|
|
|
116
116
|
- handbook/skills/as-hitl/SKILL.md
|
|
117
117
|
- handbook/workflow-instructions/hitl.wf.md
|
|
118
118
|
- lib/ace/hitl.rb
|
|
119
|
+
- lib/ace/hitl/atoms/hitl_effect_validator.rb
|
|
119
120
|
- lib/ace/hitl/atoms/hitl_file_pattern.rb
|
|
120
121
|
- lib/ace/hitl/atoms/hitl_id_formatter.rb
|
|
121
122
|
- lib/ace/hitl/cli.rb
|
|
123
|
+
- lib/ace/hitl/cli/commands/ask.rb
|
|
122
124
|
- lib/ace/hitl/cli/commands/create.rb
|
|
123
125
|
- lib/ace/hitl/cli/commands/list.rb
|
|
124
126
|
- lib/ace/hitl/cli/commands/show.rb
|
|
@@ -132,6 +134,8 @@ files:
|
|
|
132
134
|
- lib/ace/hitl/molecules/hitl_loader.rb
|
|
133
135
|
- lib/ace/hitl/molecules/hitl_resolver.rb
|
|
134
136
|
- lib/ace/hitl/molecules/hitl_scanner.rb
|
|
137
|
+
- lib/ace/hitl/molecules/lab_projection_observer.rb
|
|
138
|
+
- lib/ace/hitl/molecules/lab_request_submitter.rb
|
|
135
139
|
- lib/ace/hitl/molecules/resume_dispatcher.rb
|
|
136
140
|
- lib/ace/hitl/molecules/worktree_scope_resolver.rb
|
|
137
141
|
- lib/ace/hitl/organisms/hitl_manager.rb
|