ace-hitl 0.8.9 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d3661968abbe2abf17fc1cf79fbe2a4d710805c134825dfa525661b64d27bd6d
4
- data.tar.gz: 0c1b3ba003b4ce8020458d8f23909bbed8d2331a81cda9f83f33c23bcb6aaa28
3
+ metadata.gz: 6c36ea0129ebd4da1a3b54f5f6cb2f1701e38a1c4176403aead696aacc954dae
4
+ data.tar.gz: 4c46c595bd2dbe61968429240b6a1770895ea9f531fe84c4fe472c6146bb06df
5
5
  SHA512:
6
- metadata.gz: 4ad8b699ab12f9ba22283fb1259cd5a9c5cb5b1de13e579097c8b8d3f1926f9200affb2e30aa6569c37720127da2c5ecb2202d4ad01b9b2a6594eb4fd5032880
7
- data.tar.gz: 84f7b019ef890ab2b5e0d77b2ce8f75ef9618ffe1f03dbf63bfc3fbf9b762809f7bf4a42dc9e1fc43ef2b8139d3aca3c5d54725c3aad844177ec9b32194de1c0
6
+ metadata.gz: 54cf3972cd8ada223070d61b8d09ae1fbd6c7f033ad202831e07d2d7fd902f465e424947eb558c85f6aee4eb493d754b54f11c4f6a032a2787b4054b12ebea55
7
+ data.tar.gz: 98ffa471017dae7e4671958411e2a4d36b573dfaaa5ef363a39153353466919ff06c76fd6f69108289cb3d35a5781db02f3e7d6855031c38fdc340b2427d2a35
data/CHANGELOG.md CHANGED
@@ -7,6 +7,17 @@ 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
+
16
+
17
+ ## [0.8.10] - 2026-09-02
18
+
19
+ ### Technical
20
+ - Included in the coordinated all-package patch release preparation for ACE monorepo review.
10
21
  ## [0.8.9] - 2026-08-12
11
22
 
12
23
  ### Technical
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
- if event.answered?
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Hitl
5
- VERSION = "0.8.9"
5
+ VERSION = "0.9.0"
6
6
  end
7
7
  end
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.8.9
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-08-12 00:00:00.000000000 Z
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
@@ -29,14 +29,14 @@ dependencies:
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '0.17'
32
+ version: '0.18'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: '0.17'
39
+ version: '0.18'
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: ace-support-fs
42
42
  requirement: !ruby/object:Gem::Requirement
@@ -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