samagotchi 0.91.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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -1
  3. data/README.md +5 -2
  4. data/bin/chi +8 -8
  5. data/docs/architecture.md +20 -16
  6. data/docs/bundle-authoring.md +2 -1
  7. data/docs/cli.md +5 -5
  8. data/docs/configuration.md +3 -1
  9. data/docs/context.md +5 -3
  10. data/docs/guardrails.md +4 -5
  11. data/docs/hooks.md +3 -3
  12. data/docs/internals/background-tasks.md +2 -2
  13. data/docs/internals/bundles.md +4 -4
  14. data/docs/internals/engine.md +27 -8
  15. data/docs/internals/tool-guardrails.md +10 -2
  16. data/docs/memory.md +6 -0
  17. data/docs/plugins.md +30 -13
  18. data/docs/releasing.md +3 -0
  19. data/docs/sessions.md +4 -4
  20. data/docs/testing.md +2 -1
  21. data/lib/samagotchi/bootstrap/probe.rb +2 -2
  22. data/lib/samagotchi/bridge.rb +8 -9
  23. data/lib/samagotchi/bundle_command/check.rb +43 -12
  24. data/lib/samagotchi/bundle_command/scaffold.rb +12 -0
  25. data/lib/samagotchi/bundle_command/test_run.rb +12 -0
  26. data/lib/samagotchi/bundle_command/try/command.rb +164 -0
  27. data/lib/samagotchi/bundle_command/try/render.rb +80 -0
  28. data/lib/samagotchi/bundle_command/try/run.rb +35 -17
  29. data/lib/samagotchi/bundle_command/try/sandbox.rb +113 -34
  30. data/lib/samagotchi/bundle_command/try/trace.rb +20 -26
  31. data/lib/samagotchi/bundle_command.rb +11 -213
  32. data/lib/samagotchi/bundle_session_settings.rb +82 -45
  33. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  34. data/lib/samagotchi/bundles/cost-cap/manifest.yml +6 -5
  35. data/lib/samagotchi/bundles/cost-cap/plugin.rb +57 -12
  36. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  37. data/lib/samagotchi/child_env.rb +50 -0
  38. data/lib/samagotchi/child_reports.rb +12 -14
  39. data/lib/samagotchi/client_id.rb +18 -0
  40. data/lib/samagotchi/config/restart_watch.rb +112 -0
  41. data/lib/samagotchi/config.rb +18 -59
  42. data/lib/samagotchi/engine.rb +26 -26
  43. data/lib/samagotchi/guardrails/data_readers.rb +34 -30
  44. data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
  45. data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
  46. data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
  47. data/lib/samagotchi/hook_question.rb +21 -0
  48. data/lib/samagotchi/hook_routes.rb +6 -6
  49. data/lib/samagotchi/hooks/registry.rb +7 -5
  50. data/lib/samagotchi/host_registry.rb +1 -1
  51. data/lib/samagotchi/kernel_loop.rb +14 -3
  52. data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
  53. data/lib/samagotchi/llm/chat_loop.rb +3 -1
  54. data/lib/samagotchi/llm/errors.rb +4 -4
  55. data/lib/samagotchi/memory_bundle/builder.rb +3 -2
  56. data/lib/samagotchi/memory_bundle/installed_bundle.rb +7 -2
  57. data/lib/samagotchi/memory_bundle/installer.rb +1 -0
  58. data/lib/samagotchi/memory_bundle/manifest.rb +42 -8
  59. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  60. data/lib/samagotchi/parent_answers_command.rb +6 -6
  61. data/lib/samagotchi/parent_report.rb +12 -0
  62. data/lib/samagotchi/plugin/context.rb +6 -5
  63. data/lib/samagotchi/plugin/http_transport.rb +14 -3
  64. data/lib/samagotchi/plugin/sessions.rb +2 -3
  65. data/lib/samagotchi/question_desk.rb +12 -9
  66. data/lib/samagotchi/reply_wait/poll.rb +267 -0
  67. data/lib/samagotchi/reply_wait.rb +51 -190
  68. data/lib/samagotchi/sandbox_layout.rb +66 -0
  69. data/lib/samagotchi/send_command/parent_gate.rb +83 -0
  70. data/lib/samagotchi/send_command/wait_target.rb +55 -0
  71. data/lib/samagotchi/send_command.rb +34 -103
  72. data/lib/samagotchi/session.rb +10 -9
  73. data/lib/samagotchi/session_inbox.rb +31 -8
  74. data/lib/samagotchi/session_manager.rb +60 -35
  75. data/lib/samagotchi/session_metrics.rb +22 -3
  76. data/lib/samagotchi/sessions_command.rb +4 -4
  77. data/lib/samagotchi/task_watch.rb +64 -21
  78. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
  79. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  80. data/lib/samagotchi/testing/bundle.rb +33 -20
  81. data/lib/samagotchi/testing/host.rb +2 -8
  82. data/lib/samagotchi/testing/install.rb +26 -17
  83. data/lib/samagotchi/testing/recorder.rb +6 -9
  84. data/lib/samagotchi/testing/sandbox.rb +45 -37
  85. data/lib/samagotchi/testing/waiting.rb +5 -7
  86. data/lib/samagotchi/tool_response.rb +28 -6
  87. data/lib/samagotchi/tools/delegate.rb +10 -10
  88. data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
  89. data/lib/samagotchi/tools/delegate_relay.rb +31 -16
  90. data/lib/samagotchi/tools/delegate_wait.rb +4 -4
  91. data/lib/samagotchi/tools/task_runtime.rb +2 -2
  92. data/lib/samagotchi/turn_checkpoint.rb +23 -10
  93. data/lib/samagotchi/version.rb +1 -1
  94. data/lib/samagotchi/web/app.rb +46 -63
  95. data/lib/samagotchi/web/public/app.js +130 -554
  96. data/lib/samagotchi/web/public/cards_place.js +4 -1
  97. data/lib/samagotchi/web/public/images.js +6 -0
  98. data/lib/samagotchi/web/public/index.html +1 -1
  99. data/lib/samagotchi/web/public/model_pick.js +10 -0
  100. data/lib/samagotchi/web/public/send_turn.js +173 -0
  101. data/lib/samagotchi/web/public/session_actions.js +203 -0
  102. data/lib/samagotchi/web/public/start_page.js +236 -0
  103. data/lib/samagotchi/web/public/toasts.js +17 -9
  104. data/lib/samagotchi/web/public/unqueue.js +20 -4
  105. data/lib/samagotchi/worker.rb +77 -185
  106. data/lib/samagotchi/worker_idle_exit.rb +20 -5
  107. data/lib/samagotchi/worker_inbound.rb +5 -5
  108. data/lib/samagotchi/worker_task_wakes.rb +160 -0
  109. data/lib/samagotchi/worker_wakes.rb +0 -3
  110. metadata +15 -1
@@ -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
@@ -93,9 +95,9 @@ module Samagotchi
93
95
  --set BUNDLE.KEY=VALUE
94
96
  (--new) a bundle setting for this session, over
95
97
  config.yml's bundles: (repeatable; a key its bundle's
96
- manifest lists in session_settings:; 3, 2.5, true,
97
- false and off are typed, the rest is text:
98
- --set cost-cap.cap=3)
98
+ manifest lists in session_settings:; numbers (3,
99
+ .5) and booleans (true, off, no) are typed, the
100
+ rest is text: --set cost-cap.cap=3)
99
101
  --parent-answers LEVEL
100
102
  (--new) what a parent agent may answer on its
101
103
  questions: hard, standard or soft, only stricter than
@@ -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))
@@ -5,6 +5,7 @@ require "json"
5
5
  require "time"
6
6
  require "securerandom"
7
7
  require "rbconfig"
8
+ require_relative "child_env"
8
9
  require_relative "client_id"
9
10
  require_relative "analytics_file"
10
11
  require_relative "atomic_file"
@@ -79,7 +80,7 @@ module Samagotchi
79
80
 
80
81
  def initialize(session_id)
81
82
  @session_id = session_id
82
- 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")
83
84
  end
84
85
  end
85
86
 
@@ -210,7 +211,7 @@ module Samagotchi
210
211
  # is when the worker starts); opts[:env] adds to it (merged, not
211
212
  # replaced) what it can't read there: this `chi`'s CLI settings
212
213
  # (Config.cli_env) and the absolute log file. It unsets
213
- # SAMAGOTCHI_GUARDRAILS_* and the session markers (SESSION_MARKER_ENV).
214
+ # what ChildEnv lists (SAMAGOTCHI_GUARDRAILS_*, the session markers).
214
215
  # XDG_CONFIG_HOME passes: whoever starts or
215
216
  # wakes the worker picks which config.yml it reads (docs/guardrails.md).
216
217
  # XDG_STATE_HOME and XDG_CONFIG_HOME, when set, pass made absolute
@@ -244,18 +245,15 @@ module Samagotchi
244
245
  # And the spawner's XDG dirs, absolute: a relative XDG_STATE_HOME or
245
246
  # XDG_CONFIG_HOME would otherwise be read from the session's directory.
246
247
  child_env.merge!(Paths.absolute_xdg)
247
- # The spawner's environment must not reach the worker's guardrails (a
248
- # parent agent starting or waking a session): Process.spawn unsets a
249
- # key mapped to nil. config.yml no longer reads them; this keeps an
250
- # older or future env-exposed one out as well.
251
- ENV.each_key { |key| child_env[key] = nil if key.start_with?(GUARDRAILS_ENV_PREFIX) }
252
- # The markers a session's execute exports (Tools::Builtins.parent_env)
253
- # are that session's: a worker started from its commands (chi send)
254
- # is another session. Its execute and tasks set its own; nothing else
255
- # it runs (a user's !cmd, an MCP server, a context command) has one.
256
- SESSION_MARKER_ENV.each { |key| child_env[key] = nil }
257
- # A smoke's fake "newest installed" is for this process's notices only.
258
- child_env[InstalledVersions::ENV_KEY] = nil
248
+ # Unset (Process.spawn unsets a key mapped to nil) what no child chi
249
+ # takes from its spawner (ChildEnv): the spawner's guardrails settings
250
+ # (a parent agent starting or waking a session; config.yml no longer
251
+ # reads them, this keeps an older or future env-exposed one out as
252
+ # well); the session markers, a session's own (a worker started from
253
+ # its commands, chi send, is another session: its execute and tasks
254
+ # set its own; nothing else it runs has one); and a smoke's fake
255
+ # "newest installed", for this process's notices only.
256
+ child_env.merge!(ChildEnv.unset)
259
257
  # A worker process: run_session_loop traps signals, and leaves this
260
258
  # process's tree.
261
259
  child_env[WORKER_PROCESS_ENV] = "1"
@@ -263,17 +261,11 @@ module Samagotchi
263
261
  # A gem-mode worker activates the newest installed chi itself: under
264
262
  # `bundle exec` (a Gemfile that has samagotchi) Bundler's RUBYOPT would
265
263
  # hold it to the bundle's version, and its badge would never clear.
266
- child_env.merge!(InstalledGem.unbundled_env_changes) if InstalledGem.spec
264
+ child_env.merge!(ChildEnv.unbundled)
267
265
  opts[:env] = child_env unless child_env.empty?
268
266
  opts
269
267
  end
270
268
 
271
- GUARDRAILS_ENV_PREFIX = "SAMAGOTCHI_GUARDRAILS_"
272
- WORKER_ENV_PREFIX = "SAMAGOTCHI_WORKER_"
273
- # Tools::Builtins.parent_env's (Tools::TaskRuntime::MARKER_ENV_KEYS).
274
- SESSION_MARKER_ENV = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL SAMAGOTCHI_SESSION_THINKING
275
- SAMAGOTCHI_SESSION_REASONING_HISTORY].freeze
276
-
277
269
  # List all sessions, reading status from persisted session.json files.
278
270
  # +project_root+: only that project's sessions (nil: every session).
279
271
  # +include_archived+: archived sessions too (Session#archived says which).
@@ -1148,6 +1140,39 @@ module Samagotchi
1148
1140
  # How long a turn waits for the Bridge of a worker a resume just spawned.
1149
1141
  TURN_BRIDGE_WAIT = 5.0
1150
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
+
1151
1176
  # Hand a user message to the session the way a UI does, for the web
1152
1177
  # composer and `chi send` alike: wakes the worker when none runs, posts
1153
1178
  # through its Bridge so every live UI sees :turn_enqueued, and falls back
@@ -1161,14 +1186,12 @@ module Samagotchi
1161
1186
  # stand-in (the web's specs); #session_owner is optional
1162
1187
  # @param bridge [#call, nil] returns the BridgeClient or nil; defaults to
1163
1188
  # the session's sidecar, waiting TURN_BRIDGE_WAIT for a new worker's
1164
- # @return [Hash] {status: :accepted, ack: Hash} (the Bridge's reply, or
1165
- # {status:, enqueued_id:, session_id:} for a file), {status: :refused,
1166
- # 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
1167
1191
  # error: bad images, a delivery it doesn't run) or a queue would reach
1168
- # a worker that doesn't queue (no file then: it would steer),
1169
- # {status: :timeout, ack:} when it took the request but never answered
1170
- # (no file then), or {status: :failed} when the input file couldn't be
1171
- # 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
1172
1195
  # @raise [OwnedByTUI] a chi REPL owns the session
1173
1196
  # @raise [ArgumentError] no such session
1174
1197
  def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], delivery: nil, state_dir: nil, manager: self,
@@ -1188,7 +1211,7 @@ module Samagotchi
1188
1211
  options[:delivery] = delivery unless Delivery.next_step?(delivery)
1189
1212
  reply = client.post_turn(**options)
1190
1213
  ack = reply.json
1191
- 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)
1192
1215
  # Read after its deadline and dropped: a file would run it after all.
1193
1216
  return turn_timeout if ack.is_a?(Hash) && ack["error"] == "deadline_passed"
1194
1217
  # Any other 4xx with an error is a refusal (bad images, a delivery
@@ -1197,7 +1220,7 @@ module Samagotchi
1197
1220
  # limit, a big piped chi send) and a 5xx keep the file fallback
1198
1221
  # below, which carries the delivery (a queue too).
1199
1222
  if (400..499).cover?(reply.status) && reply.status != 413 && ack.is_a?(Hash) && ack["error"]
1200
- return { status: :refused, code: reply.status, ack: ack }
1223
+ return DeliverResult.new(status: :refused, via: :bridge, code: reply.status, ack: ack)
1201
1224
  end
1202
1225
  rescue Errno::ETIMEDOUT
1203
1226
  # The Bridge drops a turn it reads after the request's deadline
@@ -1213,7 +1236,7 @@ module Samagotchi
1213
1236
  input[:images] = images unless images.empty?
1214
1237
  input[:delivery] = delivery unless Delivery.next_step?(delivery)
1215
1238
  path = manager.write_turn_input(session_id, **input)
1216
- return { status: :failed } unless path
1239
+ return DeliverResult.new(status: :failed) unless path
1217
1240
 
1218
1241
  owner = delivery_owner(manager, session_id, state_dir)
1219
1242
  # A TUI that took the session between the resume and the write never
@@ -1226,7 +1249,8 @@ module Samagotchi
1226
1249
  # wake a new one. (The exiting worker also looks for input it left;
1227
1250
  # one the first resume spawned that is still starting is left be.)
1228
1251
  manager.resume_session(session_id, state_dir: state_dir) if owner.nil?
1229
- { 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 })
1230
1254
  end
1231
1255
 
1232
1256
  # Whether the session's live worker runs delivery: queue (its sidecar
@@ -1238,12 +1262,13 @@ module Samagotchi
1238
1262
  end
1239
1263
 
1240
1264
  private_class_method def self.queue_unavailable
1241
- { status: :refused, code: 409, ack: Delivery::QUEUE_UNAVAILABLE }
1265
+ DeliverResult.new(status: :refused, code: 409, ack: Delivery::QUEUE_UNAVAILABLE)
1242
1266
  end
1243
1267
 
1244
1268
  private_class_method def self.turn_timeout
1245
- { status: :timeout, ack: { "error" => "worker_timeout",
1246
- "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" })
1247
1272
  end
1248
1273
 
1249
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