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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -1
- data/bin/chi +4 -4
- data/docs/architecture.md +4 -4
- data/docs/guardrails.md +4 -5
- data/docs/internals/engine.md +6 -0
- data/docs/sessions.md +1 -1
- data/docs/testing.md +2 -1
- data/lib/samagotchi/bootstrap/probe.rb +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- 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 +4 -14
- 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 +5 -5
- data/lib/samagotchi/host_registry.rb +1 -1
- data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
- data/lib/samagotchi/parent_answers_command.rb +6 -6
- 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 +47 -190
- 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 +31 -100
- data/lib/samagotchi/session.rb +10 -9
- data/lib/samagotchi/session_inbox.rb +31 -8
- data/lib/samagotchi/session_manager.rb +48 -15
- data/lib/samagotchi/session_metrics.rb +22 -3
- data/lib/samagotchi/sessions_command.rb +4 -4
- 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 +1 -1
- data/lib/samagotchi/testing/host.rb +2 -8
- data/lib/samagotchi/testing/recorder.rb +6 -9
- 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/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +46 -63
- data/lib/samagotchi/web/public/app.js +122 -556
- data/lib/samagotchi/web/public/index.html +1 -1
- 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
- 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
|
|
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))
|
|
@@ -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
|
|
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 [
|
|
1157
|
-
#
|
|
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
|
-
#
|
|
1162
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1238
|
-
|
|
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
|
|
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
|
|
@@ -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]
|
|
1643
|
-
def context_origin?(origin) = origin[:client_id]
|
|
1644
|
-
def task_origin?(origin) = origin[:client_id]
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
342
|
+
# or a callable given the HookQuestion).
|
|
343
343
|
def answer(*answers)
|
|
344
344
|
@recorder.answers.concat(answers)
|
|
345
345
|
self
|