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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +88 -1
- data/README.md +5 -2
- data/bin/chi +8 -8
- data/docs/architecture.md +20 -16
- data/docs/bundle-authoring.md +2 -1
- data/docs/cli.md +5 -5
- data/docs/configuration.md +3 -1
- data/docs/context.md +5 -3
- data/docs/guardrails.md +4 -5
- data/docs/hooks.md +3 -3
- data/docs/internals/background-tasks.md +2 -2
- data/docs/internals/bundles.md +4 -4
- data/docs/internals/engine.md +27 -8
- data/docs/internals/tool-guardrails.md +10 -2
- data/docs/memory.md +6 -0
- data/docs/plugins.md +30 -13
- data/docs/releasing.md +3 -0
- data/docs/sessions.md +4 -4
- data/docs/testing.md +2 -1
- data/lib/samagotchi/bootstrap/probe.rb +2 -2
- data/lib/samagotchi/bridge.rb +8 -9
- data/lib/samagotchi/bundle_command/check.rb +43 -12
- data/lib/samagotchi/bundle_command/scaffold.rb +12 -0
- data/lib/samagotchi/bundle_command/test_run.rb +12 -0
- data/lib/samagotchi/bundle_command/try/command.rb +164 -0
- data/lib/samagotchi/bundle_command/try/render.rb +80 -0
- data/lib/samagotchi/bundle_command/try/run.rb +35 -17
- data/lib/samagotchi/bundle_command/try/sandbox.rb +113 -34
- data/lib/samagotchi/bundle_command/try/trace.rb +20 -26
- data/lib/samagotchi/bundle_command.rb +11 -213
- data/lib/samagotchi/bundle_session_settings.rb +82 -45
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +6 -5
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +57 -12
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_env.rb +50 -0
- data/lib/samagotchi/child_reports.rb +12 -14
- data/lib/samagotchi/client_id.rb +18 -0
- data/lib/samagotchi/config/restart_watch.rb +112 -0
- data/lib/samagotchi/config.rb +18 -59
- data/lib/samagotchi/engine.rb +26 -26
- data/lib/samagotchi/guardrails/data_readers.rb +34 -30
- data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
- data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
- data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
- data/lib/samagotchi/hook_question.rb +21 -0
- data/lib/samagotchi/hook_routes.rb +6 -6
- data/lib/samagotchi/hooks/registry.rb +7 -5
- data/lib/samagotchi/host_registry.rb +1 -1
- data/lib/samagotchi/kernel_loop.rb +14 -3
- data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
- data/lib/samagotchi/llm/chat_loop.rb +3 -1
- data/lib/samagotchi/llm/errors.rb +4 -4
- data/lib/samagotchi/memory_bundle/builder.rb +3 -2
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +7 -2
- data/lib/samagotchi/memory_bundle/installer.rb +1 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +42 -8
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
- data/lib/samagotchi/parent_answers_command.rb +6 -6
- data/lib/samagotchi/parent_report.rb +12 -0
- data/lib/samagotchi/plugin/context.rb +6 -5
- data/lib/samagotchi/plugin/http_transport.rb +14 -3
- data/lib/samagotchi/plugin/sessions.rb +2 -3
- data/lib/samagotchi/question_desk.rb +12 -9
- data/lib/samagotchi/reply_wait/poll.rb +267 -0
- data/lib/samagotchi/reply_wait.rb +51 -190
- data/lib/samagotchi/sandbox_layout.rb +66 -0
- data/lib/samagotchi/send_command/parent_gate.rb +83 -0
- data/lib/samagotchi/send_command/wait_target.rb +55 -0
- data/lib/samagotchi/send_command.rb +34 -103
- data/lib/samagotchi/session.rb +10 -9
- data/lib/samagotchi/session_inbox.rb +31 -8
- data/lib/samagotchi/session_manager.rb +60 -35
- data/lib/samagotchi/session_metrics.rb +22 -3
- data/lib/samagotchi/sessions_command.rb +4 -4
- data/lib/samagotchi/task_watch.rb +64 -21
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
- data/lib/samagotchi/testing/bundle.rb +33 -20
- data/lib/samagotchi/testing/host.rb +2 -8
- data/lib/samagotchi/testing/install.rb +26 -17
- data/lib/samagotchi/testing/recorder.rb +6 -9
- data/lib/samagotchi/testing/sandbox.rb +45 -37
- data/lib/samagotchi/testing/waiting.rb +5 -7
- data/lib/samagotchi/tool_response.rb +28 -6
- data/lib/samagotchi/tools/delegate.rb +10 -10
- data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
- data/lib/samagotchi/tools/delegate_relay.rb +31 -16
- data/lib/samagotchi/tools/delegate_wait.rb +4 -4
- data/lib/samagotchi/tools/task_runtime.rb +2 -2
- data/lib/samagotchi/turn_checkpoint.rb +23 -10
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +46 -63
- data/lib/samagotchi/web/public/app.js +130 -554
- data/lib/samagotchi/web/public/cards_place.js +4 -1
- data/lib/samagotchi/web/public/images.js +6 -0
- data/lib/samagotchi/web/public/index.html +1 -1
- data/lib/samagotchi/web/public/model_pick.js +10 -0
- data/lib/samagotchi/web/public/send_turn.js +173 -0
- data/lib/samagotchi/web/public/session_actions.js +203 -0
- data/lib/samagotchi/web/public/start_page.js +236 -0
- data/lib/samagotchi/web/public/toasts.js +17 -9
- data/lib/samagotchi/web/public/unqueue.js +20 -4
- data/lib/samagotchi/worker.rb +77 -185
- data/lib/samagotchi/worker_idle_exit.rb +20 -5
- data/lib/samagotchi/worker_inbound.rb +5 -5
- data/lib/samagotchi/worker_task_wakes.rb +160 -0
- data/lib/samagotchi/worker_wakes.rb +0 -3
- 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,
|
|
97
|
-
|
|
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
|
|
256
|
-
#
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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] =
|
|
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],
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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.
|
|
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
|
-
|
|
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)
|
|
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
|
|
614
|
-
@info.puts("#{session.id} failed: #{result.
|
|
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
|
-
|
|
619
|
-
@
|
|
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
|
|
675
|
+
unless result.accepted?
|
|
744
676
|
# A worker older than queueing would steer it: refused, not sent.
|
|
745
|
-
word = result.
|
|
746
|
-
@info.puts("#{short} #{word}: #{result.
|
|
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 =
|
|
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
|
|
757
|
-
@
|
|
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
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -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]
|
|
232
|
-
#
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
#
|
|
293
|
-
# one winner)
|
|
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
|
-
|
|
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
|
|
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_
|
|
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
|
-
#
|
|
248
|
-
#
|
|
249
|
-
#
|
|
250
|
-
# older or future env-exposed one out as
|
|
251
|
-
|
|
252
|
-
#
|
|
253
|
-
#
|
|
254
|
-
#
|
|
255
|
-
|
|
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!(
|
|
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 [
|
|
1165
|
-
#
|
|
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
|
-
#
|
|
1170
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1246
|
-
|
|
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
|
|
494
|
-
|
|
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
|
|
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
|
|
399
|
+
@stderr.puts e.message
|
|
400
400
|
next false
|
|
401
401
|
rescue Samagotchi::SessionManager::RestartRefused => e
|
|
402
402
|
@stdout.flush
|