samagotchi 0.92.0 → 0.93.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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -1
  3. data/bin/chi +4 -4
  4. data/docs/architecture.md +4 -4
  5. data/docs/guardrails.md +4 -5
  6. data/docs/internals/engine.md +6 -0
  7. data/docs/sessions.md +1 -1
  8. data/docs/testing.md +2 -1
  9. data/lib/samagotchi/bootstrap/probe.rb +2 -2
  10. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  11. data/lib/samagotchi/child_reports.rb +12 -14
  12. data/lib/samagotchi/client_id.rb +18 -0
  13. data/lib/samagotchi/config/restart_watch.rb +112 -0
  14. data/lib/samagotchi/config.rb +18 -59
  15. data/lib/samagotchi/engine.rb +4 -14
  16. data/lib/samagotchi/guardrails/data_readers.rb +34 -30
  17. data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
  18. data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
  19. data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
  20. data/lib/samagotchi/hook_question.rb +21 -0
  21. data/lib/samagotchi/hook_routes.rb +6 -6
  22. data/lib/samagotchi/hooks/registry.rb +5 -5
  23. data/lib/samagotchi/host_registry.rb +1 -1
  24. data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
  25. data/lib/samagotchi/parent_answers_command.rb +6 -6
  26. data/lib/samagotchi/plugin/context.rb +6 -5
  27. data/lib/samagotchi/plugin/http_transport.rb +14 -3
  28. data/lib/samagotchi/plugin/sessions.rb +2 -3
  29. data/lib/samagotchi/question_desk.rb +12 -9
  30. data/lib/samagotchi/reply_wait/poll.rb +267 -0
  31. data/lib/samagotchi/reply_wait.rb +47 -190
  32. data/lib/samagotchi/send_command/parent_gate.rb +83 -0
  33. data/lib/samagotchi/send_command/wait_target.rb +55 -0
  34. data/lib/samagotchi/send_command.rb +31 -100
  35. data/lib/samagotchi/session.rb +10 -9
  36. data/lib/samagotchi/session_inbox.rb +31 -8
  37. data/lib/samagotchi/session_manager.rb +48 -15
  38. data/lib/samagotchi/session_metrics.rb +22 -3
  39. data/lib/samagotchi/sessions_command.rb +4 -4
  40. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
  41. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  42. data/lib/samagotchi/testing/bundle.rb +1 -1
  43. data/lib/samagotchi/testing/host.rb +2 -8
  44. data/lib/samagotchi/testing/recorder.rb +6 -9
  45. data/lib/samagotchi/tools/delegate.rb +10 -10
  46. data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
  47. data/lib/samagotchi/tools/delegate_relay.rb +31 -16
  48. data/lib/samagotchi/tools/delegate_wait.rb +4 -4
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +46 -63
  51. data/lib/samagotchi/web/public/app.js +122 -556
  52. data/lib/samagotchi/web/public/index.html +1 -1
  53. data/lib/samagotchi/web/public/send_turn.js +173 -0
  54. data/lib/samagotchi/web/public/session_actions.js +203 -0
  55. data/lib/samagotchi/web/public/start_page.js +236 -0
  56. metadata +10 -1
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../reply_wait"
4
+ require_relative "../session"
5
+
6
+ module Samagotchi
7
+ class SendCommand
8
+ # Which turn chi send --wait waits for and names in its report:
9
+ # ReplyWait's +turn+ (an enqueued_id; nil: the next reply), +queued+
10
+ # and +ended_before+, and ParentWait's +report_turn_id+ (the turn_id
11
+ # of the --format json line). Each run path builds it its way.
12
+ WaitTarget = Data.define(:turn, :report_turn_id, :queued, :ended_before) do
13
+ def initialize(turn: nil, report_turn_id: nil, queued: false, ended_before: false) = super
14
+
15
+ # A --new session's first message. A file-delivered one's own turn
16
+ # (its id also names the outcome of a session command that runs no
17
+ # turn); through the Bridge, the next reply.
18
+ # @param delivered [SessionManager::DeliverResult, nil] nil when the
19
+ # message started the session itself (no delivery)
20
+ def self.first_message(delivered)
21
+ return new unless delivered
22
+
23
+ new(turn: delivered.file? ? delivered.enqueued_id : nil, report_turn_id: delivered.enqueued_id)
24
+ end
25
+
26
+ # A message to an existing session. --queue: its own turn, the ack's
27
+ # id, also when the turn running at the check ended before it went in
28
+ # (an idle worker's ack says next_step, and the file still runs as
29
+ # that turn). A file fallback's id names its turn too. Otherwise the
30
+ # next reply, reported as the turn that was running when it went in
31
+ # (+running_turn+, ReplyWait.own_turn), or as its own on an idle one.
32
+ # @param running [Boolean] the session was running when it went in
33
+ def self.message(delivered, queue:, running:, running_turn:)
34
+ turn = delivered.enqueued_id if queue || delivered.file?
35
+ report = if queue then turn
36
+ elsif running then running_turn
37
+ else delivered.enqueued_id
38
+ end
39
+ new(turn: turn, report_turn_id: report, queued: queue && !turn.nil?)
40
+ end
41
+
42
+ # --wait with no message: --turn's (+requested+), or the turn running
43
+ # now or whose continue offer stands (ReplyWait.own_turn). Settled
44
+ # (idle, a turn ended, no question standing: a continue offer waits
45
+ # for its answer), its last turn's end is the answer (ended_before);
46
+ # with --turn, when that turn is the last one.
47
+ def self.wait_only(session, session_dir:, requested:)
48
+ turn = requested || ReplyWait.own_turn(session, session_dir: session_dir)
49
+ settled = session.status == Session::STATUS_IDLE && !session.last_turn.nil? && session.pending_question.nil?
50
+ ended_before = requested ? session.last_turn&.enqueued_id == requested : settled
51
+ new(turn: turn, report_turn_id: turn || (session.last_turn&.enqueued_id if settled), ended_before: ended_before)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -23,6 +23,8 @@ require_relative "vision_support"
23
23
  require_relative "cli/command"
24
24
  require_relative "cli/flags"
25
25
  require_relative "cli/parent_wait"
26
+ require_relative "send_command/parent_gate"
27
+ require_relative "send_command/wait_target"
26
28
 
27
29
  module Samagotchi
28
30
  # `chi send`: put text into sessions as the user's message, the same as
@@ -252,19 +254,14 @@ module Samagotchi
252
254
  return refuse_local_command(local, options)
253
255
  end
254
256
 
255
- # A parent agent's /continue yes is chi answer --option Continue by
256
- # another route: held to guardrails.parent_answers (tightened by each
257
- # target's own) here, and marked as chi answer's (#client_id_for) so
258
- # the worker checks it again.
259
- if prompt && parent_continue?(prompt) && (refusal = continue_refusal_for(options))
257
+ # A parent agent's /continue yes held to guardrails.parent_answers,
258
+ # and a /parent-answers that loosens: refused before anything is sent.
259
+ gate = ParentGate.new(options, state_dir: @state_dir)
260
+ if prompt && (refusal = gate.continue_refusal(prompt))
260
261
  @stderr.puts("chi send: #{refusal}")
261
262
  return CLI::Exit::FAILED
262
263
  end
263
- # chi send never loosens a session's own parent_answers (only a user
264
- # at a UI does: the worker refuses it too); refused before anything
265
- # is sent. Tightening goes.
266
- if prompt && SessionCommands.builtin_registry.lookup(prompt)&.id == :parent_answers &&
267
- target_parent_answers(options).any? { |value| ParentAnswersCommand.loosens?(prompt, value) }
264
+ if prompt && gate.loosens?(prompt)
268
265
  error_line("chi send: refused: #{ParentAnswersCommand::REFUSED}")
269
266
  return CLI::Exit::FAILED
270
267
  end
@@ -365,11 +362,12 @@ module Samagotchi
365
362
  options[:thinking] = Thinking.session_level(word)
366
363
  end
367
364
  if options[:parent_answers]
368
- word = options[:parent_answers].strip.downcase
369
- levels = Guardrails::ParentPolicy::ORDER
370
- return usage_error("--parent-answers takes #{levels.join(", ")}, not #{options[:parent_answers]}") unless levels.include?(word)
365
+ level = Guardrails::ParentPolicy.parse_level(options[:parent_answers])
366
+ unless Guardrails::ParentPolicy::ORDER.include?(level)
367
+ return usage_error("--parent-answers takes #{Guardrails::ParentPolicy::ORDER.join(", ")}, not #{options[:parent_answers]}")
368
+ end
371
369
 
372
- options[:parent_answers] = word
370
+ options[:parent_answers] = level
373
371
  end
374
372
  toolkit = Toolkit.from_flags(name: options[:toolkit], add: options[:tools], remove: options[:no_tools])
375
373
  options[:toolkit_value] = toolkit if toolkit
@@ -453,8 +451,7 @@ module Samagotchi
453
451
  return report_command(session.id) if @sent_command
454
452
 
455
453
  wait_for_reply(session.id, cursor: nil, baseline: baseline_of(session, question_id: nil),
456
- timeout: options[:timeout], turn: @sent_input_file ? @sent_enqueued_id : nil,
457
- report_turn_id: @sent_enqueued_id)
454
+ timeout: options[:timeout], **WaitTarget.first_message(@delivered).to_h)
458
455
  end
459
456
 
460
457
  # The model the new session will run, for the images check: --model
@@ -526,29 +523,15 @@ module Samagotchi
526
523
  return 1 unless deliver(id, prompt)
527
524
  return report_command(id) if @sent_command
528
525
 
529
- # The ack's id names the queued message's turn, also when the turn
530
- # running at the check ended before it went in (an idle worker's ack
531
- # says next_step, and the file still runs as that turn).
532
- # A file fallback has no delivery field; use its id to see terminal
533
- # outcomes for commands that finish without starting a turn.
534
- turn = @sent_enqueued_id if @queue || @sent_input_file
535
- report_turn_id = if @queue
536
- turn
537
- elsif session.status == Session::STATUS_RUNNING
538
- running_turn
539
- else
540
- @sent_enqueued_id
541
- end
542
- wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout], turn: turn,
543
- queued: @queue && !turn.nil?,
544
- report_turn_id: report_turn_id)
526
+ target = WaitTarget.message(@delivered, queue: @queue, running: session.status == Session::STATUS_RUNNING,
527
+ running_turn: running_turn)
528
+ wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout], **target.to_h)
545
529
  end
546
530
 
547
531
  # --wait with no message: the session's next reply, sending nothing,
548
532
  # so an agent can go back to waiting after exit 3 or 130. A running
549
533
  # turn is fine here (nothing joins its queue); a question pending now
550
- # was already reported. No message count: a note landing between turns
551
- # grows the messages without a turn. An idle session with no worker
534
+ # was already reported. An idle session with no worker
552
535
  # waits for whatever wakes one (the web, chi send) rather than calling
553
536
  # it gone. The turn running now, or the one whose continue offer
554
537
  # stands, is the one waited for (ReplyWait.own_turn): its reply, not a
@@ -568,17 +551,10 @@ module Samagotchi
568
551
  return 1
569
552
  end
570
553
  live = session.status == Session::STATUS_RUNNING || SessionManager.session_owner(id, state_dir: @state_dir)
571
- turn = requested_turn || ReplyWait.own_turn(session, session_dir: session_dir)
572
- # The gate for the settled case: idle, a turn ended (last_turn), no
573
- # question standing (a continue offer waits for its answer).
574
- settled = session.status == Session::STATUS_IDLE && session.last_turn && session.pending_question.nil?
575
- ended_before = requested_turn ? session.last_turn&.enqueued_id == requested_turn : settled
576
- report_turn_id = turn
577
- report_turn_id ||= session.last_turn&.enqueued_id if settled
554
+ target = WaitTarget.wait_only(session, session_dir: session_dir, requested: requested_turn)
578
555
  wait_for_reply(id, cursor: ReplyWait.newest_reply(id, state_dir: @state_dir),
579
- baseline: baseline_of(session).merge(messages: nil),
580
- timeout: options[:timeout], owner_grace: live ? self.class::WORKER_GONE_AFTER : nil,
581
- turn: turn, ended_before: ended_before, report_turn_id: report_turn_id)
556
+ baseline: baseline_of(session),
557
+ timeout: options[:timeout], owner_grace: live ? self.class::WORKER_GONE_AFTER : nil, **target.to_h)
582
558
  end
583
559
 
584
560
  # The session as it was before the message went in (ReplyWait's
@@ -610,66 +586,22 @@ module Samagotchi
610
586
  @info.puts("#{session.id} failed: its worker did not start; the session is kept (chi --attach #{session.id})")
611
587
  return false
612
588
  end
613
- unless result[:status] == :accepted
614
- @info.puts("#{session.id} failed: #{result.dig(:ack, "detail") || "could not queue it"}")
589
+ unless result.accepted?
590
+ @info.puts("#{session.id} failed: #{result.detail || "could not queue it"}")
615
591
  return false
616
592
  end
617
593
 
618
- ack = result[:ack].is_a?(Hash) ? result[:ack] : {}
619
- @sent_input_file = !ack.key?("delivery")
620
- @sent_enqueued_id = (ack["enqueued_id"] || ack[:enqueued_id])&.to_s
621
- @sent_command = command_ack?(result[:ack])
594
+ @delivered = result
595
+ @sent_command = result.command?
622
596
  @info.puts("#{session.id} started#{continued_words(session)}#{with_images}#{"; #{SENT_COMMAND}" if @sent_command}")
623
597
  true
624
598
  end
625
599
 
626
600
  SENT_COMMAND = "sent as a session command"
627
601
 
628
- # Whether the worker took the message as a session command (a POST
629
- # /turn whose line is one answers with its command_id).
630
- def command_ack?(ack) = ack.is_a?(Hash) && !ack["command_id"].to_s.empty?
631
-
632
602
  # chi send's client id, or chi answer's for a parent agent's /continue
633
603
  # yes (the worker holds that to its guardrails.parent_answers).
634
- def client_id_for(prompt) = parent_continue?(prompt) ? ClientId::CLI_ANSWER : CLIENT_ID
635
-
636
- # The first refusal of a parent's /continue yes among the targets, each
637
- # by config.yml tightened by its own parent_answers; nil when none
638
- # refuses.
639
- def continue_refusal_for(options)
640
- target_parent_answers(options).each do |value|
641
- strategy = Guardrails::ParentPolicy.effective(value)
642
- refusal = Guardrails::ParentPolicy.continue_refusal(strategy: strategy)
643
- return refusal if refusal
644
- end
645
- nil
646
- end
647
-
648
- # The targets' own parent_answers values: a --new session's
649
- # --parent-answers, a --continues link's previous session's (the link
650
- # keeps it), each id's. An id that doesn't resolve is skipped (#resolve
651
- # reports it later); none left is config.yml's alone ([nil]).
652
- def target_parent_answers(options)
653
- return [options[:parent_answers]] if options[:new] && !options[:continues]
654
-
655
- sessions = if options[:new]
656
- [target_session { SessionChain.resolve(options[:continues], state_dir: @state_dir) }]
657
- else
658
- options[:ids].map { |given| target_session { Session.resolve_id(given, state_dir: @state_dir) } }
659
- end
660
- values = sessions.compact.map(&:parent_answers)
661
- values.empty? ? [nil] : values
662
- end
663
-
664
- # The session the block's id names, or nil when there is none.
665
- def target_session
666
- Session.load(yield, state_dir: @state_dir)
667
- rescue ArgumentError
668
- nil
669
- end
670
-
671
- # A /continue yes (or bare /continue) from a parent agent's process.
672
- def parent_continue?(prompt) = TurnFlow.continue_yes?(prompt) && Guardrails::ParentApprovals.parent_process?
604
+ def client_id_for(prompt) = ParentGate.parent_continue?(prompt) ? ClientId::CLI_ANSWER : CLIENT_ID
673
605
 
674
606
  # Whether +text+ may be a session command, which only its worker knows
675
607
  # (a bundle's too).
@@ -740,26 +672,25 @@ module Samagotchi
740
672
  end
741
673
  result = SessionManager.deliver_turn(id, prompt: prompt, client_id: client_id_for(prompt), images: refs, state_dir: @state_dir,
742
674
  delivery: delivery)
743
- unless result[:status] == :accepted
675
+ unless result.accepted?
744
676
  # A worker older than queueing would steer it: refused, not sent.
745
- word = result.dig(:ack, "error") == Delivery::QUEUE_UNAVAILABLE["error"] ? "refused" : "failed"
746
- @info.puts("#{short} #{word}: #{result.dig(:ack, "detail") || "could not queue it"}")
677
+ word = result.error == Delivery::QUEUE_UNAVAILABLE["error"] ? "refused" : "failed"
678
+ @info.puts("#{short} #{word}: #{result.detail || "could not queue it"}")
747
679
  return false
748
680
  end
749
681
 
750
- @sent_command = command_ack?(result[:ack])
682
+ @sent_command = result.command?
751
683
  if @sent_command
752
684
  @info.puts("#{short} #{SENT_COMMAND}")
753
685
  return true
754
686
  end
755
687
 
756
- ack = result[:ack].is_a?(Hash) ? result[:ack] : {}
757
- @sent_input_file = !ack.key?("delivery")
688
+ ack = result.ack
689
+ @delivered = result
758
690
  # What the worker applied, for --format json: its ack's delivery
759
691
  # (next_step for an idle session) and a cut's outcome.
760
692
  @sent_delivery = ack["delivery"] || delivery
761
693
  @sent_cut = ack["cut"]
762
- @sent_enqueued_id = (ack["enqueued_id"] || ack[:enqueued_id])&.to_s
763
694
  @info.puts("#{short} sent#{with_images}#{delivery_note(ack, delivery: delivery, running: running, owner: owner)}")
764
695
  true
765
696
  rescue SessionManager::OwnedByTUI
@@ -15,6 +15,7 @@ require_relative "prompt_note"
15
15
 
16
16
  require_relative "paths"
17
17
  require_relative "project_scope"
18
+ require_relative "guardrails/parent_levels"
18
19
 
19
20
  module Samagotchi
20
21
  # archive_store requires this file.
@@ -228,19 +229,19 @@ module Samagotchi
228
229
  # it (SessionSetup.of), a delegate child takes its parent's.
229
230
  attr_reader :parent_answers
230
231
 
231
- # @param value [String, Symbol, nil] stripped and downcased; "" and
232
- # "default" are none; a value that isn't a level is kept as written
232
+ # @param value [String, Symbol, nil] read as
233
+ # Guardrails::ParentPolicy.parse_level: a level, or nil for none ("",
234
+ # "default"); a value that isn't a level is kept as written, stripped
233
235
  # (read as hard: ParentPolicy.session_level)
234
236
  def parent_answers=(value)
235
- word = value.to_s.strip
236
- word = word.downcase if PARENT_ANSWERS_WORDS.include?(word.downcase)
237
- @parent_answers = word.empty? || word == "default" ? nil : word
237
+ level = Guardrails::ParentPolicy.parse_level(value)
238
+ @parent_answers = case level
239
+ when :unset then nil
240
+ when :invalid then value.to_s.strip
241
+ else level
242
+ end
238
243
  end
239
244
 
240
- # The levels and "default" (Guardrails::ParentPolicy::ORDER and UNSET;
241
- # not required here: that file loads the guardrails).
242
- PARENT_ANSWERS_WORDS = %w[hard standard soft default].freeze
243
-
244
245
  # The hook that stopped the last turn (stop_turn, or a cut with no retry
245
246
  # left), e.g. "loop-guard"; nil when it ended otherwise.
246
247
  def stopped_by
@@ -289,8 +289,11 @@ module Samagotchi
289
289
  # turn takes it (the Bridge's POST turn/dequeue, under the event log):
290
290
  # only one that waits for the turn's end (a queue or an image message,
291
291
  # #waits_for_turn_end?); a next-step file may be merged at any boundary.
292
- # The file is claimed first, as the drain does (the rename is the race's
293
- # one winner), then deleted, and its tombstone written.
292
+ # Its tombstone is written first, then the file claimed as the drain
293
+ # does (the rename is the race's one winner) and deleted; a claim a
294
+ # turn won takes the tombstone back. Tombstone first: a ReplyWait look
295
+ # that finds the file claimed and no tombstone knows a turn took it
296
+ # (never a removal half done, which it would read as a merge).
294
297
  # @return [Array(Symbol, Input)] [:removed, the input it held],
295
298
  # [:not_queued, nil] for a next-step file, [:claimed, nil] when a
296
299
  # turn took it (its *.json.processing is there), [:none, nil] for
@@ -304,10 +307,24 @@ module Samagotchi
304
307
  next unless data.is_a?(Hash) && data["enqueued_id"] == id
305
308
  return [:not_queued, nil] unless Array(data["images"]).any? || Delivery.queue?(data["delivery"])
306
309
 
307
- claimed = claim_input_file(file) or return [:claimed, nil]
308
- input = read_input(claimed)
309
310
  mark_removed(session_dir, id)
310
- FileUtils.rm_f(claimed)
311
+ unless (claimed = claim_input_file(file))
312
+ unmark_removed(session_dir, id)
313
+ return [:claimed, nil]
314
+ end
315
+ # The claimed file goes whatever the read does: a claimed file left
316
+ # with the tombstone would read as a removal still under way
317
+ # (ReplyWait) until the wait's timeout. One that can't be read was
318
+ # still taken back (no prompt to give). A process killed between
319
+ # the claim and this delete leaves both: that wait then times out,
320
+ # never a wrong answer (a stale *.json.processing is never run).
321
+ input = begin
322
+ read_input(claimed)
323
+ rescue SystemCallError
324
+ UNREADABLE_INPUT
325
+ ensure
326
+ FileUtils.rm_f(claimed)
327
+ end
311
328
  return [:removed, input]
312
329
  rescue JSON::ParserError, SystemCallError
313
330
  next
@@ -316,7 +333,7 @@ module Samagotchi
316
333
  end
317
334
 
318
335
  # Whether a claimed input file (*.json.processing: a turn's, now or
319
- # left by a crash) carries +id+.
336
+ # left by a crash, or a removal's before it deletes it) carries +id+.
320
337
  def self.claimed_input?(session_dir, id)
321
338
  Dir.glob(File.join(session_dir, INPUT_DIR, "*.json.processing")).any? do |file|
322
339
  data = JSON.parse(File.read(file))
@@ -325,7 +342,6 @@ module Samagotchi
325
342
  false
326
343
  end
327
344
  end
328
- private_class_method :claimed_input?
329
345
 
330
346
  def self.mark_removed(session_dir, id)
331
347
  return unless ENQUEUED_ID_NAME.match?(id)
@@ -338,8 +354,15 @@ module Samagotchi
338
354
  end
339
355
  private_class_method :mark_removed
340
356
 
357
+ def self.unmark_removed(session_dir, id)
358
+ FileUtils.rm_f(File.join(session_dir, INPUT_DIR, REMOVED_DIR, id)) if ENQUEUED_ID_NAME.match?(id)
359
+ end
360
+ private_class_method :unmark_removed
361
+
341
362
  # @return [Boolean] whether the queued input +enqueued_id+ was removed
342
- # before it ran (#take_queued_input's tombstone)
363
+ # before it ran (#take_queued_input's tombstone), or is being removed:
364
+ # the tombstone goes first, so while its claimed file is still there
365
+ # (#claimed_input?) the removal may yet lose to a turn's claim
343
366
  def self.removed?(session_dir, enqueued_id)
344
367
  id = enqueued_id.to_s
345
368
  ENQUEUED_ID_NAME.match?(id) && File.exist?(File.join(session_dir, INPUT_DIR, REMOVED_DIR, id))
@@ -80,7 +80,7 @@ module Samagotchi
80
80
 
81
81
  def initialize(session_id)
82
82
  @session_id = session_id
83
- super("session #{session_id} is owned by an interactive TUI")
83
+ super("session #{session_id} is open in a chi REPL; close it there first")
84
84
  end
85
85
  end
86
86
 
@@ -1140,6 +1140,39 @@ module Samagotchi
1140
1140
  # How long a turn waits for the Bridge of a worker a resume just spawned.
1141
1141
  TURN_BRIDGE_WAIT = 5.0
1142
1142
 
1143
+ # What #deliver_turn did with a message.
1144
+ # +status+ :accepted; :refused (the Bridge refused it, or a queue would
1145
+ # reach a worker that doesn't queue); :timeout (the Bridge took it and
1146
+ # never answered); :failed (the input file couldn't be written).
1147
+ # +via+ :bridge (the worker's Bridge answered) or :file (the input-file
1148
+ # fallback); nil when nothing went in.
1149
+ # +ack+ the Bridge's reply body, or the file's {"status", "enqueued_id",
1150
+ # "session_id"}: string keys either way, {} for none.
1151
+ # +code+ a refusal's HTTP status.
1152
+ DeliverResult = Data.define(:status, :via, :ack, :code) do
1153
+ def initialize(status:, via: nil, ack: {}, code: nil) = super(status:, via:, ack: ack || {}, code:)
1154
+
1155
+ def accepted? = status == :accepted
1156
+
1157
+ # Sent as an input file (no Bridge): its turn and outcome carry the
1158
+ # file's enqueued_id, which a wait follows (chi send --wait).
1159
+ def file? = via == :file
1160
+
1161
+ # @return [String, nil] the id the message's turn will carry
1162
+ def enqueued_id
1163
+ id = ack["enqueued_id"].to_s
1164
+ id.empty? ? nil : id
1165
+ end
1166
+
1167
+ # Whether the worker took the line as a session command (a POST /turn
1168
+ # answered with its command_id).
1169
+ def command? = !ack["command_id"].to_s.empty?
1170
+
1171
+ def error = ack["error"]
1172
+
1173
+ def detail = ack["detail"]
1174
+ end
1175
+
1143
1176
  # Hand a user message to the session the way a UI does, for the web
1144
1177
  # composer and `chi send` alike: wakes the worker when none runs, posts
1145
1178
  # through its Bridge so every live UI sees :turn_enqueued, and falls back
@@ -1153,14 +1186,12 @@ module Samagotchi
1153
1186
  # stand-in (the web's specs); #session_owner is optional
1154
1187
  # @param bridge [#call, nil] returns the BridgeClient or nil; defaults to
1155
1188
  # the session's sidecar, waiting TURN_BRIDGE_WAIT for a new worker's
1156
- # @return [Hash] {status: :accepted, ack: Hash} (the Bridge's reply, or
1157
- # {status:, enqueued_id:, session_id:} for a file), {status: :refused,
1158
- # code:, ack:} when the Bridge refused the request (any 4xx with an
1189
+ # @return [DeliverResult] :accepted (via the Bridge or a file),
1190
+ # :refused when the Bridge refused the request (any 4xx with an
1159
1191
  # error: bad images, a delivery it doesn't run) or a queue would reach
1160
- # a worker that doesn't queue (no file then: it would steer),
1161
- # {status: :timeout, ack:} when it took the request but never answered
1162
- # (no file then), or {status: :failed} when the input file couldn't be
1163
- # written
1192
+ # a worker that doesn't queue (no file then: it would steer), :timeout
1193
+ # when it took the request but never answered (no file then), or
1194
+ # :failed when the input file couldn't be written
1164
1195
  # @raise [OwnedByTUI] a chi REPL owns the session
1165
1196
  # @raise [ArgumentError] no such session
1166
1197
  def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], delivery: nil, state_dir: nil, manager: self,
@@ -1180,7 +1211,7 @@ module Samagotchi
1180
1211
  options[:delivery] = delivery unless Delivery.next_step?(delivery)
1181
1212
  reply = client.post_turn(**options)
1182
1213
  ack = reply.json
1183
- return { status: :accepted, ack: ack } if reply.status == 202 && ack.is_a?(Hash)
1214
+ return DeliverResult.new(status: :accepted, via: :bridge, ack: ack) if reply.status == 202 && ack.is_a?(Hash)
1184
1215
  # Read after its deadline and dropped: a file would run it after all.
1185
1216
  return turn_timeout if ack.is_a?(Hash) && ack["error"] == "deadline_passed"
1186
1217
  # Any other 4xx with an error is a refusal (bad images, a delivery
@@ -1189,7 +1220,7 @@ module Samagotchi
1189
1220
  # limit, a big piped chi send) and a 5xx keep the file fallback
1190
1221
  # below, which carries the delivery (a queue too).
1191
1222
  if (400..499).cover?(reply.status) && reply.status != 413 && ack.is_a?(Hash) && ack["error"]
1192
- return { status: :refused, code: reply.status, ack: ack }
1223
+ return DeliverResult.new(status: :refused, via: :bridge, code: reply.status, ack: ack)
1193
1224
  end
1194
1225
  rescue Errno::ETIMEDOUT
1195
1226
  # The Bridge drops a turn it reads after the request's deadline
@@ -1205,7 +1236,7 @@ module Samagotchi
1205
1236
  input[:images] = images unless images.empty?
1206
1237
  input[:delivery] = delivery unless Delivery.next_step?(delivery)
1207
1238
  path = manager.write_turn_input(session_id, **input)
1208
- return { status: :failed } unless path
1239
+ return DeliverResult.new(status: :failed) unless path
1209
1240
 
1210
1241
  owner = delivery_owner(manager, session_id, state_dir)
1211
1242
  # A TUI that took the session between the resume and the write never
@@ -1218,7 +1249,8 @@ module Samagotchi
1218
1249
  # wake a new one. (The exiting worker also looks for input it left;
1219
1250
  # one the first resume spawned that is still starting is left be.)
1220
1251
  manager.resume_session(session_id, state_dir: state_dir) if owner.nil?
1221
- { status: :accepted, ack: { status: "accepted", enqueued_id: enqueued_id, session_id: session_id } }
1252
+ DeliverResult.new(status: :accepted, via: :file,
1253
+ ack: { "status" => "accepted", "enqueued_id" => enqueued_id, "session_id" => session_id })
1222
1254
  end
1223
1255
 
1224
1256
  # Whether the session's live worker runs delivery: queue (its sidecar
@@ -1230,12 +1262,13 @@ module Samagotchi
1230
1262
  end
1231
1263
 
1232
1264
  private_class_method def self.queue_unavailable
1233
- { status: :refused, code: 409, ack: Delivery::QUEUE_UNAVAILABLE }
1265
+ DeliverResult.new(status: :refused, code: 409, ack: Delivery::QUEUE_UNAVAILABLE)
1234
1266
  end
1235
1267
 
1236
1268
  private_class_method def self.turn_timeout
1237
- { status: :timeout, ack: { "error" => "worker_timeout",
1238
- "detail" => "the session's worker did not answer, so the message was not sent" } }
1269
+ DeliverResult.new(status: :timeout, via: :bridge,
1270
+ ack: { "error" => "worker_timeout",
1271
+ "detail" => "the session's worker did not answer, so the message was not sent" })
1239
1272
  end
1240
1273
 
1241
1274
  private_class_method def self.delivery_owner(manager, session_id, state_dir)
@@ -7,6 +7,7 @@ require "fileutils"
7
7
  require "securerandom"
8
8
  require_relative "analytics_file"
9
9
  require_relative "atomic_file"
10
+ require_relative "log"
10
11
  require_relative "token_usage"
11
12
  require_relative "model_price"
12
13
  require_relative "session"
@@ -278,6 +279,8 @@ module Samagotchi
278
279
  # in snapshot order. Taken before @mutex, never under it: the write's
279
280
  # IO doesn't hold up the events.
280
281
  @persist_mutex = Mutex.new
282
+ # The error classes #persist has logged (under @persist_mutex).
283
+ @persist_failures = Set.new
281
284
  @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
282
285
  @wall_clock = wall_clock || -> { Time.now }
283
286
  @session_id = nil
@@ -481,7 +484,8 @@ module Samagotchi
481
484
 
482
485
  # Persist the summary snapshot, with every record, to a sibling
483
486
  # analytics.json in the session directory. Atomic write; failures are
484
- # swallowed (analytics must never break the running session).
487
+ # swallowed (analytics must never break the running session), each
488
+ # error class logged once (WARN turn analytics_persist_failed).
485
489
  # @param state_dir [String, nil]
486
490
  # @param running_turn [Boolean] also save the running turn's record so
487
491
  # far (a worker's turn checkpoint): as the turn a worker killed now
@@ -490,13 +494,28 @@ module Samagotchi
490
494
  # turn's own end writes over it. Only the file has it.
491
495
  # @return [Boolean] true on success
492
496
  def persist(state_dir: nil, running_turn: false)
493
- @persist_mutex.synchronize { persist_now(state_dir, running_turn) }
494
- rescue StandardError
497
+ @persist_mutex.synchronize do
498
+ persist_now(state_dir, running_turn)
499
+ rescue StandardError => e
500
+ note_persist_failure(e)
501
+ false
502
+ end
503
+ rescue StandardError # a ThreadError from the mutex, a log that raised: still never raises
495
504
  false
496
505
  end
497
506
 
498
507
  private
499
508
 
509
+ # Logs a #persist failure the first time its error class is seen (under
510
+ # the persist mutex): side requests and checkpoints persist too, so a
511
+ # lasting failure (a full disk, a read-only dir) would log on every one.
512
+ def note_persist_failure(error)
513
+ return if @persist_failures.include?(error.class)
514
+
515
+ @persist_failures << error.class
516
+ Log.warn(:turn, "analytics_persist_failed", error: error.class.name, msg: error.message.to_s[0, 300])
517
+ end
518
+
500
519
  # #persist's work, under the persist mutex.
501
520
  def persist_now(state_dir, running_turn)
502
521
  sid, data, turn_count = @mutex.synchronize do
@@ -365,9 +365,9 @@ module Samagotchi
365
365
  begin
366
366
  id = Samagotchi::Session.resolve_id(given)
367
367
  released = Samagotchi::SessionManager.stop_session(id, wait: 10)
368
- rescue Samagotchi::SessionManager::OwnedByTUI
368
+ rescue Samagotchi::SessionManager::OwnedByTUI => e
369
369
  @stdout.flush
370
- @stderr.puts "session #{id} is open in a chi REPL; close it there first"
370
+ @stderr.puts e.message
371
371
  next false
372
372
  rescue ArgumentError => e
373
373
  @stdout.flush
@@ -394,9 +394,9 @@ module Samagotchi
394
394
  begin
395
395
  id = Samagotchi::Session.resolve_id(given)
396
396
  result = Samagotchi::SessionManager.restart_session(id)
397
- rescue Samagotchi::SessionManager::OwnedByTUI
397
+ rescue Samagotchi::SessionManager::OwnedByTUI => e
398
398
  @stdout.flush
399
- @stderr.puts "session #{id} is open in a chi REPL; close it there first"
399
+ @stderr.puts e.message
400
400
  next false
401
401
  rescue Samagotchi::SessionManager::RestartRefused => e
402
402
  @stdout.flush
@@ -1639,9 +1639,9 @@ module Samagotchi
1639
1639
  end
1640
1640
 
1641
1641
  def reminder_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::SYSTEM_PREFIX)
1642
- def report_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::CHILD_PREFIX)
1643
- def context_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::CONTEXT_PREFIX)
1644
- def task_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::TASK_PREFIX)
1642
+ def report_origin?(origin) = ClientId.origin(origin[:client_id]) == "delegate_report"
1643
+ def context_origin?(origin) = ClientId.origin(origin[:client_id]) == "context"
1644
+ def task_origin?(origin) = ClientId.origin(origin[:client_id]) == "task"
1645
1645
  # A turn with no prompt: "(continuing)"; a context wake's or a task
1646
1646
  # finish wake's label alone.
1647
1647
  def continuing_text(origin) = context_origin?(origin) || task_origin?(origin) ? nil : "(continuing)"
@@ -215,11 +215,14 @@ module Samagotchi
215
215
  return fallback unless client_id
216
216
 
217
217
  id = client_id.to_s
218
- name = id.delete_prefix(ClientId::CONTEXT_PREFIX)
219
- return "context #{name} changed" if id.start_with?(ClientId::CONTEXT_PREFIX) && !name.empty?
220
-
221
- task = id.delete_prefix(ClientId::TASK_PREFIX)
222
- return "task #{Tools::TaskRecord.short_id(task)} finished" if id.start_with?(ClientId::TASK_PREFIX) && !task.empty?
218
+ case ClientId.origin(id)
219
+ when "context"
220
+ name = id.delete_prefix(ClientId::CONTEXT_PREFIX)
221
+ return "context #{name} changed" unless name.empty?
222
+ when "task"
223
+ task = id.delete_prefix(ClientId::TASK_PREFIX)
224
+ return "task #{Tools::TaskRecord.short_id(task)} finished" unless task.empty?
225
+ end
223
226
 
224
227
  prefix = id[/\A[^:]*:/]
225
228
  CLIENT_LABELS[id] || (prefix && CLIENT_LABELS[prefix]) || AUTOMATIC_LABEL
@@ -339,7 +339,7 @@ module Samagotchi
339
339
  def model_answers = @recorder.model_answers
340
340
 
341
341
  # Queue ask_user answers ({selected:, freeform:, selected_indices:},
342
- # or a callable given the Question).
342
+ # or a callable given the HookQuestion).
343
343
  def answer(*answers)
344
344
  @recorder.answers.concat(answers)
345
345
  self