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
|
@@ -9,6 +9,8 @@ require_relative "../molecules/hitl_scanner"
|
|
|
9
9
|
require_relative "../molecules/hitl_loader"
|
|
10
10
|
require_relative "../molecules/hitl_creator"
|
|
11
11
|
require_relative "../molecules/hitl_answer_editor"
|
|
12
|
+
require_relative "../molecules/hitl_suggestion_editor"
|
|
13
|
+
require_relative "../molecules/case_file_writer"
|
|
12
14
|
require_relative "../molecules/resume_dispatcher"
|
|
13
15
|
require_relative "../molecules/worktree_scope_resolver"
|
|
14
16
|
|
|
@@ -19,7 +21,7 @@ module Ace
|
|
|
19
21
|
class AmbiguousReferenceError < StandardError
|
|
20
22
|
attr_reader :ref, :matches
|
|
21
23
|
|
|
22
|
-
def initialize(ref, matches)
|
|
24
|
+
private def initialize(ref, matches)
|
|
23
25
|
@ref = ref
|
|
24
26
|
@matches = matches
|
|
25
27
|
super("Ambiguous HITL reference '#{ref}'")
|
|
@@ -28,10 +30,27 @@ module Ace
|
|
|
28
30
|
|
|
29
31
|
class NotAnswerableError < StandardError; end
|
|
30
32
|
class AnswerConflictError < StandardError; end
|
|
33
|
+
class UnknownCaseError < StandardError; end
|
|
34
|
+
class LifecycleTransitionError < StandardError; end
|
|
35
|
+
# Shared rule for late writes to terminal cases: once the
|
|
36
|
+
# application is closed or cancelled, no NEW answer, suggestion or
|
|
37
|
+
# lesson lands in the file. Re-delivering the identical answer to
|
|
38
|
+
# a terminal case stays an idempotent no-op (transport replay
|
|
39
|
+
# safety); anything that would change the case is refused.
|
|
40
|
+
class TerminalCaseError < StandardError; end
|
|
41
|
+
|
|
42
|
+
# A late suggestion on an already-answered case is discarded by
|
|
43
|
+
# the advisor, never written (the human decision stands and needs
|
|
44
|
+
# no second approval); a suggestion never touches a secret case.
|
|
45
|
+
class SuggestionRefusedError < StandardError; end
|
|
46
|
+
# A lesson needs a suggestion to compare against and an accepted
|
|
47
|
+
# human decision to learn from — a missing suggestion is no
|
|
48
|
+
# comparison, never a fabricated agreement.
|
|
49
|
+
class LessonRefusedError < StandardError; end
|
|
31
50
|
|
|
32
51
|
attr_reader :root_dir, :last_list_total, :last_folder_counts, :config
|
|
33
52
|
|
|
34
|
-
def initialize(root_dir: nil, config: nil, scope_resolver: nil, resume_dispatcher: nil,
|
|
53
|
+
private def initialize(root_dir: nil, config: nil, scope_resolver: nil, resume_dispatcher: nil,
|
|
35
54
|
address_resolver: nil)
|
|
36
55
|
@config = config || load_config
|
|
37
56
|
@configured_root_setting = @config.dig("hitl", "root_dir") || Molecules::HitlConfigLoader::DEFAULT_ROOT_DIR
|
|
@@ -110,6 +129,493 @@ module Ace
|
|
|
110
129
|
events
|
|
111
130
|
end
|
|
112
131
|
|
|
132
|
+
# Receive a delivered human answer for EXACTLY one case (task
|
|
133
|
+
# 8xs.t.pu0.5 — the reply half of the Hermes → owner-machine
|
|
134
|
+
# delivery). The reference must be the case's exact id — no
|
|
135
|
+
# shortcut or latest-match resolution, so a reply can never land
|
|
136
|
+
# in a foreign case on collision. Idempotent: re-delivering the
|
|
137
|
+
# same answer for an already-answered case reports :duplicate
|
|
138
|
+
# without a second write; a DIFFERENT answer conflicts and never
|
|
139
|
+
# overwrites the human decision. The durable case file is the
|
|
140
|
+
# record — an absent asker is fine, the armed wake consumes the
|
|
141
|
+
# change whenever the asking session returns.
|
|
142
|
+
# @return [Symbol] :received or :duplicate
|
|
143
|
+
# @raise [UnknownCaseError] no case with this exact id
|
|
144
|
+
# @raise [AmbiguousReferenceError] the reference collides
|
|
145
|
+
# @raise [NotAnswerableError] the case is a report
|
|
146
|
+
# @raise [AnswerConflictError] a different answer already exists
|
|
147
|
+
def receive_answer(ref, answer, scope: nil)
|
|
148
|
+
resolved = resolve_for_mutation(ref, scope: scope)
|
|
149
|
+
raise UnknownCaseError, "no HITL case with exact id '#{ref}'" if resolved.nil?
|
|
150
|
+
if resolved[:event].id.to_s != ref.to_s
|
|
151
|
+
raise UnknownCaseError,
|
|
152
|
+
"'#{ref}' is not an exact case id (resolved to '#{resolved[:event].id}'); " \
|
|
153
|
+
"reply requires the exact case id from the ask"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
event = resolved[:event]
|
|
157
|
+
if event.report?
|
|
158
|
+
raise NotAnswerableError,
|
|
159
|
+
"HITL event '#{event.id}' is a report; reports do not await an answer"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
content = File.read(event.file_path)
|
|
163
|
+
frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse!(content)
|
|
164
|
+
previous = body_section(body, "Answer").strip
|
|
165
|
+
if frontmatter["answered"] == true || !previous.empty?
|
|
166
|
+
if previous == answer.to_s.strip
|
|
167
|
+
return :duplicate
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
raise AnswerConflictError,
|
|
171
|
+
"case '#{event.id}' already has a different answer; a reply never overwrites a decision"
|
|
172
|
+
end
|
|
173
|
+
# A terminal case (closed/cancelled) never accepts a NEW answer;
|
|
174
|
+
# the answered/duplicate handling above keeps idempotent replay of
|
|
175
|
+
# an answered case a no-op regardless of application state.
|
|
176
|
+
refuse_terminal!(frontmatter, event.id)
|
|
177
|
+
|
|
178
|
+
apply_field_and_answer_updates(event.file_path, set: {}, add: {}, remove: {},
|
|
179
|
+
answer: answer, first_answer: true)
|
|
180
|
+
:received
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# --- case lifecycle (task 8xs.t.pu0.6) ------------------------------
|
|
184
|
+
|
|
185
|
+
# The case's lifecycle facts, read from the canonical file only:
|
|
186
|
+
# receipt (pending/received) and application
|
|
187
|
+
# (pending/applied/closed/cancelled). Reports stay `recorded` —
|
|
188
|
+
# they never await an answer or application.
|
|
189
|
+
# @return [Hash] {"receipt"=>, "application"=>, "answered"=>,
|
|
190
|
+
# "effect_ref"=>, "answered_at"=>}
|
|
191
|
+
def case_lifecycle(ref, scope: nil)
|
|
192
|
+
event = exact_case(ref, scope: scope)
|
|
193
|
+
frontmatter, _body = read_frontmatter(event.file_path)
|
|
194
|
+
{"receipt" => event.report? ? "recorded" : (frontmatter["answered"] ? "received" : "pending"),
|
|
195
|
+
"application" => event.report? ? "recorded" : (frontmatter.dig("application", "state") || "pending"),
|
|
196
|
+
"answered" => frontmatter["answered"] == true,
|
|
197
|
+
"effect_ref" => frontmatter.dig("application", "ref"),
|
|
198
|
+
"answered_at" => frontmatter["answered_at"]}
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Record that the answer's effect was applied — with the
|
|
202
|
+
# reference to the effect OWNER (task/assignment/PR) the
|
|
203
|
+
# consumer verified. Idempotent per reference: re-recording the
|
|
204
|
+
# same effect ref is a no-op; a different effect ref for an
|
|
205
|
+
# applied case is a conflict. Closing happens only after this
|
|
206
|
+
# proof exists.
|
|
207
|
+
# @return [Symbol] :applied or :duplicate
|
|
208
|
+
def apply_answer_effect(ref, effect_ref, scope: nil)
|
|
209
|
+
require_effect_ref!(effect_ref)
|
|
210
|
+
event = exact_case(ref, scope: scope)
|
|
211
|
+
ensure_answered!(event)
|
|
212
|
+
|
|
213
|
+
transition(event.file_path) do |frontmatter, _body|
|
|
214
|
+
application = frontmatter["application"] || {}
|
|
215
|
+
case application["state"].to_s
|
|
216
|
+
when "", "pending"
|
|
217
|
+
frontmatter["application"] = {"state" => "applied", "ref" => effect_ref,
|
|
218
|
+
"applied_at" => timestamp}
|
|
219
|
+
:applied
|
|
220
|
+
when "applied"
|
|
221
|
+
raise LifecycleTransitionError,
|
|
222
|
+
"case '#{event.id}' already applied with effect '#{application["ref"]}'; " \
|
|
223
|
+
"a different effect reference is a conflict" unless application["ref"] == effect_ref
|
|
224
|
+
|
|
225
|
+
:duplicate
|
|
226
|
+
when "closed"
|
|
227
|
+
raise LifecycleTransitionError,
|
|
228
|
+
"case '#{event.id}' is closed; a closed case never re-opens — " \
|
|
229
|
+
"an explicit decision change needs a new case"
|
|
230
|
+
else
|
|
231
|
+
raise LifecycleTransitionError,
|
|
232
|
+
"case '#{event.id}' is #{application["state"]}; it cannot be applied"
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Terminal close: only with a proven applied effect. A duplicate
|
|
238
|
+
# close is a no-op; a close without the applied proof fails —
|
|
239
|
+
# exactly-once belongs to the EFFECT, never to the close itself.
|
|
240
|
+
# @return [Symbol] :closed or :duplicate
|
|
241
|
+
def close_case(ref, scope: nil)
|
|
242
|
+
event = exact_case(ref, scope: scope)
|
|
243
|
+
ensure_answered!(event)
|
|
244
|
+
|
|
245
|
+
transition(event.file_path) do |frontmatter, _body|
|
|
246
|
+
application = frontmatter["application"] || {}
|
|
247
|
+
case application["state"].to_s
|
|
248
|
+
when "", "pending"
|
|
249
|
+
raise LifecycleTransitionError,
|
|
250
|
+
"case '#{event.id}' has no applied effect; close only after the " \
|
|
251
|
+
"applied proof (ace-hitl apply CASE --ref OWNER_REF)"
|
|
252
|
+
when "applied"
|
|
253
|
+
frontmatter["application"] = application.merge("state" => "closed",
|
|
254
|
+
"closed_at" => timestamp)
|
|
255
|
+
:closed
|
|
256
|
+
when "closed"
|
|
257
|
+
:duplicate
|
|
258
|
+
else
|
|
259
|
+
raise LifecycleTransitionError,
|
|
260
|
+
"case '#{event.id}' is #{application["state"]}; it cannot be closed"
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Terminal without application: the question was abandoned
|
|
266
|
+
# (superseded, withdrawn) without its answer ever being applied.
|
|
267
|
+
# Only from the not-yet-applied states — an applied effect is
|
|
268
|
+
# history and is closed, not cancelled.
|
|
269
|
+
# @return [Symbol] :cancelled or :duplicate
|
|
270
|
+
def cancel_case(ref, scope: nil)
|
|
271
|
+
event = exact_case(ref, scope: scope)
|
|
272
|
+
|
|
273
|
+
transition(event.file_path) do |frontmatter, _body|
|
|
274
|
+
application = frontmatter["application"] || {}
|
|
275
|
+
case application["state"].to_s
|
|
276
|
+
when "", "pending"
|
|
277
|
+
frontmatter["application"] = {"state" => "cancelled",
|
|
278
|
+
"cancelled_at" => timestamp}
|
|
279
|
+
:cancelled
|
|
280
|
+
when "cancelled"
|
|
281
|
+
:duplicate
|
|
282
|
+
else
|
|
283
|
+
raise LifecycleTransitionError,
|
|
284
|
+
"case '#{event.id}' is #{application["state"]}; only an unanswered, " \
|
|
285
|
+
"unapplied case can be cancelled — close applied cases instead"
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Sweep of transport leftovers (task 8xs.t.pu0.6): qualify the
|
|
291
|
+
# ask-bridge wake-request drop files (and any recorded delivery
|
|
292
|
+
# remnants) against the CANONICAL case files. Deletion candidates
|
|
293
|
+
# are ONLY requests whose case is terminally closed or cancelled;
|
|
294
|
+
# active cases (pending/received/applied), unknown ids and the
|
|
295
|
+
# canonical decision history are always preserved.
|
|
296
|
+
# @return [Hash] {"qualified" => [{request:, case:, application:}],
|
|
297
|
+
# "preserved" => [{request:, reason:}]}
|
|
298
|
+
# --- marked suggestions (task 8xs.t.pu0.b) -------------------------
|
|
299
|
+
#
|
|
300
|
+
# One marked suggestion per case: an ADVISORY proposal with its
|
|
301
|
+
# author, rationale and grounds, written as its own section
|
|
302
|
+
# before the Answer (an answer rewrite can never remove it). It
|
|
303
|
+
# is not an Answer, never satisfies a gate, and never acts in
|
|
304
|
+
# the human's name. Idempotent: an identical repeat is a no-op;
|
|
305
|
+
# a case that already carries ANY suggestion refuses a second,
|
|
306
|
+
# different one. A secret case is never delegated; a late
|
|
307
|
+
# suggestion on an answered case is discarded, never written.
|
|
308
|
+
# @return [Symbol] :written or :duplicate
|
|
309
|
+
def add_suggestion(ref, author:, text:, rationale:, refs: [], scope: nil)
|
|
310
|
+
event = exact_case(ref, scope: scope)
|
|
311
|
+
|
|
312
|
+
rewrite_case(event.file_path) do |frontmatter, body|
|
|
313
|
+
refuse_terminal!(frontmatter, event.id)
|
|
314
|
+
if frontmatter["secret"] == true
|
|
315
|
+
raise SuggestionRefusedError,
|
|
316
|
+
"case '#{event.id}' is marked secret; secret cases are never delegated to the advisor"
|
|
317
|
+
end
|
|
318
|
+
if frontmatter["answered"] == true
|
|
319
|
+
raise SuggestionRefusedError,
|
|
320
|
+
"case '#{event.id}' is already answered; discard the late suggestion - " \
|
|
321
|
+
"the human decision stands and needs no second approval"
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
if body.include?("## Suggestion")
|
|
325
|
+
next [:duplicate, nil, nil] if suggestion_matches?(body, author, text)
|
|
326
|
+
|
|
327
|
+
raise SuggestionRefusedError,
|
|
328
|
+
"case '#{event.id}' already carries a suggestion; one proposal per case"
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
suggestion = {"id" => "#{event.id}-s1", "author" => author.to_s,
|
|
332
|
+
"text" => text.to_s.strip, "rationale" => rationale.to_s.strip,
|
|
333
|
+
"refs" => Array(refs).map(&:to_s).reject(&:empty?),
|
|
334
|
+
"at" => timestamp}
|
|
335
|
+
updated = Molecules::HitlSuggestionEditor.apply(body, suggestion)
|
|
336
|
+
|
|
337
|
+
[:written, frontmatter, updated]
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# --- lessons on real decisions (task 8xs.t.pu0.c) -------------------
|
|
342
|
+
#
|
|
343
|
+
# After the human answers a case that carries a suggestion, the
|
|
344
|
+
# advisory agent compares the decision with its proposal and
|
|
345
|
+
# writes ONE lesson at the same case: relation
|
|
346
|
+
# (accepted|rejected|modified), the takeaway, references to the
|
|
347
|
+
# exact question/suggestion/decision revisions, its own author
|
|
348
|
+
# identity and time. The lesson never overwrites the question,
|
|
349
|
+
# the suggestion, or the decision, never changes the answer's
|
|
350
|
+
# author, and is idempotent per case+decision (an identical
|
|
351
|
+
# repeat is a no-op). A case WITHOUT a suggestion has nothing to
|
|
352
|
+
# compare — refused, never a fabricated agreement.
|
|
353
|
+
# @return [Symbol] :written or :duplicate
|
|
354
|
+
def add_lesson(ref, by:, relation:, takeaway:, refs: [], scope: nil)
|
|
355
|
+
event = exact_case(ref, scope: scope)
|
|
356
|
+
|
|
357
|
+
rewrite_case(event.file_path) do |frontmatter, body|
|
|
358
|
+
refuse_terminal!(frontmatter, event.id)
|
|
359
|
+
unless body.include?("## Suggestion")
|
|
360
|
+
raise LessonRefusedError,
|
|
361
|
+
"case '#{event.id}' carries no suggestion; there is nothing to compare"
|
|
362
|
+
end
|
|
363
|
+
unless frontmatter["answered"] == true
|
|
364
|
+
raise LessonRefusedError,
|
|
365
|
+
"case '#{event.id}' has no accepted human decision to learn from"
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
if body.include?("## Lesson")
|
|
369
|
+
next [:duplicate, nil, nil] if lesson_matches?(body, by, takeaway)
|
|
370
|
+
|
|
371
|
+
raise LessonRefusedError,
|
|
372
|
+
"case '#{event.id}' already carries a lesson; reprocessing does not multiply them"
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
unless %w[accepted rejected modified].include?(relation.to_s)
|
|
376
|
+
raise LessonRefusedError,
|
|
377
|
+
"relation must be accepted, rejected or modified (got '#{relation}')"
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
decision_refs = ["case:#{event.id}", "suggestion:#{event.id}-s1"]
|
|
381
|
+
decision_refs << "decision:#{frontmatter["answered_at"]}" if frontmatter["answered_at"]
|
|
382
|
+
lesson = {"id" => "#{event.id}-l1", "author" => by.to_s,
|
|
383
|
+
"relation" => relation.to_s, "takeaway" => takeaway.to_s.strip,
|
|
384
|
+
"refs" => Array(refs).map(&:to_s).reject(&:empty?).push(*decision_refs).uniq,
|
|
385
|
+
"at" => timestamp}
|
|
386
|
+
updated = Molecules::HitlLessonEditor.apply(body, lesson)
|
|
387
|
+
|
|
388
|
+
[:written, frontmatter, updated]
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Decision history for the advisory agent's NEXT suggestion (task
|
|
393
|
+
# 8xs.t.pu0.c): the answered, NON-SECRET question cases of THIS
|
|
394
|
+
# project scope — each row carrying the citable references (case
|
|
395
|
+
# id, kind, suggestion proposal, decision, lesson takeaway+refs)
|
|
396
|
+
# so every appeal to a precedent is checkable at its source.
|
|
397
|
+
# Secret/OTP cases are filtered OUT before anything reaches a
|
|
398
|
+
# model; the wider lab overseer's access is not a license to
|
|
399
|
+
# share other projects' history. No embedding store, no decision
|
|
400
|
+
# index, no corpus copy: HITL stays the single source.
|
|
401
|
+
# @return [Array<Hash>]
|
|
402
|
+
def decision_history(scope: "current", limit: 50)
|
|
403
|
+
events = list(in_folder: "all", scope: scope, type: "question")
|
|
404
|
+
events.select { |event| history_eligible?(event) }.first(limit).filter_map do |event|
|
|
405
|
+
begin
|
|
406
|
+
content = File.read(event.file_path)
|
|
407
|
+
rescue Errno::ENOENT
|
|
408
|
+
# The case vanished between the eligibility read and this
|
|
409
|
+
# read: it leaves history quietly, never crashes the report.
|
|
410
|
+
next
|
|
411
|
+
end
|
|
412
|
+
frontmatter, case_body = Ace::Support::Items::Atoms::FrontmatterParser.parse!(content)
|
|
413
|
+
answer = body_section(case_body, "Answer").strip
|
|
414
|
+
suggestion = body_section(case_body, "Suggestion")
|
|
415
|
+
lesson = body_section(case_body, "Lesson")
|
|
416
|
+
next if answer.empty?
|
|
417
|
+
|
|
418
|
+
{"id" => event.id, "title" => event.title, "kind" => event.kind,
|
|
419
|
+
"status" => event.status,
|
|
420
|
+
"suggestion" => suggestion[/^proposal: (.+)$/, 1],
|
|
421
|
+
"decision" => answer,
|
|
422
|
+
"lesson" => lesson[/^takeaway: (.+)$/, 1],
|
|
423
|
+
"lesson_relation" => lesson[/^relation: (accepted|rejected|modified)$/, 1],
|
|
424
|
+
"decision_at" => frontmatter["answered_at"]}
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
private def history_eligible?(event)
|
|
430
|
+
frontmatter = read_frontmatter(event.file_path)[0]
|
|
431
|
+
return false if frontmatter["secret"] == true
|
|
432
|
+
return false unless frontmatter["answered"] == true
|
|
433
|
+
|
|
434
|
+
# Only cases whose decision the advisor may cite: they carry a
|
|
435
|
+
# visible decision in this scope's own store.
|
|
436
|
+
true
|
|
437
|
+
rescue Errno::ENOENT
|
|
438
|
+
false
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# The content of one `## <Title>` section, up to the next `## `
|
|
442
|
+
# header: history rows and idempotency checks never bleed into
|
|
443
|
+
# neighbouring sections — a `proposal:` line quoted in the question
|
|
444
|
+
# body or a Resume Dispatch appended after the Answer stays where
|
|
445
|
+
# it belongs.
|
|
446
|
+
private def body_section(body, title)
|
|
447
|
+
body[/^## #{Regexp.escape(title)}[ \t]*\n(.*?)(?=^## |\z)/m, 1].to_s
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
private def lesson_matches?(body, by, takeaway)
|
|
451
|
+
section = body_section(body, "Lesson")
|
|
452
|
+
section.match?(/^author: #{Regexp.escape(by.to_s)}$/) &&
|
|
453
|
+
section.match?(/^takeaway: #{Regexp.escape(takeaway.to_s.strip)}$/)
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def sweep(apply: false)
|
|
458
|
+
requests_dir = File.join(File.expand_path(@root_dir), "wake-requests")
|
|
459
|
+
return {"qualified" => [], "preserved" => [], "removed" => []} unless Dir.exist?(requests_dir)
|
|
460
|
+
|
|
461
|
+
qualified = []
|
|
462
|
+
preserved = []
|
|
463
|
+
removed = []
|
|
464
|
+
Dir.glob(File.join(requests_dir, "*.json")).sort.each do |request_path|
|
|
465
|
+
begin
|
|
466
|
+
request = JSON.parse(File.read(request_path))
|
|
467
|
+
raise TypeError, "not a JSON object" unless request.is_a?(Hash)
|
|
468
|
+
rescue JSON::ParserError, TypeError, Errno::ENOENT, Errno::EACCES
|
|
469
|
+
preserved << {"request" => request_path, "case" => nil,
|
|
470
|
+
"reason" => "unreadable request — preserved"}
|
|
471
|
+
next
|
|
472
|
+
end
|
|
473
|
+
case_id = request["case_id"].to_s
|
|
474
|
+
lifecycle = begin
|
|
475
|
+
case_lifecycle(case_id)
|
|
476
|
+
rescue UnknownCaseError, AmbiguousReferenceError
|
|
477
|
+
nil
|
|
478
|
+
end
|
|
479
|
+
application = lifecycle && lifecycle["application"]
|
|
480
|
+
if %w[closed cancelled].include?(application.to_s)
|
|
481
|
+
entry = {"request" => request_path, "case" => case_id, "application" => application}
|
|
482
|
+
qualified << entry
|
|
483
|
+
if apply
|
|
484
|
+
File.delete(request_path)
|
|
485
|
+
removed << entry
|
|
486
|
+
end
|
|
487
|
+
else
|
|
488
|
+
preserved << {"request" => request_path, "case" => case_id,
|
|
489
|
+
"reason" => application.nil? ? "unknown case — preserved" :
|
|
490
|
+
"case application is '#{application}' (active)"}
|
|
491
|
+
end
|
|
492
|
+
rescue JSON::ParserError
|
|
493
|
+
preserved << {"request" => request_path, "case" => nil,
|
|
494
|
+
"reason" => "unparseable request — preserved"}
|
|
495
|
+
end
|
|
496
|
+
{"qualified" => qualified, "preserved" => preserved, "removed" => removed}
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
private def exact_case(ref, scope: nil)
|
|
500
|
+
resolved = resolve_for_mutation(ref, scope: scope)
|
|
501
|
+
raise UnknownCaseError, "no HITL case with exact id '#{ref}'" if resolved.nil?
|
|
502
|
+
if resolved[:event].id.to_s != ref.to_s
|
|
503
|
+
raise UnknownCaseError,
|
|
504
|
+
"'#{ref}' is not an exact case id (resolved to '#{resolved[:event].id}')"
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
resolved[:event]
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
private def require_effect_ref!(effect_ref)
|
|
511
|
+
unless effect_ref.is_a?(String) && !effect_ref.strip.empty?
|
|
512
|
+
raise LifecycleTransitionError,
|
|
513
|
+
"an effect reference is required (the task/assignment/PR that owns the effect)"
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
private def ensure_answered!(event)
|
|
518
|
+
frontmatter, _body = read_frontmatter(event.file_path)
|
|
519
|
+
return if frontmatter["answered"] == true
|
|
520
|
+
|
|
521
|
+
raise LifecycleTransitionError,
|
|
522
|
+
"case '#{event.id}' has no received answer; deliver the answer first " \
|
|
523
|
+
"(ace-hitl reply CASE --stdin)"
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
private def read_frontmatter(file_path)
|
|
527
|
+
content = File.read(file_path)
|
|
528
|
+
frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse!(content)
|
|
529
|
+
[frontmatter, body.sub(/\A\n/, "")]
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# The case's stable sidecar lock: mutual exclusion for every
|
|
533
|
+
# read-validate-write cycle on a case file. The lock lives on a
|
|
534
|
+
# file that is NEVER renamed — locking the case file itself would
|
|
535
|
+
# break the moment an atomic tmp+rename write replaces its inode
|
|
536
|
+
# (a waiter blocked on the old inode would then "hold" a lock on
|
|
537
|
+
# a detached file and silently lose updates).
|
|
538
|
+
private def with_case_lock(file_path, &block)
|
|
539
|
+
File.open("#{file_path}.lock", File::RDWR | File::CREAT, 0o600) do |lock|
|
|
540
|
+
lock.flock(File::LOCK_EX)
|
|
541
|
+
block.call
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# Atomic, conflict-rejecting lifecycle transition: exclusive lock,
|
|
546
|
+
# validate-and-mutate frontmatter in one pass, serialize back.
|
|
547
|
+
private def transition(file_path)
|
|
548
|
+
with_case_lock(file_path) do
|
|
549
|
+
content = File.read(file_path)
|
|
550
|
+
frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse!(content)
|
|
551
|
+
# Strip before BOTH the yield and the rebuild: rebuild re-inserts
|
|
552
|
+
# the frontmatter/body separator, so rebuilding the raw body
|
|
553
|
+
# would add one blank line per transition — compounding.
|
|
554
|
+
stripped = body.sub(/\A\n/, "")
|
|
555
|
+
result = yield(frontmatter, stripped)
|
|
556
|
+
write_atomic(file_path,
|
|
557
|
+
Ace::Support::Items::Atoms::FrontmatterSerializer.rebuild(frontmatter, stripped))
|
|
558
|
+
result
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
# Read-validate-write in ONE pass under the exclusive lock, against
|
|
563
|
+
# the CURRENT content: the block reads, validates and drafts from
|
|
564
|
+
# the same body that is finally written, so a decision made from a
|
|
565
|
+
# pre-lock read can never overwrite a concurrent write (a reply
|
|
566
|
+
# landing between read and lock). The block returns
|
|
567
|
+
# [result, frontmatter, body]; a nil body means "no write" (the
|
|
568
|
+
# idempotent duplicate path leaves the file untouched).
|
|
569
|
+
private def rewrite_case(file_path)
|
|
570
|
+
with_case_lock(file_path) do
|
|
571
|
+
content = File.read(file_path)
|
|
572
|
+
frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse!(content)
|
|
573
|
+
result, new_frontmatter, new_body = yield(frontmatter, body.sub(/\A\n/, ""))
|
|
574
|
+
if new_body
|
|
575
|
+
write_atomic(file_path,
|
|
576
|
+
Ace::Support::Items::Atoms::FrontmatterSerializer.rebuild(new_frontmatter, new_body))
|
|
577
|
+
end
|
|
578
|
+
result
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
# Crash-atomic replace: the new content is fsynced to a 0600 tmp
|
|
583
|
+
# file (never world-readable for an instant, even before the
|
|
584
|
+
# rename) and renamed over the case file, so a crash mid-write
|
|
585
|
+
# never leaves a truncated or half-serialized case behind.
|
|
586
|
+
# Callers hold the sidecar lock, so the rename never breaks
|
|
587
|
+
# mutual exclusion.
|
|
588
|
+
private def write_atomic(file_path, new_content)
|
|
589
|
+
tmp_path = "#{file_path}.tmp.#{Process.pid}.#{Thread.current.object_id}"
|
|
590
|
+
File.open(tmp_path, "w", 0o600) do |tmp|
|
|
591
|
+
tmp.write(new_content)
|
|
592
|
+
tmp.flush
|
|
593
|
+
tmp.fsync
|
|
594
|
+
end
|
|
595
|
+
File.rename(tmp_path, file_path)
|
|
596
|
+
ensure
|
|
597
|
+
File.unlink(tmp_path) if tmp_path && File.exist?(tmp_path)
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# The case's application state when it is terminal (closed or
|
|
601
|
+
# cancelled), else nil.
|
|
602
|
+
private def terminal_application_state(frontmatter)
|
|
603
|
+
state = frontmatter.dig("application", "state").to_s
|
|
604
|
+
%w[closed cancelled].include?(state) ? state : nil
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
private def refuse_terminal!(frontmatter, case_id)
|
|
608
|
+
state = terminal_application_state(frontmatter)
|
|
609
|
+
return unless state
|
|
610
|
+
|
|
611
|
+
raise TerminalCaseError,
|
|
612
|
+
"case '#{case_id}' is #{state}; a terminal case never accepts a late answer or suggestion"
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
private def timestamp
|
|
616
|
+
Time.now.utc.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
617
|
+
end
|
|
618
|
+
|
|
113
619
|
def update(ref, set: {}, add: {}, remove: {}, move_to: nil, answer: nil, scope: nil)
|
|
114
620
|
resolved = resolve_for_mutation(ref, scope: scope)
|
|
115
621
|
return nil unless resolved
|
|
@@ -126,16 +632,11 @@ module Ace
|
|
|
126
632
|
if has_field_updates && !answer.nil?
|
|
127
633
|
apply_field_and_answer_updates(event.file_path, set: set, add: add, remove: remove, answer: answer)
|
|
128
634
|
elsif has_field_updates
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
add: add,
|
|
133
|
-
remove: remove
|
|
134
|
-
)
|
|
635
|
+
apply_field_updates(event.file_path, set: set, add: add, remove: remove)
|
|
636
|
+
else
|
|
637
|
+
apply_answer_update(event.file_path, answer) unless answer.nil?
|
|
135
638
|
end
|
|
136
639
|
|
|
137
|
-
apply_answer_update(event.file_path, answer) unless answer.nil?
|
|
138
|
-
|
|
139
640
|
current_path = event.path
|
|
140
641
|
current_special = event.special_folder
|
|
141
642
|
if move_to
|
|
@@ -273,21 +774,26 @@ module Ace
|
|
|
273
774
|
{status: :dispatched, event: archived, mode: result.mode, details: result.details}
|
|
274
775
|
end
|
|
275
776
|
|
|
276
|
-
private
|
|
277
777
|
|
|
278
|
-
def
|
|
778
|
+
private def suggestion_matches?(body, author, text)
|
|
779
|
+
section = body_section(body, "Suggestion")
|
|
780
|
+
section.match?(/^author: #{Regexp.escape(author.to_s)}$/) &&
|
|
781
|
+
section.match?(/^proposal: #{Regexp.escape(text.to_s.strip)}$/)
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
private def load_config
|
|
279
785
|
Molecules::HitlConfigLoader.load
|
|
280
786
|
end
|
|
281
787
|
|
|
282
|
-
def resolve_root_dir
|
|
788
|
+
private def resolve_root_dir
|
|
283
789
|
Molecules::HitlConfigLoader.root_dir(@config)
|
|
284
790
|
end
|
|
285
791
|
|
|
286
|
-
def ensure_root_dir
|
|
792
|
+
private def ensure_root_dir
|
|
287
793
|
FileUtils.mkdir_p(@root_dir) unless Dir.exist?(@root_dir)
|
|
288
794
|
end
|
|
289
795
|
|
|
290
|
-
def resolve_for_mutation(ref, scope: nil)
|
|
796
|
+
private def resolve_for_mutation(ref, scope: nil)
|
|
291
797
|
effective_scope = @scope_resolver.effective_scope(scope)
|
|
292
798
|
roots = hitl_roots_for_scope(effective_scope)
|
|
293
799
|
|
|
@@ -299,7 +805,7 @@ module Ace
|
|
|
299
805
|
resolved
|
|
300
806
|
end
|
|
301
807
|
|
|
302
|
-
def resolve_from_roots(ref, hitl_roots, strict_ambiguity: false)
|
|
808
|
+
private def resolve_from_roots(ref, hitl_roots, strict_ambiguity: false)
|
|
303
809
|
root_results = hitl_roots.uniq.filter_map do |hitl_root|
|
|
304
810
|
scanner = Molecules::HitlScanner.new(hitl_root)
|
|
305
811
|
results = scanner.scan
|
|
@@ -332,7 +838,7 @@ module Ace
|
|
|
332
838
|
}
|
|
333
839
|
end
|
|
334
840
|
|
|
335
|
-
def scan_results_for_scope(scope, in_folder:)
|
|
841
|
+
private def scan_results_for_scope(scope, in_folder:)
|
|
336
842
|
total = 0
|
|
337
843
|
folder_counts = Hash.new(0)
|
|
338
844
|
|
|
@@ -349,18 +855,18 @@ module Ace
|
|
|
349
855
|
results
|
|
350
856
|
end
|
|
351
857
|
|
|
352
|
-
def load_events(scan_results)
|
|
858
|
+
private def load_events(scan_results)
|
|
353
859
|
scan_results.filter_map { |scan_result| load_event(scan_result) }
|
|
354
860
|
end
|
|
355
861
|
|
|
356
|
-
def load_event(scan_result)
|
|
862
|
+
private def load_event(scan_result)
|
|
357
863
|
loader = Molecules::HitlLoader.new
|
|
358
864
|
loader.load(scan_result.dir_path,
|
|
359
865
|
id: scan_result.id,
|
|
360
866
|
special_folder: scan_result.special_folder)
|
|
361
867
|
end
|
|
362
868
|
|
|
363
|
-
def hitl_roots_for_scope(scope)
|
|
869
|
+
private def hitl_roots_for_scope(scope)
|
|
364
870
|
roots = @scope_resolver.worktree_roots(scope: scope)
|
|
365
871
|
roots = [@scope_resolver.current_worktree_root].compact if roots.empty?
|
|
366
872
|
roots = [nil] if roots.empty?
|
|
@@ -373,7 +879,7 @@ module Ace
|
|
|
373
879
|
end.uniq
|
|
374
880
|
end
|
|
375
881
|
|
|
376
|
-
def current_hitl_root
|
|
882
|
+
private def current_hitl_root
|
|
377
883
|
root = @scope_resolver.current_worktree_root
|
|
378
884
|
return @root_dir if root.nil?
|
|
379
885
|
return @configured_root_setting if Pathname.new(@configured_root_setting).absolute?
|
|
@@ -381,7 +887,7 @@ module Ace
|
|
|
381
887
|
File.join(root, @configured_root_setting)
|
|
382
888
|
end
|
|
383
889
|
|
|
384
|
-
def worktree_root_from_hitl_root(hitl_root)
|
|
890
|
+
private def worktree_root_from_hitl_root(hitl_root)
|
|
385
891
|
return nil if hitl_root.nil?
|
|
386
892
|
return nil if Pathname.new(@configured_root_setting).absolute?
|
|
387
893
|
|
|
@@ -393,11 +899,11 @@ module Ace
|
|
|
393
899
|
expanded[0...-suffix.length]
|
|
394
900
|
end
|
|
395
901
|
|
|
396
|
-
def filter_by_tags(events, tags)
|
|
902
|
+
private def filter_by_tags(events, tags)
|
|
397
903
|
events.select { |event| tags.any? { |tag| event.tags.include?(tag) } }
|
|
398
904
|
end
|
|
399
905
|
|
|
400
|
-
def filter_by_delivery(events, delivery)
|
|
906
|
+
private def filter_by_delivery(events, delivery)
|
|
401
907
|
case delivery.to_s
|
|
402
908
|
when "undelivered"
|
|
403
909
|
events.select { |event| Molecules::HitlDeliveryState.undelivered?(event.metadata["delivery"]) }
|
|
@@ -408,37 +914,51 @@ module Ace
|
|
|
408
914
|
end
|
|
409
915
|
end
|
|
410
916
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
917
|
+
# Field-only update (e.g. the waiter lease refresh): the SAME
|
|
918
|
+
# crash-atomic posture as every other case writer — one locked
|
|
919
|
+
# read-modify-write replaced via tmp+fsync+rename (see
|
|
920
|
+
# Molecules::CaseFileWriter; re-review R2).
|
|
921
|
+
private def apply_field_updates(file_path, set:, add:, remove:)
|
|
922
|
+
Molecules::CaseFileWriter.apply_fields(file_path, set: set, add: add, remove: remove)
|
|
923
|
+
end
|
|
414
924
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
925
|
+
private def apply_answer_update(file_path, answer)
|
|
926
|
+
with_case_lock(file_path) do
|
|
927
|
+
content = File.read(file_path)
|
|
928
|
+
frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse!(content)
|
|
929
|
+
body = body.sub(/\A\n/, "")
|
|
420
930
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
File.unlink(tmp_path) if tmp_path && File.exist?(tmp_path)
|
|
427
|
-
end
|
|
931
|
+
updated_body = Molecules::HitlAnswerEditor.apply(body, answer)
|
|
932
|
+
answered = !answer.to_s.strip.empty?
|
|
933
|
+
frontmatter["answered"] = answered
|
|
934
|
+
frontmatter["status"] = answered ? "answered" : (frontmatter["status"] || "pending")
|
|
935
|
+
frontmatter["answered_at"] = answered ? Time.now.utc : nil
|
|
428
936
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
937
|
+
write_atomic(file_path,
|
|
938
|
+
Ace::Support::Items::Atoms::FrontmatterSerializer.rebuild(frontmatter, updated_body))
|
|
939
|
+
end
|
|
940
|
+
end
|
|
433
941
|
|
|
434
|
-
|
|
435
|
-
|
|
942
|
+
private def apply_field_and_answer_updates(file_path, set:, add:, remove:, answer:, first_answer: false)
|
|
943
|
+
with_case_lock(file_path) do
|
|
944
|
+
content = File.read(file_path)
|
|
945
|
+
frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse!(content)
|
|
436
946
|
body = body.sub(/\A\n/, "")
|
|
437
|
-
previous = body
|
|
947
|
+
previous = body_section(body, "Answer").strip
|
|
438
948
|
if first_answer && (frontmatter["answered"] == true || !previous.empty?)
|
|
439
949
|
raise AnswerConflictError, "case already has a different answer" unless previous == answer.to_s.strip
|
|
440
950
|
return
|
|
441
951
|
end
|
|
952
|
+
# Authoritative terminal check under the lock: a case whose
|
|
953
|
+
# application is closed or cancelled never accepts a NEW
|
|
954
|
+
# answer (a late reply to a cancelled case is refused, not
|
|
955
|
+
# recorded). The identical-answer return above keeps
|
|
956
|
+
# idempotent replay of a closed case a no-op.
|
|
957
|
+
if first_answer && terminal_application_state(frontmatter)
|
|
958
|
+
raise TerminalCaseError,
|
|
959
|
+
"case application is #{frontmatter.dig("application", "state")}; " \
|
|
960
|
+
"a terminal case never accepts a late answer"
|
|
961
|
+
end
|
|
442
962
|
|
|
443
963
|
Ace::Support::Items::Molecules::FieldUpdater.apply_set(frontmatter, set)
|
|
444
964
|
Ace::Support::Items::Molecules::FieldUpdater.apply_add(frontmatter, add)
|
|
@@ -450,16 +970,12 @@ module Ace
|
|
|
450
970
|
frontmatter["status"] = answered ? "answered" : (frontmatter["status"] || "pending")
|
|
451
971
|
frontmatter["answered_at"] = answered ? Time.now.utc : nil
|
|
452
972
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
file.truncate(0)
|
|
456
|
-
file.write(new_content)
|
|
457
|
-
file.flush
|
|
458
|
-
file.fsync
|
|
973
|
+
write_atomic(file_path,
|
|
974
|
+
Ace::Support::Items::Atoms::FrontmatterSerializer.rebuild(frontmatter, updated_body))
|
|
459
975
|
end
|
|
460
976
|
end
|
|
461
977
|
|
|
462
|
-
def parse_archive_date(event)
|
|
978
|
+
private def parse_archive_date(event)
|
|
463
979
|
raw = event.metadata["answered_at"] || event.metadata["created_at"] || event.created_at
|
|
464
980
|
return nil unless raw
|
|
465
981
|
|
|
@@ -473,7 +989,7 @@ module Ace
|
|
|
473
989
|
nil
|
|
474
990
|
end
|
|
475
991
|
|
|
476
|
-
def refresh_waiter_lease(event, now:, deadline:, poll_every:, waiter_session_id:, waiter_provider:, scope:)
|
|
992
|
+
private def refresh_waiter_lease(event, now:, deadline:, poll_every:, waiter_session_id:, waiter_provider:, scope:)
|
|
477
993
|
set = {
|
|
478
994
|
"waiter_state" => "waiting",
|
|
479
995
|
"waiter_last_seen_at" => now.iso8601,
|
|
@@ -485,7 +1001,7 @@ module Ace
|
|
|
485
1001
|
update(event.id, set: set, scope: scope)
|
|
486
1002
|
end
|
|
487
1003
|
|
|
488
|
-
def waiter_active?(event, now:)
|
|
1004
|
+
private def waiter_active?(event, now:)
|
|
489
1005
|
return false unless event.metadata["waiter_state"].to_s == "waiting"
|
|
490
1006
|
|
|
491
1007
|
last_seen = parse_time(event.metadata["waiter_last_seen_at"])
|
|
@@ -496,7 +1012,7 @@ module Ace
|
|
|
496
1012
|
(now - last_seen) <= (interval * 2)
|
|
497
1013
|
end
|
|
498
1014
|
|
|
499
|
-
def parse_time(raw)
|
|
1015
|
+
private def parse_time(raw)
|
|
500
1016
|
return raw if raw.is_a?(Time)
|
|
501
1017
|
return nil if raw.nil?
|
|
502
1018
|
|
|
@@ -505,31 +1021,33 @@ module Ace
|
|
|
505
1021
|
nil
|
|
506
1022
|
end
|
|
507
1023
|
|
|
508
|
-
def normalize_poll_seconds(value)
|
|
1024
|
+
private def normalize_poll_seconds(value)
|
|
509
1025
|
parsed = value.to_i
|
|
510
1026
|
return 600 if parsed <= 0
|
|
511
1027
|
|
|
512
1028
|
parsed
|
|
513
1029
|
end
|
|
514
1030
|
|
|
515
|
-
def normalize_timeout_seconds(value)
|
|
1031
|
+
private def normalize_timeout_seconds(value)
|
|
516
1032
|
parsed = value.to_i
|
|
517
1033
|
return 14_400 if parsed <= 0
|
|
518
1034
|
|
|
519
1035
|
parsed
|
|
520
1036
|
end
|
|
521
1037
|
|
|
522
|
-
def append_resume_note(ref, note, scope: nil)
|
|
1038
|
+
private def append_resume_note(ref, note, scope: nil)
|
|
523
1039
|
current = show(ref, scope: scope)
|
|
524
1040
|
return unless current
|
|
525
1041
|
|
|
526
1042
|
event = current[:event]
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
1043
|
+
with_case_lock(event.file_path) do
|
|
1044
|
+
content = File.read(event.file_path)
|
|
1045
|
+
frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse!(content)
|
|
1046
|
+
updated_body = body.to_s.sub(/\A\n/, "").sub(/\s*\z/, "")
|
|
1047
|
+
updated_body << "\n\n## Resume Dispatch\n\n#{note}\n"
|
|
1048
|
+
write_atomic(event.file_path,
|
|
1049
|
+
Ace::Support::Items::Atoms::FrontmatterSerializer.rebuild(frontmatter, updated_body))
|
|
1050
|
+
end
|
|
533
1051
|
end
|
|
534
1052
|
end
|
|
535
1053
|
end
|