ace-hitl 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 +7 -0
- data/.ace-defaults/hitl/config.yml +3 -0
- data/.ace-defaults/nav/protocols/skill-sources/ace-hitl.yml +13 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-hitl.yml +13 -0
- data/CHANGELOG.md +128 -0
- data/README.md +42 -0
- data/Rakefile +10 -0
- data/docs/demo/ace-hitl-scope-and-answer.tape.yml +37 -0
- data/docs/demo/fixtures/demo-context.md +7 -0
- data/docs/usage.md +112 -0
- data/exe/ace-hitl +18 -0
- data/handbook/README.md +14 -0
- data/handbook/skills/as-hitl/SKILL.md +29 -0
- data/handbook/workflow-instructions/hitl.wf.md +110 -0
- data/lib/ace/hitl/atoms/hitl_file_pattern.rb +20 -0
- data/lib/ace/hitl/atoms/hitl_id_formatter.rb +15 -0
- data/lib/ace/hitl/cli/commands/create.rb +76 -0
- data/lib/ace/hitl/cli/commands/list.rb +63 -0
- data/lib/ace/hitl/cli/commands/show.rb +79 -0
- data/lib/ace/hitl/cli/commands/update.rb +127 -0
- data/lib/ace/hitl/cli/commands/wait.rb +74 -0
- data/lib/ace/hitl/cli.rb +62 -0
- data/lib/ace/hitl/models/hitl_event.rb +32 -0
- data/lib/ace/hitl/molecules/hitl_answer_editor.rb +25 -0
- data/lib/ace/hitl/molecules/hitl_config_loader.rb +50 -0
- data/lib/ace/hitl/molecules/hitl_creator.rb +207 -0
- data/lib/ace/hitl/molecules/hitl_display_formatter.rb +53 -0
- data/lib/ace/hitl/molecules/hitl_loader.rb +113 -0
- data/lib/ace/hitl/molecules/hitl_resolver.rb +30 -0
- data/lib/ace/hitl/molecules/hitl_scanner.rb +42 -0
- data/lib/ace/hitl/molecules/resume_dispatcher.rb +99 -0
- data/lib/ace/hitl/molecules/worktree_scope_resolver.rb +77 -0
- data/lib/ace/hitl/organisms/hitl_manager.rb +462 -0
- data/lib/ace/hitl/version.rb +7 -0
- data/lib/ace/hitl.rb +24 -0
- metadata +164 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "time"
|
|
5
|
+
require "yaml"
|
|
6
|
+
require "ace/support/items"
|
|
7
|
+
require_relative "../atoms/hitl_file_pattern"
|
|
8
|
+
require_relative "../atoms/hitl_id_formatter"
|
|
9
|
+
require_relative "hitl_loader"
|
|
10
|
+
|
|
11
|
+
module Ace
|
|
12
|
+
module Hitl
|
|
13
|
+
module Molecules
|
|
14
|
+
class HitlCreator
|
|
15
|
+
def initialize(root_dir:, config: {})
|
|
16
|
+
@root_dir = root_dir
|
|
17
|
+
@config = config
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def create(title,
|
|
21
|
+
kind: nil,
|
|
22
|
+
questions: [],
|
|
23
|
+
tags: [],
|
|
24
|
+
assignment: nil,
|
|
25
|
+
step: nil,
|
|
26
|
+
step_name: nil,
|
|
27
|
+
resume_instructions: nil,
|
|
28
|
+
move_to: nil,
|
|
29
|
+
time: Time.now.utc)
|
|
30
|
+
raise ArgumentError, "Title is required" if title.nil? || title.strip.empty?
|
|
31
|
+
|
|
32
|
+
effective_kind = kind || @config.dig("hitl", "default_kind") || "clarification"
|
|
33
|
+
id = generate_unique_id(time)
|
|
34
|
+
folder_slug = generate_folder_slug(title)
|
|
35
|
+
file_slug = generate_file_slug(title)
|
|
36
|
+
|
|
37
|
+
target_dir = determine_target_dir(move_to)
|
|
38
|
+
FileUtils.mkdir_p(target_dir)
|
|
39
|
+
|
|
40
|
+
folder_name, _ = unique_folder_name(id, folder_slug, target_dir)
|
|
41
|
+
item_dir = File.join(target_dir, folder_name)
|
|
42
|
+
FileUtils.mkdir_p(item_dir)
|
|
43
|
+
|
|
44
|
+
frontmatter = {
|
|
45
|
+
"id" => id,
|
|
46
|
+
"title" => title,
|
|
47
|
+
"kind" => effective_kind,
|
|
48
|
+
"status" => "pending",
|
|
49
|
+
"tags" => tags,
|
|
50
|
+
"questions" => questions,
|
|
51
|
+
"assignment" => assignment,
|
|
52
|
+
"step" => step,
|
|
53
|
+
"step_name" => step_name,
|
|
54
|
+
"resume_instructions" => resume_instructions,
|
|
55
|
+
"answered" => false,
|
|
56
|
+
"created_at" => time
|
|
57
|
+
}.merge(infer_requester_context(assignment: assignment, step: step)).compact
|
|
58
|
+
|
|
59
|
+
body = build_body(title: title, questions: questions)
|
|
60
|
+
content = Ace::Support::Items::Atoms::FrontmatterSerializer.rebuild(frontmatter, body)
|
|
61
|
+
|
|
62
|
+
item_file = File.join(item_dir, Atoms::HitlFilePattern.filename(id, file_slug))
|
|
63
|
+
File.write(item_file, content)
|
|
64
|
+
|
|
65
|
+
loader = HitlLoader.new
|
|
66
|
+
special_folder = Ace::Support::Items::Atoms::SpecialFolderDetector.detect_in_path(
|
|
67
|
+
item_dir,
|
|
68
|
+
root: @root_dir
|
|
69
|
+
)
|
|
70
|
+
loader.load(item_dir, id: id, special_folder: special_folder)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def generate_unique_id(base_time)
|
|
76
|
+
time = base_time
|
|
77
|
+
1000.times do
|
|
78
|
+
id = Atoms::HitlIdFormatter.generate(time)
|
|
79
|
+
return id unless hitl_id_exists?(id)
|
|
80
|
+
|
|
81
|
+
time += 2
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
raise "Failed to generate unique HITL ID after 1000 attempts"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def hitl_id_exists?(id)
|
|
88
|
+
pattern = File.join(@root_dir, "**", "#{id}-*")
|
|
89
|
+
!Dir.glob(pattern).empty?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def unique_folder_name(id, slug, target_dir)
|
|
93
|
+
folder_name = Atoms::HitlFilePattern.folder_name(id, slug)
|
|
94
|
+
candidate_dir = File.join(target_dir, folder_name)
|
|
95
|
+
return [folder_name, slug] unless Dir.exist?(candidate_dir)
|
|
96
|
+
|
|
97
|
+
counter = 2
|
|
98
|
+
loop do
|
|
99
|
+
unique_slug = "#{slug}-#{counter}"
|
|
100
|
+
folder_name = Atoms::HitlFilePattern.folder_name(id, unique_slug)
|
|
101
|
+
candidate_dir = File.join(target_dir, folder_name)
|
|
102
|
+
break [folder_name, unique_slug] unless Dir.exist?(candidate_dir)
|
|
103
|
+
|
|
104
|
+
counter += 1
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def generate_folder_slug(title)
|
|
109
|
+
sanitized = Ace::Support::Items::Atoms::SlugSanitizer.sanitize(title.to_s)
|
|
110
|
+
words = sanitized.split("-")
|
|
111
|
+
words.take(6).join("-").then { |s| s.empty? ? "hitl" : s }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def generate_file_slug(title)
|
|
115
|
+
sanitized = Ace::Support::Items::Atoms::SlugSanitizer.sanitize(title.to_s)
|
|
116
|
+
words = sanitized.split("-")
|
|
117
|
+
words.take(8).join("-").then { |s| s.empty? ? "hitl" : s }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def determine_target_dir(move_to)
|
|
121
|
+
return @root_dir unless move_to
|
|
122
|
+
|
|
123
|
+
normalized = Ace::Support::Items::Atoms::SpecialFolderDetector.normalize(move_to)
|
|
124
|
+
candidate = File.expand_path(File.join(@root_dir, normalized))
|
|
125
|
+
root_real = File.expand_path(@root_dir)
|
|
126
|
+
|
|
127
|
+
unless candidate.start_with?(root_real + File::SEPARATOR) || candidate == root_real
|
|
128
|
+
raise ArgumentError, "Path traversal detected in --move-to option"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
candidate
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def build_body(title:, questions: [])
|
|
135
|
+
question_lines = if questions.empty?
|
|
136
|
+
"- (pending human input)"
|
|
137
|
+
else
|
|
138
|
+
questions.map { |q| "- #{q}" }.join("\n")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
<<~BODY
|
|
142
|
+
# #{title}
|
|
143
|
+
|
|
144
|
+
## Questions
|
|
145
|
+
|
|
146
|
+
#{question_lines}
|
|
147
|
+
|
|
148
|
+
## Answer
|
|
149
|
+
|
|
150
|
+
BODY
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def infer_requester_context(assignment:, step:)
|
|
154
|
+
assignment_id = assignment.to_s.split("@", 2).first.to_s.strip
|
|
155
|
+
return {} if assignment_id.empty?
|
|
156
|
+
|
|
157
|
+
sessions_dir = File.join(project_root, ".ace-local", "assign", assignment_id, "sessions")
|
|
158
|
+
return {} unless Dir.exist?(sessions_dir)
|
|
159
|
+
|
|
160
|
+
candidates = []
|
|
161
|
+
ancestors_for_step(step).each do |number|
|
|
162
|
+
path = File.join(sessions_dir, "#{number}-session.yml")
|
|
163
|
+
candidates << path if File.exist?(path)
|
|
164
|
+
end
|
|
165
|
+
newest = Dir.glob(File.join(sessions_dir, "*-session.yml")).sort_by { |path| File.mtime(path) }.reverse
|
|
166
|
+
candidates.concat(newest)
|
|
167
|
+
|
|
168
|
+
candidates.uniq.each do |path|
|
|
169
|
+
data = YAML.safe_load_file(path, permitted_classes: [Time], aliases: false) || {}
|
|
170
|
+
next unless data.is_a?(Hash)
|
|
171
|
+
|
|
172
|
+
provider = data["provider"].to_s.strip
|
|
173
|
+
model = data["model"].to_s.strip
|
|
174
|
+
session_id = data["session_id"].to_s.strip
|
|
175
|
+
next if provider.empty? && model.empty? && session_id.empty?
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
"requester_provider" => (provider.empty? ? nil : provider),
|
|
179
|
+
"requester_model" => (model.empty? ? nil : model),
|
|
180
|
+
"requester_session_id" => (session_id.empty? ? nil : session_id)
|
|
181
|
+
}.compact
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
{}
|
|
185
|
+
rescue StandardError
|
|
186
|
+
{}
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def ancestors_for_step(step)
|
|
190
|
+
raw = step.to_s.strip
|
|
191
|
+
return [] if raw.empty?
|
|
192
|
+
|
|
193
|
+
parts = raw.split(".")
|
|
194
|
+
(1..parts.length).map { |count| parts[0, count].join(".") }.reverse
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def project_root
|
|
198
|
+
expanded = File.expand_path(@root_dir)
|
|
199
|
+
suffix = "#{File::SEPARATOR}.ace-local#{File::SEPARATOR}hitl"
|
|
200
|
+
return expanded[0...-suffix.length] if expanded.end_with?(suffix)
|
|
201
|
+
|
|
202
|
+
Dir.pwd
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Hitl
|
|
5
|
+
module Molecules
|
|
6
|
+
# Formats HITL events for list output with stats footer.
|
|
7
|
+
class HitlDisplayFormatter
|
|
8
|
+
C = Ace::Support::Items::Atoms::AnsiColors
|
|
9
|
+
|
|
10
|
+
STATUS_SYMBOLS = {
|
|
11
|
+
"pending" => "○",
|
|
12
|
+
"answered" => "✓"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
STATUS_ORDER = %w[pending answered].freeze
|
|
16
|
+
|
|
17
|
+
def self.format_list(events, total_count: nil, global_folder_stats: nil)
|
|
18
|
+
body = if events.empty?
|
|
19
|
+
"No HITL events found"
|
|
20
|
+
else
|
|
21
|
+
events.sort_by(&:id).map { |event| format_list_line(event) }.join("\n")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
stats = Ace::Support::Items::Atoms::ItemStatistics.count_by(events, :status)
|
|
25
|
+
folder_stats = Ace::Support::Items::Atoms::ItemStatistics.count_by(events, :special_folder)
|
|
26
|
+
footer = Ace::Support::Items::Atoms::StatsLineFormatter.format(
|
|
27
|
+
label: "HITL Events",
|
|
28
|
+
stats: stats,
|
|
29
|
+
status_order: STATUS_ORDER,
|
|
30
|
+
status_icons: STATUS_SYMBOLS,
|
|
31
|
+
folder_stats: folder_stats,
|
|
32
|
+
total_count: total_count,
|
|
33
|
+
global_folder_stats: global_folder_stats
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
"#{body}\n\n#{footer}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.format_list_line(event)
|
|
40
|
+
status_sym = STATUS_SYMBOLS[event.status] || "○"
|
|
41
|
+
id_str = C.colorize(event.id, C::DIM)
|
|
42
|
+
tags_str = event.tags.any? ? C.colorize(" [#{event.tags.join(", ")}]", C::DIM) : ""
|
|
43
|
+
folder_label = event.special_folder ? Ace::Support::Items::Atoms::SpecialFolderDetector.short_name(event.special_folder) : nil
|
|
44
|
+
folder_str = folder_label ? C.colorize(" (#{folder_label})", C::DIM) : ""
|
|
45
|
+
|
|
46
|
+
"#{status_sym} #{id_str} #{event.title}#{tags_str}#{folder_str}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private_class_method :format_list_line
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require "ace/support/items"
|
|
5
|
+
require_relative "../atoms/hitl_file_pattern"
|
|
6
|
+
require_relative "../models/hitl_event"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Hitl
|
|
10
|
+
module Molecules
|
|
11
|
+
class HitlLoader
|
|
12
|
+
def load(dir_path, id: nil, special_folder: nil)
|
|
13
|
+
return nil unless Dir.exist?(dir_path)
|
|
14
|
+
|
|
15
|
+
hitl_file = Dir.glob(File.join(dir_path, Atoms::HitlFilePattern::FILE_GLOB)).first
|
|
16
|
+
return nil unless hitl_file
|
|
17
|
+
|
|
18
|
+
folder_name = File.basename(dir_path)
|
|
19
|
+
id ||= extract_id(folder_name)
|
|
20
|
+
|
|
21
|
+
content = File.read(hitl_file)
|
|
22
|
+
frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
|
|
23
|
+
answer = extract_answer(body)
|
|
24
|
+
|
|
25
|
+
title = frontmatter["title"] ||
|
|
26
|
+
Ace::Support::Items::Atoms::TitleExtractor.extract(body) ||
|
|
27
|
+
folder_name
|
|
28
|
+
|
|
29
|
+
created_at = parse_created_at(frontmatter["created_at"])
|
|
30
|
+
|
|
31
|
+
known_keys = %w[
|
|
32
|
+
id status title kind tags questions assignment step step_name
|
|
33
|
+
resume_instructions created_at answered answered_at
|
|
34
|
+
requester_provider requester_model requester_session_id
|
|
35
|
+
waiter_state waiter_session_id waiter_provider waiter_last_seen_at
|
|
36
|
+
waiter_poll_every_sec waiter_timeout_at
|
|
37
|
+
resume_dispatch_status resume_dispatch_attempted_at resumed_at resumed_by
|
|
38
|
+
resume_dispatch_error
|
|
39
|
+
]
|
|
40
|
+
extra_metadata = frontmatter.reject { |k, _| known_keys.include?(k) }
|
|
41
|
+
|
|
42
|
+
Models::HitlEvent.new(
|
|
43
|
+
id: id || frontmatter["id"],
|
|
44
|
+
status: frontmatter["status"] || "pending",
|
|
45
|
+
kind: frontmatter["kind"] || "clarification",
|
|
46
|
+
title: title,
|
|
47
|
+
tags: Array(frontmatter["tags"]),
|
|
48
|
+
questions: Array(frontmatter["questions"]),
|
|
49
|
+
answer: answer,
|
|
50
|
+
content: body.to_s.strip,
|
|
51
|
+
path: dir_path,
|
|
52
|
+
file_path: hitl_file,
|
|
53
|
+
special_folder: special_folder,
|
|
54
|
+
created_at: created_at,
|
|
55
|
+
metadata: extra_metadata.merge(
|
|
56
|
+
"assignment" => frontmatter["assignment"],
|
|
57
|
+
"step" => frontmatter["step"],
|
|
58
|
+
"step_name" => frontmatter["step_name"],
|
|
59
|
+
"resume_instructions" => frontmatter["resume_instructions"],
|
|
60
|
+
"answered" => frontmatter["answered"],
|
|
61
|
+
"answered_at" => frontmatter["answered_at"],
|
|
62
|
+
"requester_provider" => frontmatter["requester_provider"],
|
|
63
|
+
"requester_model" => frontmatter["requester_model"],
|
|
64
|
+
"requester_session_id" => frontmatter["requester_session_id"],
|
|
65
|
+
"waiter_state" => frontmatter["waiter_state"],
|
|
66
|
+
"waiter_session_id" => frontmatter["waiter_session_id"],
|
|
67
|
+
"waiter_provider" => frontmatter["waiter_provider"],
|
|
68
|
+
"waiter_last_seen_at" => frontmatter["waiter_last_seen_at"],
|
|
69
|
+
"waiter_poll_every_sec" => frontmatter["waiter_poll_every_sec"],
|
|
70
|
+
"waiter_timeout_at" => frontmatter["waiter_timeout_at"],
|
|
71
|
+
"resume_dispatch_status" => frontmatter["resume_dispatch_status"],
|
|
72
|
+
"resume_dispatch_attempted_at" => frontmatter["resume_dispatch_attempted_at"],
|
|
73
|
+
"resumed_at" => frontmatter["resumed_at"],
|
|
74
|
+
"resumed_by" => frontmatter["resumed_by"],
|
|
75
|
+
"resume_dispatch_error" => frontmatter["resume_dispatch_error"]
|
|
76
|
+
).compact
|
|
77
|
+
)
|
|
78
|
+
rescue SystemCallError, Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError => e
|
|
79
|
+
warn "Warning: Failed to load HITL event from #{dir_path}: #{e.class}: #{e.message}"
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def extract_id(folder_name)
|
|
86
|
+
match = folder_name.match(/^([0-9a-z]{6})/)
|
|
87
|
+
match ? match[1] : nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def extract_answer(body)
|
|
91
|
+
text = body.to_s
|
|
92
|
+
match = text.match(/^## Answer[ \t]*(?:\n(.*))?\z/m)
|
|
93
|
+
return nil unless match
|
|
94
|
+
|
|
95
|
+
answer = match[1].to_s.strip
|
|
96
|
+
answer.empty? ? nil : answer
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def parse_created_at(value)
|
|
100
|
+
return Time.now unless value
|
|
101
|
+
|
|
102
|
+
case value
|
|
103
|
+
when Time then value
|
|
104
|
+
when String then Time.parse(value)
|
|
105
|
+
else Time.now
|
|
106
|
+
end
|
|
107
|
+
rescue StandardError
|
|
108
|
+
Time.now
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/items"
|
|
4
|
+
require_relative "hitl_scanner"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Hitl
|
|
8
|
+
module Molecules
|
|
9
|
+
class HitlResolver
|
|
10
|
+
def initialize(root_dir)
|
|
11
|
+
@scanner = HitlScanner.new(root_dir)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def resolve(ref, warn_on_ambiguity: true)
|
|
15
|
+
scan_results = @scanner.scan
|
|
16
|
+
resolver = Ace::Support::Items::Molecules::ShortcutResolver.new(scan_results)
|
|
17
|
+
|
|
18
|
+
on_ambiguity = if warn_on_ambiguity
|
|
19
|
+
->(matches) {
|
|
20
|
+
ids = matches.map(&:id).join(", ")
|
|
21
|
+
warn "Warning: Ambiguous shortcut '#{ref}' matches #{matches.size} HITL events: #{ids}. Using most recent."
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
resolver.resolve(ref, on_ambiguity: on_ambiguity)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/items"
|
|
4
|
+
require_relative "../atoms/hitl_file_pattern"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Hitl
|
|
8
|
+
module Molecules
|
|
9
|
+
class HitlScanner
|
|
10
|
+
attr_reader :last_scan_total, :last_folder_counts
|
|
11
|
+
|
|
12
|
+
def initialize(root_dir)
|
|
13
|
+
@root_dir = root_dir
|
|
14
|
+
@scanner = Ace::Support::Items::Molecules::DirectoryScanner.new(
|
|
15
|
+
root_dir,
|
|
16
|
+
file_pattern: Atoms::HitlFilePattern::FILE_GLOB
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def scan
|
|
21
|
+
@scanner.scan
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def scan_in_folder(folder)
|
|
25
|
+
results = scan
|
|
26
|
+
@last_scan_total = results.size
|
|
27
|
+
@last_folder_counts = results.group_by(&:special_folder).transform_values(&:size)
|
|
28
|
+
return results if folder.nil?
|
|
29
|
+
|
|
30
|
+
virtual = Ace::Support::Items::Atoms::SpecialFolderDetector.virtual_filter?(folder)
|
|
31
|
+
case virtual
|
|
32
|
+
when :all then results
|
|
33
|
+
when :next then results.select { |r| r.special_folder.nil? }
|
|
34
|
+
else
|
|
35
|
+
normalized = Ace::Support::Items::Atoms::SpecialFolderDetector.normalize(folder)
|
|
36
|
+
results.select { |r| r.special_folder == normalized }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Hitl
|
|
8
|
+
module Molecules
|
|
9
|
+
# Dispatches resume signals to waiting agents.
|
|
10
|
+
# Primary path is provider/session resume; fallback path executes resume instructions.
|
|
11
|
+
class ResumeDispatcher
|
|
12
|
+
Result = Struct.new(:success?, :mode, :details, :error, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
def dispatch(event:, answer:, now: Time.now.utc)
|
|
15
|
+
session_id = event.metadata["requester_session_id"].to_s.strip
|
|
16
|
+
provider = event.metadata["requester_provider"].to_s.strip
|
|
17
|
+
instructions = event.metadata["resume_instructions"].to_s.strip
|
|
18
|
+
|
|
19
|
+
payload = build_payload(event: event, answer: answer, now: now)
|
|
20
|
+
|
|
21
|
+
if !session_id.empty? && !provider.empty?
|
|
22
|
+
resumed = dispatch_to_session(provider: provider, session_id: session_id, payload: payload)
|
|
23
|
+
return resumed if resumed.success?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
return failed("No resume instructions available") if instructions.empty?
|
|
27
|
+
|
|
28
|
+
shell = run_shell(instructions)
|
|
29
|
+
return ok(mode: "command", details: "resume_instructions") if shell[:status].success?
|
|
30
|
+
|
|
31
|
+
failed("Resume command failed: #{stderr_or_stdout(shell)}")
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
failed("Resume dispatch exception: #{e.class}: #{e.message}")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def build_payload(event:, answer:, now:)
|
|
39
|
+
lines = []
|
|
40
|
+
lines << "HITL event answer received."
|
|
41
|
+
lines << "id: #{event.id}"
|
|
42
|
+
assignment = event.metadata["assignment"]
|
|
43
|
+
step = event.metadata["step"]
|
|
44
|
+
lines << "assignment: #{assignment}" if assignment
|
|
45
|
+
lines << "step: #{step}" if step
|
|
46
|
+
lines << "answered_at: #{now.iso8601}"
|
|
47
|
+
lines << ""
|
|
48
|
+
lines << "answer:"
|
|
49
|
+
lines << answer.to_s
|
|
50
|
+
lines << ""
|
|
51
|
+
resume = event.metadata["resume_instructions"].to_s
|
|
52
|
+
lines << "resume_instructions: #{resume}" unless resume.strip.empty?
|
|
53
|
+
lines.join("\n")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def dispatch_to_session(provider:, session_id:, payload:)
|
|
57
|
+
case provider
|
|
58
|
+
when /\Acodex\z/i
|
|
59
|
+
cmd = ["codex", "exec", "resume", session_id]
|
|
60
|
+
io = run_command(cmd, stdin_data: payload)
|
|
61
|
+
return ok(mode: "session", details: "codex:#{session_id}") if io[:status].success?
|
|
62
|
+
failed("Codex session resume failed: #{stderr_or_stdout(io)}")
|
|
63
|
+
when /\Aclaude\z/i
|
|
64
|
+
cmd = ["claude", "-p", "--resume", session_id]
|
|
65
|
+
io = run_command(cmd, stdin_data: payload)
|
|
66
|
+
return ok(mode: "session", details: "claude:#{session_id}") if io[:status].success?
|
|
67
|
+
failed("Claude session resume failed: #{stderr_or_stdout(io)}")
|
|
68
|
+
else
|
|
69
|
+
failed("Unsupported resume provider '#{provider}'")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def run_shell(command)
|
|
74
|
+
stdout, stderr, status = Open3.capture3("bash", "-lc", command.to_s)
|
|
75
|
+
{stdout: stdout, stderr: stderr, status: status}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def run_command(cmd, stdin_data: nil)
|
|
79
|
+
stdout, stderr, status = Open3.capture3(*cmd, stdin_data: stdin_data.to_s)
|
|
80
|
+
{stdout: stdout, stderr: stderr, status: status}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def stderr_or_stdout(io)
|
|
84
|
+
out = io[:stderr].to_s.strip
|
|
85
|
+
out = io[:stdout].to_s.strip if out.empty?
|
|
86
|
+
out.empty? ? "(no output)" : out
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def ok(mode:, details:)
|
|
90
|
+
Result.new(success?: true, mode: mode, details: details, error: nil)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def failed(error)
|
|
94
|
+
Result.new(success?: false, mode: nil, details: nil, error: error)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Hitl
|
|
7
|
+
module Molecules
|
|
8
|
+
class WorktreeScopeResolver
|
|
9
|
+
VALID_SCOPES = %w[current all].freeze
|
|
10
|
+
|
|
11
|
+
def default_scope
|
|
12
|
+
main_checkout? ? "all" : "current"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def effective_scope(requested_scope)
|
|
16
|
+
requested_scope || default_scope
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def current_worktree_root
|
|
20
|
+
@current_worktree_root ||= git_output("rev-parse", "--show-toplevel")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def worktree_roots(scope:)
|
|
24
|
+
case scope
|
|
25
|
+
when "current"
|
|
26
|
+
[current_worktree_root].compact
|
|
27
|
+
when "all"
|
|
28
|
+
roots = parse_worktree_list
|
|
29
|
+
roots = [current_worktree_root].compact if roots.empty?
|
|
30
|
+
roots
|
|
31
|
+
else
|
|
32
|
+
[]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def main_checkout?
|
|
39
|
+
current = current_worktree_root
|
|
40
|
+
common_root = git_common_root
|
|
41
|
+
return false if current.nil? || common_root.nil?
|
|
42
|
+
|
|
43
|
+
File.expand_path(current) == File.expand_path(common_root)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def parse_worktree_list
|
|
47
|
+
output = git_output("worktree", "list", "--porcelain")
|
|
48
|
+
return [] if output.nil? || output.empty?
|
|
49
|
+
|
|
50
|
+
roots = output.lines.filter_map do |line|
|
|
51
|
+
next unless line.start_with?("worktree ")
|
|
52
|
+
|
|
53
|
+
line.sub("worktree ", "").strip
|
|
54
|
+
end
|
|
55
|
+
roots.uniq
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def git_common_root
|
|
59
|
+
common_dir = git_output("rev-parse", "--path-format=absolute", "--git-common-dir")
|
|
60
|
+
return nil if common_dir.nil? || common_dir.empty?
|
|
61
|
+
|
|
62
|
+
File.dirname(common_dir)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def git_output(*args)
|
|
66
|
+
stdout, status = Open3.capture2("git", *args)
|
|
67
|
+
return nil unless status.success?
|
|
68
|
+
|
|
69
|
+
output = stdout.to_s.strip
|
|
70
|
+
output.empty? ? nil : output
|
|
71
|
+
rescue StandardError
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|