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.
@@ -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
- Ace::Support::Items::Molecules::FieldUpdater.update(
130
- event.file_path,
131
- set: set,
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 load_config
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
- def apply_answer_update(file_path, answer)
412
- content = File.read(file_path)
413
- frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
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
- updated_body = Molecules::HitlAnswerEditor.apply(body, answer)
416
- answered = !answer.to_s.strip.empty?
417
- frontmatter["answered"] = answered
418
- frontmatter["status"] = answered ? "answered" : (frontmatter["status"] || "pending")
419
- frontmatter["answered_at"] = answered ? Time.now.utc : nil
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
- new_content = Ace::Support::Items::Atoms::FrontmatterSerializer.rebuild(frontmatter, updated_body)
422
- tmp_path = "#{file_path}.tmp.#{Process.pid}"
423
- File.write(tmp_path, new_content)
424
- File.rename(tmp_path, file_path)
425
- ensure
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
- def apply_field_and_answer_updates(file_path, set:, add:, remove:, answer:, first_answer: false)
430
- File.open(file_path, File::RDWR) do |file|
431
- file.flock(File::LOCK_EX)
432
- file.rewind
937
+ write_atomic(file_path,
938
+ Ace::Support::Items::Atoms::FrontmatterSerializer.rebuild(frontmatter, updated_body))
939
+ end
940
+ end
433
941
 
434
- content = file.read
435
- frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
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.match(/^## Answer[ \t]*(?:\n(.*))?\z/m)&.[](1).to_s.strip
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
- new_content = Ace::Support::Items::Atoms::FrontmatterSerializer.rebuild(frontmatter, updated_body)
454
- file.rewind
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
- content = File.read(event.file_path)
528
- frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
529
- updated_body = body.to_s.sub(/\s*\z/, "")
530
- updated_body << "\n\n## Resume Dispatch\n\n#{note}\n"
531
- rebuilt = Ace::Support::Items::Atoms::FrontmatterSerializer.rebuild(frontmatter, updated_body)
532
- File.write(event.file_path, rebuilt)
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