ace-hitl 0.24.2 → 0.25.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 +61 -1
- data/docs/usage.md +103 -2
- data/lib/ace/hitl/cli/commands/apply.rb +43 -0
- data/lib/ace/hitl/cli/commands/ask.rb +10 -3
- data/lib/ace/hitl/cli/commands/close.rb +37 -0
- data/lib/ace/hitl/cli/commands/history.rb +60 -0
- data/lib/ace/hitl/cli/commands/lesson.rb +47 -0
- data/lib/ace/hitl/cli/commands/reply.rb +65 -0
- data/lib/ace/hitl/cli/commands/suggest.rb +47 -0
- data/lib/ace/hitl/cli/commands/sweep.rb +33 -0
- data/lib/ace/hitl/cli.rb +22 -1
- data/lib/ace/hitl/molecules/case_file_writer.rb +68 -0
- data/lib/ace/hitl/molecules/hitl_creator.rb +6 -1
- data/lib/ace/hitl/molecules/hitl_lesson_editor.rb +43 -0
- data/lib/ace/hitl/molecules/hitl_suggestion_editor.rb +48 -0
- data/lib/ace/hitl/organisms/hitl_manager.rb +583 -65
- data/lib/ace/hitl/organisms/hitl_sender.rb +6 -3
- data/lib/ace/hitl/version.rb +1 -1
- data/lib/ace/hitl.rb +2 -0
- metadata +12 -2
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/items"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Hitl
|
|
7
|
+
module Molecules
|
|
8
|
+
# The one crash-atomic writer for HITL case files (re-review R2):
|
|
9
|
+
# every mutation locks a STABLE sidecar (`<case>.lock`, a file that
|
|
10
|
+
# is never renamed) and replaces the case file via tmp+fsync+rename.
|
|
11
|
+
# A process killed mid-write leaves either the old or the new
|
|
12
|
+
# content — never a truncated or zero-byte case — and mutual
|
|
13
|
+
# exclusion survives the rename (a lock on the case file itself
|
|
14
|
+
# would break the moment its inode is replaced).
|
|
15
|
+
module CaseFileWriter
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Exclusive sidecar lock around a case-file mutation.
|
|
19
|
+
def with_case_lock(file_path)
|
|
20
|
+
File.open("#{file_path}.lock", File::RDWR | File::CREAT, 0o600) do |lock|
|
|
21
|
+
lock.flock(File::LOCK_EX)
|
|
22
|
+
yield
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Crash-atomic replace of the whole case file (fsynced tmp +
|
|
27
|
+
# rename). Callers hold the sidecar lock.
|
|
28
|
+
def write_atomic(file_path, new_content)
|
|
29
|
+
tmp_path = "#{file_path}.tmp.#{Process.pid}.#{Thread.current.object_id}"
|
|
30
|
+
File.open(tmp_path, "w", 0o600) do |tmp|
|
|
31
|
+
tmp.write(new_content)
|
|
32
|
+
tmp.flush
|
|
33
|
+
tmp.fsync
|
|
34
|
+
end
|
|
35
|
+
File.rename(tmp_path, file_path)
|
|
36
|
+
ensure
|
|
37
|
+
File.unlink(tmp_path) if tmp_path && File.exist?(tmp_path)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Field-only update under the sidecar lock: one locked
|
|
41
|
+
# read-modify-write, replaced atomically — never the shared
|
|
42
|
+
# FieldUpdater's in-place truncate(0) write.
|
|
43
|
+
def apply_fields(file_path, set: {}, add: {}, remove: {})
|
|
44
|
+
with_case_lock(file_path) do
|
|
45
|
+
content = File.read(file_path)
|
|
46
|
+
begin
|
|
47
|
+
frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse!(content)
|
|
48
|
+
rescue Psych::Exception => e
|
|
49
|
+
# Same error shape as FieldUpdater.parse_strict: a corrupt
|
|
50
|
+
# case file is a named, path-carrying refusal to rewrite —
|
|
51
|
+
# never a raw parser error escaping the writer.
|
|
52
|
+
raise ArgumentError, "#{file_path}: refusing to rewrite the file (#{e.message})"
|
|
53
|
+
end
|
|
54
|
+
# Strip the body's leading newline: rebuild re-inserts the
|
|
55
|
+
# frontmatter/body separator, so keeping it would add one
|
|
56
|
+
# blank line per write — compounding on every field update.
|
|
57
|
+
body = body.sub(/\A\n/, "")
|
|
58
|
+
Ace::Support::Items::Molecules::FieldUpdater.apply_set(frontmatter, set)
|
|
59
|
+
Ace::Support::Items::Molecules::FieldUpdater.apply_add(frontmatter, add)
|
|
60
|
+
Ace::Support::Items::Molecules::FieldUpdater.apply_remove(frontmatter, remove)
|
|
61
|
+
write_atomic(file_path,
|
|
62
|
+
Ace::Support::Items::Atoms::FrontmatterSerializer.rebuild(frontmatter, body))
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -29,6 +29,7 @@ module Ace
|
|
|
29
29
|
step: nil,
|
|
30
30
|
step_name: nil,
|
|
31
31
|
resume_instructions: nil,
|
|
32
|
+
secret: nil,
|
|
32
33
|
move_to: nil,
|
|
33
34
|
recipient: nil,
|
|
34
35
|
reply_to: nil,
|
|
@@ -80,6 +81,7 @@ module Ace
|
|
|
80
81
|
"step_name" => step_name,
|
|
81
82
|
"resume_instructions" => resume_instructions,
|
|
82
83
|
"answered" => false,
|
|
84
|
+
"secret" => secret == true ? true : nil,
|
|
83
85
|
"created_at" => time
|
|
84
86
|
}.merge(recipient_keys(recipient, id))
|
|
85
87
|
.merge(reply_to_keys(reply_to, id))
|
|
@@ -91,7 +93,10 @@ module Ace
|
|
|
91
93
|
content = Ace::Support::Items::Atoms::FrontmatterSerializer.rebuild(frontmatter, body_text)
|
|
92
94
|
|
|
93
95
|
item_file = File.join(item_dir, Atoms::HitlFilePattern.filename(id, file_slug))
|
|
94
|
-
|
|
96
|
+
# 0600 from the first byte: a HITL case can carry secret
|
|
97
|
+
# material (secret asks), so it is never world-readable — even
|
|
98
|
+
# between creation and any later hardening write.
|
|
99
|
+
File.open(item_file, "w", 0o600) { |file| file.write(content) }
|
|
95
100
|
|
|
96
101
|
loader = HitlLoader.new
|
|
97
102
|
special_folder = Ace::Support::Items::Atoms::SpecialFolderDetector.detect_in_path(
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Hitl
|
|
5
|
+
module Molecules
|
|
6
|
+
# Lesson editor (task 8xs.t.pu0.c): the takeaway the advisory agent
|
|
7
|
+
# draws from comparing the human decision with its suggestion — its
|
|
8
|
+
# OWN `## Lesson` section at the same case, inserted BEFORE the
|
|
9
|
+
# `## Answer` section like the suggestion (answer rewrites can never
|
|
10
|
+
# remove history). A lesson references the exact question/suggestion/
|
|
11
|
+
# decision revisions it compared; it is not a new decision and never
|
|
12
|
+
# changes the answer's authorship.
|
|
13
|
+
class HitlLessonEditor
|
|
14
|
+
ANSWER_HEADER = /^## Answer\s*$/m
|
|
15
|
+
|
|
16
|
+
# @param body [String] current case body
|
|
17
|
+
# @param lesson [Hash] {"id","author","relation","takeaway",
|
|
18
|
+
# "refs","at"}
|
|
19
|
+
def self.apply(body, lesson)
|
|
20
|
+
block = render(lesson)
|
|
21
|
+
source = body.to_s.rstrip
|
|
22
|
+
return block if source.empty?
|
|
23
|
+
|
|
24
|
+
if source.match?(ANSWER_HEADER)
|
|
25
|
+
source.sub(ANSWER_HEADER) { "#{block}\n\n## Answer" }
|
|
26
|
+
else
|
|
27
|
+
"#{source}\n\n#{block}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.render(lesson)
|
|
32
|
+
lines = ["## Lesson"]
|
|
33
|
+
lines << "author: #{lesson.fetch("author")}"
|
|
34
|
+
lines << "at: #{lesson.fetch("at")}"
|
|
35
|
+
lines << "relation: #{lesson.fetch("relation")}"
|
|
36
|
+
lines << "refs: #{Array(lesson["refs"]).join(", ")}" if lesson["refs"]
|
|
37
|
+
lines << "" << "takeaway: #{lesson.fetch("takeaway")}"
|
|
38
|
+
lines.join("\n") + "\n"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Hitl
|
|
5
|
+
module Molecules
|
|
6
|
+
# Marked-suggestion editor (task 8xs.t.pu0.b): a suggestion is its
|
|
7
|
+
# OWN section in the canonical case file — never the Answer. It is
|
|
8
|
+
# inserted BEFORE the `## Answer` section (or appended at the end
|
|
9
|
+
# when no answer exists), so the Answer editor's tail replacement
|
|
10
|
+
# can never remove a suggestion, and an answer written after a
|
|
11
|
+
# suggestion preserves it. Pure string surgery — atomicity and
|
|
12
|
+
# locking belong to the manager's transition helper.
|
|
13
|
+
class HitlSuggestionEditor
|
|
14
|
+
ANSWER_HEADER = /^## Answer\s*$/m
|
|
15
|
+
SUGGESTION_HEADER = "## Suggestion"
|
|
16
|
+
|
|
17
|
+
# @param body [String] current case body (frontmatter stripped)
|
|
18
|
+
# @param suggestion [Hash] {"id","author","text","rationale",
|
|
19
|
+
# "refs","at"}
|
|
20
|
+
# @return [String] the updated body
|
|
21
|
+
def self.apply(body, suggestion)
|
|
22
|
+
block = render(suggestion)
|
|
23
|
+
source = body.to_s.rstrip
|
|
24
|
+
return block if source.empty?
|
|
25
|
+
|
|
26
|
+
if source.match?(ANSWER_HEADER)
|
|
27
|
+
# Insert strictly before the Answer section: the answer tail
|
|
28
|
+
# replacement never touches what precedes it.
|
|
29
|
+
source.sub(ANSWER_HEADER) { "#{block}\n\n## Answer" }
|
|
30
|
+
else
|
|
31
|
+
"#{source}\n\n#{block}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [String] the rendered marked suggestion block
|
|
36
|
+
def self.render(suggestion)
|
|
37
|
+
lines = [SUGGESTION_HEADER]
|
|
38
|
+
lines << "author: #{suggestion.fetch("author")}"
|
|
39
|
+
lines << "at: #{suggestion.fetch("at")}"
|
|
40
|
+
lines << "refs: #{Array(suggestion["refs"]).join(", ")}" if suggestion["refs"]
|
|
41
|
+
lines << "" << "proposal: #{suggestion.fetch("text")}" << ""
|
|
42
|
+
lines << "rationale: #{suggestion.fetch("rationale")}"
|
|
43
|
+
lines.join("\n") + "\n"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|