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
|
@@ -6,6 +6,7 @@ require "json"
|
|
|
6
6
|
require_relative "cli/exit"
|
|
7
7
|
require_relative "pending_question"
|
|
8
8
|
require_relative "session"
|
|
9
|
+
require_relative "turn_checkpoint"
|
|
9
10
|
require_relative "guardrails/parent_policy"
|
|
10
11
|
|
|
11
12
|
module Samagotchi
|
|
@@ -339,6 +340,8 @@ module Samagotchi
|
|
|
339
340
|
end
|
|
340
341
|
|
|
341
342
|
def no_reply_line(result, session_id = nil, strategy: nil)
|
|
343
|
+
return command_line(result) if result.origin == TurnCheckpoint::COMMAND_ORIGIN
|
|
344
|
+
|
|
342
345
|
case result.outcome
|
|
343
346
|
when "failed" then failed_line(result)
|
|
344
347
|
when "canceled"
|
|
@@ -360,6 +363,15 @@ module Samagotchi
|
|
|
360
363
|
end
|
|
361
364
|
end
|
|
362
365
|
|
|
366
|
+
# A file-delivered session command's outcome: no turn ran for it.
|
|
367
|
+
def command_line(result)
|
|
368
|
+
case result.outcome
|
|
369
|
+
when "completed" then "the command ran (no turn)"
|
|
370
|
+
when "failed" then "the command failed"
|
|
371
|
+
else "the command was refused (#{result.cancel_reason || "canceled"})"
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
363
375
|
# A failed turn's line; one whose work stayed says so, so the task isn't
|
|
364
376
|
# sent again over what it changed.
|
|
365
377
|
def failed_line(result)
|
|
@@ -5,6 +5,7 @@ require_relative "../log"
|
|
|
5
5
|
require_relative "../paths"
|
|
6
6
|
require_relative "../session_metrics/cost"
|
|
7
7
|
require_relative "../guardrails/context"
|
|
8
|
+
require_relative "../hook_question"
|
|
8
9
|
require_relative "../idle_client"
|
|
9
10
|
require_relative "service"
|
|
10
11
|
require_relative "side_question"
|
|
@@ -28,8 +29,8 @@ module Samagotchi
|
|
|
28
29
|
|
|
29
30
|
# The Engine's side of a Context: callables, so the Engine itself is
|
|
30
31
|
# never handed out. +messages+ returns the conversation, +notify+ takes
|
|
31
|
-
# (text, level, label, fallback_for:), +ask_user+ takes
|
|
32
|
-
#
|
|
32
|
+
# (text, level, label, fallback_for:), +ask_user+ takes a HookQuestion
|
|
33
|
+
# (the plugin's label in it) like the hook runtime's, +card+ takes
|
|
33
34
|
# Engine#show_card's keywords and returns the id, +ask_model+ takes
|
|
34
35
|
# (chat messages, timeout:, max_tokens:, cancel_controller:, model:: a
|
|
35
36
|
# configured model ref, nil for the session's) and returns the answer
|
|
@@ -363,13 +364,13 @@ module Samagotchi
|
|
|
363
364
|
# @return [Hash, nil] {selected:, freeform:, selected_indices:}, or nil
|
|
364
365
|
# (no one to ask, cancelled, bad options or effects)
|
|
365
366
|
def ask_user(question:, options:, header: nil, allow_freeform: false, effects: nil)
|
|
366
|
-
tags = effects.nil? ? {} : { effects: effects }
|
|
367
367
|
if (helper = event_helper(:ask_user))
|
|
368
|
+
tags = effects.nil? ? {} : { effects: effects }
|
|
368
369
|
return helper.call(question: question, options: options, header: header, allow_freeform: allow_freeform, **tags)
|
|
369
370
|
end
|
|
370
371
|
|
|
371
|
-
@host.ask_user.call(question: question, options: options, header: header,
|
|
372
|
-
|
|
372
|
+
@host.ask_user.call(HookQuestion.new(question: question, options: options, header: header,
|
|
373
|
+
allow_freeform: allow_freeform, hook: @label, effects: effects))
|
|
373
374
|
end
|
|
374
375
|
|
|
375
376
|
# Whether the running turn was cancelled (a long tool should stop).
|
|
@@ -12,8 +12,10 @@ module Samagotchi
|
|
|
12
12
|
class Http
|
|
13
13
|
# Plugin::Http's wire: one LLM::HTTP request per call (chi's TLS,
|
|
14
14
|
# proxies, User-Agent, cancel by closing the socket, and one log line
|
|
15
|
-
# without query, headers or body), with chi's retries off. Every
|
|
16
|
-
#
|
|
15
|
+
# without query, headers or body), with chi's retries off. Every
|
|
16
|
+
# network, TLS, protocol or provider error but a cancel becomes an
|
|
17
|
+
# HttpError, so a plugin rescues one type; anything else is a bug in
|
|
18
|
+
# chi, logged with its backtrace and raised as it is.
|
|
17
19
|
#
|
|
18
20
|
# Loaded with the first real request (it pulls in net/http and the
|
|
19
21
|
# LLM layer). Stateless.
|
|
@@ -29,6 +31,12 @@ module Samagotchi
|
|
|
29
31
|
Net::ProtocolError => "protocol error", Zlib::Error => "bad compressed body"
|
|
30
32
|
}.freeze
|
|
31
33
|
|
|
34
|
+
# The errors a request's wire may raise: each becomes an HttpError.
|
|
35
|
+
WIRE_ERRORS = [
|
|
36
|
+
*REASONS.keys, *LLM::HTTP::NETWORK_ERRORS, SystemCallError, IOError, OpenSSL::OpenSSLError,
|
|
37
|
+
LLM::ProviderError
|
|
38
|
+
].uniq.freeze
|
|
39
|
+
|
|
32
40
|
def initialize(bundle:)
|
|
33
41
|
@bundle = bundle.to_s
|
|
34
42
|
end
|
|
@@ -47,8 +55,11 @@ module Samagotchi
|
|
|
47
55
|
raise HttpCancelled, "#{method} #{where(uri)}: cancelled"
|
|
48
56
|
rescue HttpError
|
|
49
57
|
raise
|
|
50
|
-
rescue
|
|
58
|
+
rescue *WIRE_ERRORS => e
|
|
51
59
|
raise HttpError, "#{method} #{where(uri)}: #{reason(e)}"
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
Log.exception(:plugins, "http_transport_failed", e, bundle: @bundle)
|
|
62
|
+
raise
|
|
52
63
|
end
|
|
53
64
|
|
|
54
65
|
private
|
|
@@ -80,11 +80,10 @@ module Samagotchi
|
|
|
80
80
|
sid = resolve(id, state_dir)
|
|
81
81
|
delivered = SessionManager.deliver_turn(sid, prompt: text.to_s, client_id: ClientId::PLUGIN, state_dir: state_dir,
|
|
82
82
|
delivery: wire)
|
|
83
|
-
case delivered
|
|
83
|
+
case delivered.status
|
|
84
84
|
when :accepted then sid
|
|
85
85
|
when :refused
|
|
86
|
-
|
|
87
|
-
raise Error, "session #{sid[0, 8]} refused the message (#{ack["error"]}#{": #{ack["detail"]}" if ack["detail"]})"
|
|
86
|
+
raise Error, "session #{sid[0, 8]} refused the message (#{delivered.error}#{": #{delivered.detail}" if delivered.detail})"
|
|
88
87
|
when :timeout then raise Error, "session #{sid[0, 8]}'s worker did not answer in time; the message was not sent"
|
|
89
88
|
else raise Error, "the message to session #{sid[0, 8]} could not be written"
|
|
90
89
|
end
|
|
@@ -6,6 +6,7 @@ require "securerandom"
|
|
|
6
6
|
require "time"
|
|
7
7
|
|
|
8
8
|
require_relative "client_id"
|
|
9
|
+
require_relative "hook_question"
|
|
9
10
|
require_relative "log"
|
|
10
11
|
require_relative "pending_question"
|
|
11
12
|
require_relative "question_answer"
|
|
@@ -404,30 +405,32 @@ module Samagotchi
|
|
|
404
405
|
# web), single-select, kind "hook". A --non-interactive run has no one
|
|
405
406
|
# to ask: nil at once. Anything but an answer (a sync handler's text, no
|
|
406
407
|
# answer, cancelled) is nil too.
|
|
407
|
-
# @param
|
|
408
|
-
#
|
|
408
|
+
# @param asked [HookQuestion] its effects (label => effect for a parent
|
|
409
|
+
# agent's answer, Guardrails::ParentPolicy; a label left out is
|
|
410
|
+
# "answer") become the pending question's per-option tags. A label
|
|
409
411
|
# the options don't have, or an effect it doesn't know, makes the ask
|
|
410
412
|
# invalid (nil, warned), like bad options.
|
|
411
413
|
# @return [Hash, nil] {selected:, freeform:, selected_indices:} (a plain
|
|
412
414
|
# Hash: the bundle boundary)
|
|
413
|
-
def ask_for_hook(
|
|
415
|
+
def ask_for_hook(asked)
|
|
414
416
|
return nil if @interface_lookup.call == :non_interactive
|
|
415
417
|
|
|
416
|
-
|
|
418
|
+
hook = asked.hook.to_s
|
|
419
|
+
opts = Tools::AskUserQuestion.normalize_options(asked.options)
|
|
417
420
|
unless opts
|
|
418
|
-
Log.warn(:hooks, "ask_user_invalid", echo: "[samagotchi:hooks] #{hook} asked with invalid options (2-8 strings)", hook: hook
|
|
421
|
+
Log.warn(:hooks, "ask_user_invalid", echo: "[samagotchi:hooks] #{hook} asked with invalid options (2-8 strings)", hook: hook)
|
|
419
422
|
return nil
|
|
420
423
|
end
|
|
421
|
-
tags = Guardrails::ParentPolicy.option_tags(opts, effects)
|
|
424
|
+
tags = Guardrails::ParentPolicy.option_tags(opts, asked.effects)
|
|
422
425
|
if tags == false
|
|
423
426
|
Log.warn(:hooks, "ask_user_invalid",
|
|
424
427
|
echo: "[samagotchi:hooks] #{hook} asked with invalid effects (option label => " \
|
|
425
|
-
"#{Guardrails::ParentPolicy::EFFECTS.join("|")})", hook: hook
|
|
428
|
+
"#{Guardrails::ParentPolicy::EFFECTS.join("|")})", hook: hook)
|
|
426
429
|
return nil
|
|
427
430
|
end
|
|
428
431
|
|
|
429
|
-
fields = { question: question.to_s, options: opts, header: header, multi_select: false,
|
|
430
|
-
allow_freeform: !!allow_freeform, effects: tags, kind: "hook", hook: hook
|
|
432
|
+
fields = { question: asked.question.to_s, options: opts, header: asked.header, multi_select: false,
|
|
433
|
+
allow_freeform: !!asked.allow_freeform, effects: tags, kind: "hook", hook: hook }.compact
|
|
431
434
|
answer = open_question(fields)
|
|
432
435
|
return nil unless answer.is_a?(QuestionAnswer)
|
|
433
436
|
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../session"
|
|
4
|
+
require_relative "../session_inbox"
|
|
5
|
+
require_relative "../turn_checkpoint"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
module ReplyWait
|
|
9
|
+
# One ReplyWait.call: what the wait learned so far (the turn's input
|
|
10
|
+
# claimed, its turn seen running, any turn seen running, the worker
|
|
11
|
+
# gone since) and one look at the session per poll. Each check returns
|
|
12
|
+
# a Result that ends the wait, or nil to go on, in the order #look runs
|
|
13
|
+
# them. ReplyWait.call's parameters say what each means.
|
|
14
|
+
class Poll
|
|
15
|
+
def initialize(id, state_dir:, cursor:, timeout:, poll_interval:, cancelled:, baseline:, owner_grace:, interject:,
|
|
16
|
+
turn:, queued:, ended_before:)
|
|
17
|
+
@id = id
|
|
18
|
+
@state_dir = state_dir
|
|
19
|
+
@cursor = cursor
|
|
20
|
+
@poll_interval = poll_interval
|
|
21
|
+
@cancelled = cancelled
|
|
22
|
+
@baseline = baseline
|
|
23
|
+
@owner_grace = owner_grace
|
|
24
|
+
@interject = interject
|
|
25
|
+
@ended_before = ended_before
|
|
26
|
+
@deadline = timeout && (ReplyWait.monotonic + timeout.to_f)
|
|
27
|
+
@turn = turn.to_s.empty? ? nil : turn.to_s
|
|
28
|
+
@session_dir = Session.session_dir(id, state_dir: state_dir) if @turn
|
|
29
|
+
# Whether the turn's input file has been taken (latched): looked at
|
|
30
|
+
# before each load, so a running session seen after it is that turn
|
|
31
|
+
# or a later one, never the turn that ran when the message was sent.
|
|
32
|
+
@claimed = false
|
|
33
|
+
# Its input runs a turn of its own (a queue, images: +queued+, or
|
|
34
|
+
# seen waiting as one): never merged into another turn.
|
|
35
|
+
@own_turn_input = queued
|
|
36
|
+
# The turn was seen running (its mark's id), and last_turn's
|
|
37
|
+
# ended_at then: a later end that isn't its is a turn after it.
|
|
38
|
+
@seen_own = false
|
|
39
|
+
@own_base = nil
|
|
40
|
+
@seen_running = false
|
|
41
|
+
@gone_since = nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Result]
|
|
45
|
+
def run
|
|
46
|
+
loop do
|
|
47
|
+
result = look
|
|
48
|
+
return result if result
|
|
49
|
+
|
|
50
|
+
if @interject
|
|
51
|
+
started = ReplyWait.monotonic
|
|
52
|
+
@interject.call
|
|
53
|
+
@deadline += ReplyWait.monotonic - started if @deadline
|
|
54
|
+
next if @cancelled.call
|
|
55
|
+
end
|
|
56
|
+
sleep(@poll_interval)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# One poll: the input file, then the session as loaded now.
|
|
63
|
+
# @return [Result, nil]
|
|
64
|
+
def look
|
|
65
|
+
look_at_input
|
|
66
|
+
session = Session.load(@id, state_dir: @state_dir)
|
|
67
|
+
if (result = removed(session))
|
|
68
|
+
return result
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
note_own_running(session)
|
|
72
|
+
follow_merge(session)
|
|
73
|
+
reply(session) || settled_turn(session) || status_end(session) || question(session) || canceled ||
|
|
74
|
+
ended_before_reply(session) || ended_before_turn(session) || turn_end(session) || owner_gone(session) ||
|
|
75
|
+
timed_out
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def look_at_input
|
|
79
|
+
return unless @turn && !@claimed
|
|
80
|
+
|
|
81
|
+
waiting = SessionInbox.waiting_input(@session_dir, @turn)
|
|
82
|
+
@own_turn_input ||= waiting == :turn_end
|
|
83
|
+
@claimed = waiting.nil?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Taken back before it ran (POST turn/dequeue): no turn will come. A
|
|
87
|
+
# tombstone with the claimed file still there is a removal not done
|
|
88
|
+
# yet (or one a turn's claim beat): looked at again next poll. One
|
|
89
|
+
# whose turn ran anyway (its reply, or last_turn its own) is a stray:
|
|
90
|
+
# a worker killed between the tombstone and the claim left it, or a
|
|
91
|
+
# short turn won the claim and ended before the removal took it back.
|
|
92
|
+
def removed(session)
|
|
93
|
+
return nil unless @turn && @claimed && !@seen_own && SessionInbox.removed?(@session_dir, @turn)
|
|
94
|
+
return nil if SessionInbox.claimed_input?(@session_dir, @turn) || turn_ran?(session)
|
|
95
|
+
|
|
96
|
+
Result.new(status: :no_reply, outcome: "removed", session: session)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def turn_ran?(session)
|
|
100
|
+
session.last_turn&.enqueued_id == @turn || !SessionInbox.reply_for(@session_dir, @turn).nil?
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def note_own_running(session)
|
|
104
|
+
return unless @turn && !@seen_own && own_running?(session)
|
|
105
|
+
|
|
106
|
+
@seen_own = true
|
|
107
|
+
@own_base = session.last_turn&.ended_at
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Taken but never run as a turn of its own: a running turn's drain
|
|
111
|
+
# merged it as a steer, so that turn's reply (or question) is its
|
|
112
|
+
# answer. Waited for as the next reply past the cursor, as without
|
|
113
|
+
# turn:, never as a turn that will not come.
|
|
114
|
+
def follow_merge(session)
|
|
115
|
+
return unless @turn && @claimed && !@seen_own && !@own_turn_input &&
|
|
116
|
+
ReplyWait.merged_elsewhere?(session, @turn, @baseline, @session_dir)
|
|
117
|
+
|
|
118
|
+
@turn = nil
|
|
119
|
+
@seen_running = running?(session)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def reply(session)
|
|
123
|
+
reply = if @turn
|
|
124
|
+
ReplyWait.reply_for_turn(@id, state_dir: @state_dir, turn: @turn)
|
|
125
|
+
else
|
|
126
|
+
ReplyWait.reply_past(@id, state_dir: @state_dir, cursor: @cursor)
|
|
127
|
+
end
|
|
128
|
+
return nil unless reply
|
|
129
|
+
|
|
130
|
+
reply.session = ReplyWait.settled(session, @id, state_dir: @state_dir)
|
|
131
|
+
reply
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Its worker died in it and the next one settled it, or an input
|
|
135
|
+
# command finished without a turn. After the reply check: a worker
|
|
136
|
+
# killed between writing the reply and its idle save left the reply.
|
|
137
|
+
def settled_turn(session)
|
|
138
|
+
settled = @turn && TurnCheckpoint.settled_turn(@session_dir, @turn)
|
|
139
|
+
return nil unless settled
|
|
140
|
+
|
|
141
|
+
Result.new(status: :no_reply, outcome: settled.outcome, session: session, origin: settled.origin,
|
|
142
|
+
**settled.stop_facts)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def status_end(session)
|
|
146
|
+
case session.status
|
|
147
|
+
when Session::STATUS_ERROR then Result.new(status: :error, text: session.last_prompt.to_s.strip, session: session)
|
|
148
|
+
when Session::STATUS_STOPPED then Result.new(status: :stopped, session: session)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Only a question a live worker holds waits for an answer (the lists'
|
|
153
|
+
# rule): one a dead worker left waits for no one, and a chi REPL's is
|
|
154
|
+
# answered there.
|
|
155
|
+
def question(session)
|
|
156
|
+
pending = session.pending_question
|
|
157
|
+
return nil unless pending && !@baseline&.question?(pending.id) &&
|
|
158
|
+
(@turn.nil? || ReplyWait.own_question?(session, @turn, @claimed)) &&
|
|
159
|
+
session.waiting_question(live: SessionManager.worker_live?(@id, state_dir: @state_dir))
|
|
160
|
+
|
|
161
|
+
Result.new(status: :waiting_for_answer, question: pending, session: session)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def canceled = @cancelled.call ? Result.new(status: :canceled) : nil
|
|
165
|
+
|
|
166
|
+
# The turn that ended before this wait (ended_before: chi send --wait
|
|
167
|
+
# with no message, on a session that went idle in the gap between
|
|
168
|
+
# waits): its last_turn still stands, the session is idle, so its end
|
|
169
|
+
# is the answer, not a wait that times out "running" again. Its reply
|
|
170
|
+
# file sorts at the cursor (the newest), not past it: it is the
|
|
171
|
+
# turn's when it is at least as new as the turn's end (the worker
|
|
172
|
+
# writes it after recording the ended_at), else the turn left no
|
|
173
|
+
# reply and an earlier turn's file is not its.
|
|
174
|
+
def ended_before_reply(session)
|
|
175
|
+
return nil unless @ended_before && @turn.nil? && session.status == Session::STATUS_IDLE && baseline_end?(session)
|
|
176
|
+
|
|
177
|
+
file = ReplyWait.newest_reply(@id, state_dir: @state_dir)
|
|
178
|
+
path = File.join(ReplyWait.reply_dir(@id, state_dir: @state_dir), file) if file
|
|
179
|
+
if file && ReplyWait.reply_is_turns?(path, session.last_turn.ended_at)
|
|
180
|
+
reply = Result.new(status: :done, text: File.read(path), file: file)
|
|
181
|
+
reply.session = ReplyWait.settled(session, @id, state_dir: @state_dir)
|
|
182
|
+
return reply
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
ReplyWait.no_reply(session, @baseline, fresh: true)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# The same for a turn: wait whose turn is that last one (not running
|
|
189
|
+
# again). The turn's own reply lookup (#reply) already found its file,
|
|
190
|
+
# if one exists; without it the newest file is no fallback: it could
|
|
191
|
+
# be another turn's.
|
|
192
|
+
def ended_before_turn(session)
|
|
193
|
+
return nil unless @ended_before && @turn && session.last_turn&.enqueued_id == @turn && !own_running?(session) &&
|
|
194
|
+
baseline_end?(session)
|
|
195
|
+
|
|
196
|
+
ReplyWait.no_reply(session, @baseline, fresh: true)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def turn_end(session)
|
|
200
|
+
return own_turn_end(session) if @turn
|
|
201
|
+
|
|
202
|
+
if running?(session)
|
|
203
|
+
@seen_running = true
|
|
204
|
+
return nil
|
|
205
|
+
end
|
|
206
|
+
# It ran and is idle again with no new reply: canceled, failed or
|
|
207
|
+
# empty. Before it was ever seen running, idle means a
|
|
208
|
+
# file-delivered message its worker has not picked up yet. With a
|
|
209
|
+
# baseline only a turn that ended since counts: a session left
|
|
210
|
+
# running by a worker that died goes idle when the next worker
|
|
211
|
+
# settles that turn (TurnCheckpoint.settle), before the sent
|
|
212
|
+
# message's turn runs.
|
|
213
|
+
return nil unless (@seen_running && @baseline.nil?) || ReplyWait.turn_ended_since?(session, @baseline)
|
|
214
|
+
|
|
215
|
+
# The worker writes the reply before its idle save, so the read
|
|
216
|
+
# above has it; one more look costs nothing should that change.
|
|
217
|
+
ReplyWait.reply_past(@id, state_dir: @state_dir, cursor: @cursor)&.tap { |r| r.session = session } ||
|
|
218
|
+
ReplyWait.no_reply(session, @baseline)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Only the turn's own end: the session runs another turn when the
|
|
222
|
+
# wait starts, and that one's end is not the answer. A last_turn with
|
|
223
|
+
# its id is its end, even with a next queued turn running by now (the
|
|
224
|
+
# look between the two missed the idle). Only an end after the
|
|
225
|
+
# baseline: a continue offer answered from idle leaves the exhausted
|
|
226
|
+
# turn's last_turn, with the same id, until the continue turn (or a
|
|
227
|
+
# Stop) records its own.
|
|
228
|
+
def own_turn_end(session)
|
|
229
|
+
if session.last_turn&.enqueued_id == @turn && ReplyWait.ended_since_baseline?(session, @baseline)
|
|
230
|
+
return ReplyWait.reply_for_turn(@id, state_dir: @state_dir, turn: @turn)&.tap { |r| r.session = session } ||
|
|
231
|
+
ReplyWait.no_reply(session, @baseline, fresh: true)
|
|
232
|
+
end
|
|
233
|
+
# Seen running, and now another turn runs or a later turn ended
|
|
234
|
+
# while last_turn (looked at first) isn't its: lost (its worker
|
|
235
|
+
# died), or it ended with no reply and a next turn ran and ended
|
|
236
|
+
# between two looks. Waiting on would sit through those turns.
|
|
237
|
+
return Result.new(status: :no_reply, session: session) if @seen_own && ReplyWait.past_own?(session, @turn, @own_base)
|
|
238
|
+
|
|
239
|
+
nil
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def owner_gone(session)
|
|
243
|
+
return nil unless @owner_grace
|
|
244
|
+
|
|
245
|
+
if SessionManager.session_owner(@id, state_dir: @state_dir)
|
|
246
|
+
@gone_since = nil
|
|
247
|
+
elsif ReplyWait.monotonic - (@gone_since ||= ReplyWait.monotonic) > @owner_grace
|
|
248
|
+
return Result.new(status: :worker_gone, session: session)
|
|
249
|
+
end
|
|
250
|
+
nil
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def timed_out = @deadline && ReplyWait.monotonic > @deadline ? Result.new(status: :timeout) : nil
|
|
254
|
+
|
|
255
|
+
# Whether the session's last turn is the one that had ended at the
|
|
256
|
+
# baseline (its ended_at set and unchanged).
|
|
257
|
+
def baseline_end?(session)
|
|
258
|
+
ended = session.last_turn&.ended_at
|
|
259
|
+
(!ended.to_s.empty? && @baseline&.ended_at?(ended)) || false
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def running?(session) = session.status == Session::STATUS_RUNNING
|
|
263
|
+
|
|
264
|
+
def own_running?(session) = running?(session) && session.interrupted_turn&.enqueued_id == @turn
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|