samagotchi 0.102.0 → 0.104.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 +93 -1
- data/bin/chi +60 -191
- data/docs/architecture.md +67 -1
- data/docs/cli.md +190 -9
- data/docs/configuration.md +7 -1
- data/docs/guardrails.md +4 -2
- data/docs/internals/context-telemetry.md +6 -2
- data/docs/internals/engine.md +5 -2
- data/docs/internals/llm-context-forget.md +11 -1
- data/docs/internals/participants.md +137 -0
- data/docs/internals/remote-sessions.md +429 -0
- data/docs/internals/working-set.md +95 -11
- data/docs/plugins.md +33 -3
- data/docs/sessions.md +16 -7
- data/docs/sub-agent.md +16 -0
- data/docs/testing.md +68 -4
- data/lib/samagotchi/answer_command.rb +27 -25
- data/lib/samagotchi/bridge.rb +31 -7
- data/lib/samagotchi/bridge_client/event_stream.rb +86 -12
- data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
- data/lib/samagotchi/bridge_client.rb +26 -39
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_invocation.rb +40 -0
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/flags.rb +5 -0
- data/lib/samagotchi/cli/parent_wait.rb +22 -13
- data/lib/samagotchi/context_command.rb +80 -73
- data/lib/samagotchi/context_status.rb +15 -2
- data/lib/samagotchi/engine.rb +28 -14
- data/lib/samagotchi/guardrail_wiring.rb +7 -1
- data/lib/samagotchi/idle_recap.rb +8 -2
- data/lib/samagotchi/idle_scheduler.rb +34 -9
- data/lib/samagotchi/kernel_loop.rb +21 -2
- data/lib/samagotchi/launch_mode.rb +26 -0
- data/lib/samagotchi/llm/chat_loop.rb +8 -1
- data/lib/samagotchi/llm_context_forget.rb +13 -6
- data/lib/samagotchi/note_command.rb +8 -7
- data/lib/samagotchi/owner_lock.rb +65 -5
- data/lib/samagotchi/parent_report.rb +55 -43
- data/lib/samagotchi/participants.rb +122 -0
- data/lib/samagotchi/remote_attach_command.rb +119 -0
- data/lib/samagotchi/remote_command.rb +170 -0
- data/lib/samagotchi/remotes.rb +130 -0
- data/lib/samagotchi/run_options.rb +245 -0
- data/lib/samagotchi/send_command/busy.rb +58 -0
- data/lib/samagotchi/send_command/parent_gate.rb +23 -10
- data/lib/samagotchi/send_command/wait_target.rb +16 -3
- data/lib/samagotchi/send_command.rb +173 -108
- data/lib/samagotchi/session.rb +38 -9
- data/lib/samagotchi/session_access/contract.rb +167 -0
- data/lib/samagotchi/session_access/dispatch.rb +152 -0
- data/lib/samagotchi/session_access/http_transport.rb +109 -0
- data/lib/samagotchi/session_access/local.rb +400 -0
- data/lib/samagotchi/session_access/remote.rb +734 -0
- data/lib/samagotchi/session_access/web_bridge_client.rb +236 -0
- data/lib/samagotchi/session_access.rb +223 -0
- data/lib/samagotchi/session_archive_command.rb +9 -5
- data/lib/samagotchi/session_commands.rb +20 -15
- data/lib/samagotchi/session_continue.rb +1 -1
- data/lib/samagotchi/session_delete_command.rb +8 -4
- data/lib/samagotchi/session_inbox.rb +18 -9
- data/lib/samagotchi/session_manager.rb +27 -6
- data/lib/samagotchi/session_setup.rb +11 -6
- data/lib/samagotchi/sessions_command.rb +36 -14
- data/lib/samagotchi/share_command.rb +174 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +66 -17
- data/lib/samagotchi/terminal_ui/attach_link.rb +114 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +155 -87
- data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
- data/lib/samagotchi/terminal_ui/local_link.rb +135 -0
- data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
- data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +28 -3
- data/lib/samagotchi/terminal_ui.rb +6 -3
- data/lib/samagotchi/testing/waiting.rb +92 -9
- data/lib/samagotchi/title_command.rb +9 -11
- data/lib/samagotchi/tools/task_runtime.rb +17 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +530 -126
- data/lib/samagotchi/web/grants.rb +302 -0
- data/lib/samagotchi/web/participant_access.rb +286 -0
- data/lib/samagotchi/web/principal.rb +29 -0
- data/lib/samagotchi/web/public/data.js +3 -2
- data/lib/samagotchi/web/public/index.html +20 -4
- data/lib/samagotchi/web/public/stage_frame.js +7 -2
- data/lib/samagotchi/web/public/stage_view.js +3 -2
- data/lib/samagotchi/web/public/stream_handlers.js +3 -1
- data/lib/samagotchi/web/public/working_set.js +159 -28
- data/lib/samagotchi/web/public/working_set_rail.js +39 -12
- data/lib/samagotchi/web/public/working_set_view.js +91 -33
- data/lib/samagotchi/web/server.rb +3 -1
- data/lib/samagotchi/web/session_access_routes.rb +459 -0
- data/lib/samagotchi/worker.rb +26 -5
- data/lib/samagotchi/worker_sidecar.rb +12 -5
- data/lib/samagotchi/working_set.rb +88 -14
- data/lib/samagotchi/working_set_command.rb +133 -30
- data/lib/samagotchi/working_set_pin.rb +34 -0
- data/lib/samagotchi/working_set_steer.rb +191 -27
- metadata +28 -1
|
@@ -30,6 +30,7 @@ require_relative "tool_ids"
|
|
|
30
30
|
require_relative "llm_context_view"
|
|
31
31
|
require_relative "llm_context_apply"
|
|
32
32
|
require_relative "llm_context_forget"
|
|
33
|
+
require_relative "working_set"
|
|
33
34
|
require_relative "llm_context_notice"
|
|
34
35
|
require_relative "answer_display"
|
|
35
36
|
require_relative "llm/model_result"
|
|
@@ -146,6 +147,11 @@ module Samagotchi
|
|
|
146
147
|
# write; the Engine's own (it hands it to a kernel it was given).
|
|
147
148
|
attr_accessor :reminder_store
|
|
148
149
|
|
|
150
|
+
# @return [Float, nil] the last counted request's chars per token
|
|
151
|
+
# (ContextStatus#counted_chars_per_token), for the next turn's first
|
|
152
|
+
# estimate (no dip at a turn's start)
|
|
153
|
+
attr_accessor :counted_chars_per_token
|
|
154
|
+
|
|
149
155
|
# @return [ModelProfile] the active prompt profile
|
|
150
156
|
attr_reader :profile
|
|
151
157
|
|
|
@@ -178,6 +184,10 @@ module Samagotchi
|
|
|
178
184
|
# a plugin tool's row refs resolve their files against (ToolRunner,
|
|
179
185
|
# RowRefs); the Engine sets it. nil: the process's own (Dir.pwd).
|
|
180
186
|
attr_accessor :session_cwd
|
|
187
|
+
# The session's working-set pins (a callable → Array<WorkingSetPin>),
|
|
188
|
+
# which forget_outputs refuses (LLMContextForget); the Engine sets it.
|
|
189
|
+
# nil: none.
|
|
190
|
+
attr_accessor :working_set_pins
|
|
181
191
|
# The session's folder (a callable → String or nil), where ToolRunner
|
|
182
192
|
# saves a cut output whole (ToolOutputFiles); the Engine sets it. nil:
|
|
183
193
|
# none (a bare kernel), the cut says the output wasn't saved.
|
|
@@ -304,7 +314,8 @@ module Samagotchi
|
|
|
304
314
|
|
|
305
315
|
def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:, reasoning_since: nil)
|
|
306
316
|
conversation = prepare_conversation(messages, keep_thought_from: ReasoningHistory.run_start(messages, reasoning_since))
|
|
307
|
-
context = ContextStatus.new(conversation: conversation, llm_context: @turn_settings.llm_context
|
|
317
|
+
context = ContextStatus.new(conversation: conversation, llm_context: @turn_settings.llm_context,
|
|
318
|
+
counted_chars_per_token: @counted_chars_per_token)
|
|
308
319
|
emit = ->(event) { emit_stream_event(on_stream_event, event) }
|
|
309
320
|
@llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: context, emit: emit)
|
|
310
321
|
Turn.new(
|
|
@@ -604,6 +615,7 @@ module Samagotchi
|
|
|
604
615
|
response = generation.response.to_s
|
|
605
616
|
turn.context.generation_done(generation.usage, prompt_chars: request.prompt.length, image_tokens: request.image_tokens,
|
|
606
617
|
window: request.window)
|
|
618
|
+
@counted_chars_per_token = turn.context.counted_chars_per_token || @counted_chars_per_token
|
|
607
619
|
emit(turn, type: :generation_completed, iteration: turn.iteration, content_length: response.length,
|
|
608
620
|
thinking_chars: thinking_chars(response, generation.streamed_thinking),
|
|
609
621
|
served_model: generation.served_model, requested_model: turn.model_name,
|
|
@@ -945,8 +957,15 @@ module Samagotchi
|
|
|
945
957
|
end
|
|
946
958
|
|
|
947
959
|
apply = -> { apply_llm_context!(turn.conversation, llm_context, context: turn.context, emit: turn.emit) }
|
|
948
|
-
LLMContextForget.call(turn, call, llm_context: llm_context, apply: apply)
|
|
960
|
+
LLMContextForget.call(turn, call, llm_context: llm_context, apply: apply, pinned: method(:pinned_ids))
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
# The ids of +conversation+'s outputs the session's pins hold.
|
|
964
|
+
def pinned_ids(conversation)
|
|
965
|
+
pins = Array(@working_set_pins&.call)
|
|
966
|
+
WorkingSet.pinned_ids(conversation, pins: pins, cwd: @session_cwd&.call)
|
|
949
967
|
end
|
|
968
|
+
private :pinned_ids
|
|
950
969
|
|
|
951
970
|
# Before each request of both loops (+moment+ :request; the warm-up
|
|
952
971
|
# too, on its own copy, as the next turn's first request) and at the
|
|
@@ -11,6 +11,18 @@ module Samagotchi
|
|
|
11
11
|
# reach the worker: a /model command, and no_interrupt on each posted
|
|
12
12
|
# turn; --memory and --mute are session fields the worker reads.)
|
|
13
13
|
REPL_ONLY = { verbose: "--verbose" }.freeze
|
|
14
|
+
# What an attached run can't be combined with (the worker runs its turns).
|
|
15
|
+
ATTACHED_CONFLICTS = { non_interactive: "--non-interactive", verbose: "--verbose" }.freeze
|
|
16
|
+
# Opening a `chi scratch` session a killed REPL left behind would turn it
|
|
17
|
+
# into a kept one.
|
|
18
|
+
SCRATCH_LEFTOVER = "that's a leftover scratch session; it is deleted at the next sweep (chi sessions clean)"
|
|
19
|
+
# A worker spawned or resumed for an attach whose Bridge never came (%s:
|
|
20
|
+
# the session id).
|
|
21
|
+
BRIDGE_LATE = "the worker for session %s did not start its Bridge in time"
|
|
22
|
+
# An attach (or a resume) that found the session's worker leaving, its
|
|
23
|
+
# Bridge closed and its recap on leave still being written: it waits for
|
|
24
|
+
# the worker to let go (OwnerLock.await_release), then starts a new one.
|
|
25
|
+
RECAP_WAIT = "(waiting for the session to finish its recap…)"
|
|
14
26
|
|
|
15
27
|
module_function
|
|
16
28
|
|
|
@@ -30,5 +42,19 @@ module Samagotchi
|
|
|
30
42
|
|
|
31
43
|
[:attached, nil]
|
|
32
44
|
end
|
|
45
|
+
|
|
46
|
+
# Why an attached run's flags don't go together, or nil when they do.
|
|
47
|
+
# @param options [Hash] bin/chi's parsed options
|
|
48
|
+
# @return [String, nil]
|
|
49
|
+
def attached_conflict(options)
|
|
50
|
+
conflict = ATTACHED_CONFLICTS.find { |key, _flag| options[key] }
|
|
51
|
+
if options[:attach] && options[:shared]
|
|
52
|
+
"use either --attach ID or --shared"
|
|
53
|
+
elsif options[:attach] && options[:resume]
|
|
54
|
+
"--attach takes the session id; --resume goes with --shared"
|
|
55
|
+
elsif conflict
|
|
56
|
+
"--attach/--shared can't be combined with #{conflict.last}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
33
59
|
end
|
|
34
60
|
end
|
|
@@ -63,6 +63,11 @@ module Samagotchi
|
|
|
63
63
|
# The session every request is for; Engine sets it with the adapter.
|
|
64
64
|
# Goes out as a Session-Id header (OpenAIChat#chat).
|
|
65
65
|
attr_accessor :session_id
|
|
66
|
+
# The last counted request's chars per token
|
|
67
|
+
# (ContextStatus#counted_chars_per_token), carried to the next turn's
|
|
68
|
+
# tracker: its first estimate counts the tool schemas the chars leave
|
|
69
|
+
# out, so the ctx value doesn't dip at each turn's start.
|
|
70
|
+
attr_accessor :counted_chars_per_token
|
|
66
71
|
|
|
67
72
|
# @param kernel [KernelLoop] tool dispatch, thought stripping, hooks
|
|
68
73
|
# @param adapter [OpenAIChat, nil] the host to talk to; Engine sets it
|
|
@@ -585,7 +590,8 @@ module Samagotchi
|
|
|
585
590
|
@recovery_repeated = false
|
|
586
591
|
# The names the last request declared deferred.
|
|
587
592
|
@deferred = []
|
|
588
|
-
@context = ContextStatus.new(conversation: conversation, llm_context: loop.llm_context
|
|
593
|
+
@context = ContextStatus.new(conversation: conversation, llm_context: loop.llm_context,
|
|
594
|
+
counted_chars_per_token: loop.counted_chars_per_token)
|
|
589
595
|
loop.llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: @context,
|
|
590
596
|
emit: method(:emit))
|
|
591
597
|
end
|
|
@@ -976,6 +982,7 @@ module Samagotchi
|
|
|
976
982
|
"completion_tokens" => usage.completion_tokens } })
|
|
977
983
|
@context.generation_done(counts, prompt_chars: @request_chars.to_i, image_tokens: @request_image_tokens.to_i,
|
|
978
984
|
window: window)
|
|
985
|
+
@loop.counted_chars_per_token = @context.counted_chars_per_token || @loop.counted_chars_per_token
|
|
979
986
|
end
|
|
980
987
|
|
|
981
988
|
# The model's answer at debug level, as the native loop dumps its
|
|
@@ -36,7 +36,10 @@ module Samagotchi
|
|
|
36
36
|
# to free anything (SMALL), an output already stubbed or forgotten, an
|
|
37
37
|
# id nothing in context has;
|
|
38
38
|
# - an output the user dropped (WorkingSetSteer): neither forgotten nor
|
|
39
|
-
# restored by the model, in WorkingSetSteer::Refused's words
|
|
39
|
+
# restored by the model, in WorkingSetSteer::Refused's words;
|
|
40
|
+
# - an output of an item the user pinned (+pinned+: WorkingSet.pinned_ids
|
|
41
|
+
# of the session's pins), in WorkingSetSteer::Refused's words too (a
|
|
42
|
+
# restore of one is fine: it brings the output back).
|
|
40
43
|
# A read is never restored: reading the file again is its restore, and
|
|
41
44
|
# the file may have changed.
|
|
42
45
|
module LLMContextForget
|
|
@@ -74,8 +77,10 @@ module Samagotchi
|
|
|
74
77
|
# call (both loops add the model's entry before its calls run).
|
|
75
78
|
# @param apply [#call] → LLMContextApply::Outcome: the apply rule at a
|
|
76
79
|
# request, on the turn's conversation (KernelLoop#apply_llm_context!)
|
|
80
|
+
# @param pinned [#call, nil] conversation → the ids the user's pins
|
|
81
|
+
# hold (WorkingSet.pinned_ids), asked only when a forget is asked
|
|
77
82
|
# @return [String] the tool result
|
|
78
|
-
def call(turn, call, llm_context:, apply:, root: Dir.pwd, now: Time.now.utc.iso8601(3))
|
|
83
|
+
def call(turn, call, llm_context:, apply:, root: Dir.pwd, pinned: nil, now: Time.now.utc.iso8601(3))
|
|
79
84
|
request = Tools::ForgetOutputs.parse(call)
|
|
80
85
|
conversation = turn.conversation
|
|
81
86
|
return restore(turn, request.restore, llm_context) if request.restore? && request.ids.empty?
|
|
@@ -85,7 +90,8 @@ module Samagotchi
|
|
|
85
90
|
"what's ruled out, NEXT), since it is all that stays of them"
|
|
86
91
|
end
|
|
87
92
|
|
|
88
|
-
|
|
93
|
+
pinned_ids = pinned ? pinned.call(conversation) : Set.new
|
|
94
|
+
forgotten, refused = forget(conversation, request, llm_context.protect_steps, root, now, pinned_ids)
|
|
89
95
|
lines = if forgotten.empty?
|
|
90
96
|
["nothing forgotten"]
|
|
91
97
|
else
|
|
@@ -99,7 +105,7 @@ module Samagotchi
|
|
|
99
105
|
# Saves a staged forget on each output +request+ names that may go.
|
|
100
106
|
# @return [Array(Array<String>, Hash{String => String})] the ids
|
|
101
107
|
# forgotten, and the refused ones with why
|
|
102
|
-
def forget(conversation, request, protect_steps, root, now)
|
|
108
|
+
def forget(conversation, request, protect_steps, root, now, pinned_ids)
|
|
103
109
|
outputs = outputs(conversation)
|
|
104
110
|
last = LLMContextStale.step_count(conversation)
|
|
105
111
|
# The steps before the call's own: protected_ids counts that one too.
|
|
@@ -107,7 +113,7 @@ module Samagotchi
|
|
|
107
113
|
root: root)
|
|
108
114
|
refused = {}
|
|
109
115
|
forgotten = request.ids.select do |id|
|
|
110
|
-
why = refusal(conversation, outputs[id], request.keep[id], last, protect_steps, kept_reads)
|
|
116
|
+
why = refusal(conversation, outputs[id], request.keep[id], last, protect_steps, kept_reads, pinned_ids)
|
|
111
117
|
refused[id] = why if why
|
|
112
118
|
why.nil?
|
|
113
119
|
end
|
|
@@ -124,11 +130,12 @@ module Samagotchi
|
|
|
124
130
|
# Why +output+ can't go (a String for the result), nil when it can.
|
|
125
131
|
# +last+: the call's own step; the protect_steps steps before it are
|
|
126
132
|
# the model's latest outputs.
|
|
127
|
-
def refusal(conversation, output, keep, last, protect_steps, kept_reads)
|
|
133
|
+
def refusal(conversation, output, keep, last, protect_steps, kept_reads, pinned_ids)
|
|
128
134
|
return "no output with that id in context (ids look like t42)" unless output
|
|
129
135
|
|
|
130
136
|
held = LLMContextEdit.on(conversation[output.index])[output.ref.id]
|
|
131
137
|
return WorkingSetSteer::Refused.text(:dropped) if held&.kind == LLMContextEdit::DROP
|
|
138
|
+
return WorkingSetSteer::Refused.text(:pinned_by_user) if pinned_ids.include?(output.ref.id)
|
|
132
139
|
return "already forgotten or stubbed" if held
|
|
133
140
|
if protect_steps.positive? && output.step >= last - protect_steps
|
|
134
141
|
return "from your last #{protect_steps} step#{"s" unless protect_steps == 1}; you may still need it, forget it later"
|
|
@@ -6,6 +6,7 @@ require_relative "note_delivery"
|
|
|
6
6
|
require_relative "session_manager"
|
|
7
7
|
require_relative "cli/command"
|
|
8
8
|
require_relative "cli/flags"
|
|
9
|
+
require_relative "session_access/local"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
11
12
|
# `chi note`: push text into sessions as a context note (background the
|
|
@@ -33,12 +34,15 @@ module Samagotchi
|
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
# @param argv [Array<String>] the arguments after "note"
|
|
36
|
-
|
|
37
|
+
# @param access [SessionAccess::Local, nil] where the sessions are;
|
|
38
|
+
# default this machine's, in +state_dir+
|
|
39
|
+
def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, state_dir: nil, access: nil)
|
|
37
40
|
@argv = argv.dup
|
|
38
41
|
@stdin = stdin
|
|
39
42
|
@stdout = stdout
|
|
40
43
|
@stderr = stderr
|
|
41
44
|
@state_dir = state_dir || Session.default_state_dir
|
|
45
|
+
@access = access || SessionAccess::Local.new(state_dir: @state_dir)
|
|
42
46
|
end
|
|
43
47
|
|
|
44
48
|
# @return [Integer] exit status: 0 all queued, 1 any refused or failed,
|
|
@@ -93,8 +97,7 @@ module Samagotchi
|
|
|
93
97
|
|
|
94
98
|
# --all: every live session. @return [Integer] exit status
|
|
95
99
|
def deliver_to_live(text, source)
|
|
96
|
-
ids =
|
|
97
|
-
state_dir: @state_dir).map { |s| s[:id] }
|
|
100
|
+
ids = @access.summaries(live: true, include_tests: Session.test_session_env?).map { |s| s[:id] }
|
|
98
101
|
if ids.empty?
|
|
99
102
|
error_line("chi note: no live sessions (chi sessions list --live --scope=all)")
|
|
100
103
|
return 1
|
|
@@ -104,9 +107,7 @@ module Samagotchi
|
|
|
104
107
|
end
|
|
105
108
|
|
|
106
109
|
def resolve(given)
|
|
107
|
-
|
|
108
|
-
Session.load(id, state_dir: @state_dir)
|
|
109
|
-
id
|
|
110
|
+
@access.resolve(given)
|
|
110
111
|
rescue ArgumentError => e
|
|
111
112
|
message = e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"
|
|
112
113
|
error_line("chi note: #{message}")
|
|
@@ -115,7 +116,7 @@ module Samagotchi
|
|
|
115
116
|
|
|
116
117
|
# @return [Boolean] whether the note was queued
|
|
117
118
|
def deliver(id, text, source)
|
|
118
|
-
result =
|
|
119
|
+
result = @access.note(id, text: text, source: source)
|
|
119
120
|
@stdout.puts("#{id[0, 8]} #{outcome(result)}")
|
|
120
121
|
result.delivered?
|
|
121
122
|
end
|
|
@@ -19,17 +19,37 @@ module Samagotchi
|
|
|
19
19
|
DEFAULT_WAIT = 2.0
|
|
20
20
|
RETRY_INTERVAL = 0.05
|
|
21
21
|
OWNER_READ_ATTEMPTS = 10
|
|
22
|
+
# Seconds .await_release waits past a leaving worker's own budget: its
|
|
23
|
+
# last save, the task watch's absorb, its exit.
|
|
24
|
+
LEAVE_MARGIN = 5.0
|
|
25
|
+
# Seconds between .await_release's looks at the lock.
|
|
26
|
+
RELEASE_POLL = 0.1
|
|
22
27
|
|
|
23
28
|
# Who holds the lock, as .owner reads it from the lock file. Its fields
|
|
24
29
|
# may be nil for a moment right after acquisition.
|
|
25
|
-
# kind: "worker" or "tui"; pid as written (an Integer).
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
# kind: "worker" or "tui"; pid as written (an Integer). leaving_at (an
|
|
31
|
+
# ISO 8601 time) and leave_wait (seconds): set once a worker is leaving
|
|
32
|
+
# (#mark_leaving), its Bridge closed, the lock held for its last work
|
|
33
|
+
# (the recap on leave) for up to leave_wait.
|
|
34
|
+
Owner = Data.define(:pid, :kind, :started_at, :leaving_at, :leave_wait) do
|
|
35
|
+
def initialize(pid: nil, kind: nil, started_at: nil, leaving_at: nil, leave_wait: nil) = super
|
|
28
36
|
|
|
29
37
|
# The interactive TUI's: it reads no input files and takes no notes.
|
|
30
38
|
def tui? = kind == "tui"
|
|
31
39
|
|
|
32
40
|
def worker? = kind == "worker"
|
|
41
|
+
|
|
42
|
+
# A worker that closed its Bridge and releases the lock once its
|
|
43
|
+
# last work is done.
|
|
44
|
+
def leaving? = worker? && !leaving_at.nil?
|
|
45
|
+
|
|
46
|
+
# Seconds of its leave_wait still to run at +now+ (0 when past).
|
|
47
|
+
def leave_left(now = Time.now)
|
|
48
|
+
started = Time.iso8601(leaving_at.to_s)
|
|
49
|
+
[leave_wait.to_f - (now - started), 0.0].max
|
|
50
|
+
rescue ArgumentError
|
|
51
|
+
leave_wait.to_f
|
|
52
|
+
end
|
|
33
53
|
end
|
|
34
54
|
|
|
35
55
|
# Take the lock, retrying for up to +wait+ seconds (another process's
|
|
@@ -71,12 +91,35 @@ module Samagotchi
|
|
|
71
91
|
|
|
72
92
|
sleep(RETRY_INTERVAL / 5)
|
|
73
93
|
end
|
|
74
|
-
Owner.new(pid: data["pid"], kind: data["kind"], started_at: data["started_at"]
|
|
94
|
+
Owner.new(pid: data["pid"], kind: data["kind"], started_at: data["started_at"],
|
|
95
|
+
leaving_at: data["leaving_at"], leave_wait: data["leave_wait"])
|
|
75
96
|
end
|
|
76
97
|
rescue Errno::ENOENT
|
|
77
98
|
nil
|
|
78
99
|
end
|
|
79
100
|
|
|
101
|
+
# Wait for a leaving worker (Owner#leaving?) to release the lock: up to
|
|
102
|
+
# its leave_wait still to run plus LEAVE_MARGIN, looking every
|
|
103
|
+
# RELEASE_POLL seconds. An owner that isn't leaving (a live worker, a
|
|
104
|
+
# stuck one, a chi REPL) or no owner returns at once. A Ctrl-C (Interrupt)
|
|
105
|
+
# stops the wait as it would any sleep.
|
|
106
|
+
# @param on_wait [#call, nil] called once with the Owner before waiting
|
|
107
|
+
# @return [Symbol] :not_leaving, :released, or :timeout (still held)
|
|
108
|
+
def self.await_release(session_dir, on_wait: nil, margin: LEAVE_MARGIN, clock: method(:monotonic_now),
|
|
109
|
+
sleeper: method(:sleep), now: -> { Time.now })
|
|
110
|
+
holder = owner(session_dir)
|
|
111
|
+
return :not_leaving unless holder&.leaving?
|
|
112
|
+
|
|
113
|
+
on_wait&.call(holder)
|
|
114
|
+
deadline = clock.call + holder.leave_left(now.call) + margin
|
|
115
|
+
loop do
|
|
116
|
+
return :released unless owner(session_dir)
|
|
117
|
+
return :timeout if clock.call >= deadline
|
|
118
|
+
|
|
119
|
+
sleeper.call(RELEASE_POLL)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
80
123
|
def self.path(session_dir)
|
|
81
124
|
File.join(session_dir, FILE)
|
|
82
125
|
end
|
|
@@ -99,11 +142,28 @@ module Samagotchi
|
|
|
99
142
|
def initialize(file, kind:)
|
|
100
143
|
@file = file
|
|
101
144
|
@kind = kind.to_s
|
|
145
|
+
@record = { "pid" => Process.pid, "kind" => @kind, "started_at" => Time.now.iso8601(3) }
|
|
146
|
+
write_record
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The holder is leaving: its Bridge is closed and it releases the lock
|
|
150
|
+
# within +wait+ seconds (its recap on leave's timeout), which
|
|
151
|
+
# .await_release reads. .owner retries the moment the file is empty.
|
|
152
|
+
# @param wait [Numeric]
|
|
153
|
+
def mark_leaving(wait:)
|
|
154
|
+
return if @file.closed?
|
|
155
|
+
|
|
156
|
+
@record = @record.merge("leaving_at" => Time.now.iso8601(3), "leave_wait" => wait.to_f)
|
|
157
|
+
write_record
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def write_record
|
|
102
161
|
@file.truncate(0)
|
|
103
162
|
@file.rewind
|
|
104
|
-
@file.write(JSON.generate(
|
|
163
|
+
@file.write(JSON.generate(@record))
|
|
105
164
|
@file.flush
|
|
106
165
|
end
|
|
166
|
+
private :write_record
|
|
107
167
|
|
|
108
168
|
# Release the lock (process exit releases it too).
|
|
109
169
|
def release
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "delegate"
|
|
4
4
|
require "json"
|
|
5
5
|
|
|
6
|
+
require_relative "chi_invocation"
|
|
6
7
|
require_relative "cli/exit"
|
|
7
8
|
require_relative "pending_question"
|
|
8
9
|
require_relative "session"
|
|
@@ -81,7 +82,9 @@ module Samagotchi
|
|
|
81
82
|
# @return [Hash] the --format json object
|
|
82
83
|
# @param strategy [Guardrails::ParentPolicy::Strategy, nil] what a parent
|
|
83
84
|
# may pick on the waited-for session (#strategy_for); nil: config.yml's
|
|
84
|
-
|
|
85
|
+
# @param via [ChiInvocation] how the printed commands are spelled (this
|
|
86
|
+
# machine's chi, or chi --remote NAME); each function here takes it
|
|
87
|
+
def json(result, session_id:, timeout: nil, extra: {}, strategy: nil, via: ChiInvocation::HERE)
|
|
85
88
|
report = { status: status(result), session_id: session_id }
|
|
86
89
|
report[:turn_id] = result.turn_id unless result.turn_id.to_s.empty?
|
|
87
90
|
case report[:status]
|
|
@@ -89,10 +92,10 @@ module Samagotchi
|
|
|
89
92
|
when "question"
|
|
90
93
|
question = question(result.question, strategy: strategy)
|
|
91
94
|
report[:question] = question
|
|
92
|
-
report[:answer_with] = answer_with(session_id, question)
|
|
95
|
+
report[:answer_with] = answer_with(session_id, question, via: via)
|
|
93
96
|
else
|
|
94
97
|
report.merge!(stop_facts(result))
|
|
95
|
-
report[:detail] = detail(result, session_id: session_id, timeout: timeout, strategy: strategy)
|
|
98
|
+
report[:detail] = detail(result, session_id: session_id, timeout: timeout, strategy: strategy, via: via)
|
|
96
99
|
end
|
|
97
100
|
report.merge(extra)
|
|
98
101
|
end
|
|
@@ -110,8 +113,8 @@ module Samagotchi
|
|
|
110
113
|
end
|
|
111
114
|
|
|
112
115
|
# @return [String] the JSON object as one line
|
|
113
|
-
def json_line(result, session_id:, timeout: nil, extra: {}, strategy: nil)
|
|
114
|
-
JSON.generate(json(result, session_id: session_id, timeout: timeout, extra: extra, strategy: strategy))
|
|
116
|
+
def json_line(result, session_id:, timeout: nil, extra: {}, strategy: nil, via: ChiInvocation::HERE)
|
|
117
|
+
JSON.generate(json(result, session_id: session_id, timeout: timeout, extra: extra, strategy: strategy, via: via))
|
|
115
118
|
end
|
|
116
119
|
|
|
117
120
|
# Print a wait's end the way the commands do: the JSON object on
|
|
@@ -120,17 +123,19 @@ module Samagotchi
|
|
|
120
123
|
# @param command [String] "chi send", "chi answer"
|
|
121
124
|
# @param extra [Hash] more keys for the JSON object (#json)
|
|
122
125
|
# @return [Integer] the exit status
|
|
123
|
-
def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil, extra: {}, strategy: nil
|
|
126
|
+
def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil, extra: {}, strategy: nil,
|
|
127
|
+
via: ChiInvocation::HERE)
|
|
124
128
|
if json
|
|
125
|
-
stdout.puts(json_line(result, session_id: session_id, timeout: timeout, extra: extra, strategy: strategy))
|
|
129
|
+
stdout.puts(json_line(result, session_id: session_id, timeout: timeout, extra: extra, strategy: strategy, via: via))
|
|
126
130
|
elsif result.status == :done
|
|
127
131
|
stdout.puts(result.text)
|
|
128
132
|
else
|
|
129
133
|
stdout.flush
|
|
130
134
|
text = if result.status == :waiting_for_answer
|
|
131
|
-
question_text(result.question, session_id: session_id, strategy: strategy)
|
|
135
|
+
question_text(result.question, session_id: session_id, strategy: strategy, via: via)
|
|
132
136
|
else
|
|
133
|
-
"#{detail(result, session_id: session_id, timeout: timeout, turn_id: result.turn_id, strategy: strategy
|
|
137
|
+
"#{detail(result, session_id: session_id, timeout: timeout, turn_id: result.turn_id, strategy: strategy,
|
|
138
|
+
via: via)}\n"
|
|
134
139
|
end
|
|
135
140
|
stderr.print("#{command}: #{text}")
|
|
136
141
|
end
|
|
@@ -177,16 +182,22 @@ module Samagotchi
|
|
|
177
182
|
# lists an Allow once a parent may give). A step-limit question's
|
|
178
183
|
# continues the turn, or stops it where a parent may not continue (its
|
|
179
184
|
# other form is in #question_text).
|
|
180
|
-
def answer_with(session_id, question)
|
|
181
|
-
return
|
|
182
|
-
return stop_command(session_id, question) if continue?(question) && !may_continue?(question)
|
|
183
|
-
return
|
|
185
|
+
def answer_with(session_id, question, via: ChiInvocation::HERE)
|
|
186
|
+
return answer_command(session_id, question, "Deny --text WHY", via: via) if approval?(question)
|
|
187
|
+
return stop_command(session_id, question, via: via) if continue?(question) && !may_continue?(question)
|
|
188
|
+
return answer_command(session_id, question, "Continue", via: via) if continue?(question)
|
|
184
189
|
|
|
185
190
|
# The one option a parent may pick (cost-cap's Stop under hard).
|
|
186
191
|
only = Array(question[:allowed_options])
|
|
187
|
-
return
|
|
192
|
+
return answer_command(session_id, question, option_arg(only.first), via: via) if only.size == 1
|
|
188
193
|
|
|
189
|
-
|
|
194
|
+
answer_command(session_id, question, "N", via: via)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# chi answer ID --question QID --option +option+ (its words after
|
|
198
|
+
# --option: a label, maybe --text).
|
|
199
|
+
def answer_command(session_id, question, option, via: ChiInvocation::HERE)
|
|
200
|
+
via.command("answer #{session_id} --question #{question[:id]} --option #{option}")
|
|
190
201
|
end
|
|
191
202
|
|
|
192
203
|
# What a parent does with an approval it may not allow.
|
|
@@ -239,17 +250,18 @@ module Samagotchi
|
|
|
239
250
|
question.fetch(:allowed_options, question[:options]).include?(Guardrails::ParentPolicy::CONTINUE)
|
|
240
251
|
end
|
|
241
252
|
|
|
242
|
-
def stop_command(session_id, question)
|
|
243
|
-
|
|
253
|
+
def stop_command(session_id, question, via: ChiInvocation::HERE)
|
|
254
|
+
answer_command(session_id, question, "Stop --text WHY", via: via)
|
|
244
255
|
end
|
|
245
256
|
|
|
246
257
|
def option_arg(label) = label.match?(/\A[\w.-]+\z/) ? label : label.inspect
|
|
247
258
|
|
|
248
259
|
# The other way: the user answers it in chi (the web's bell and badge
|
|
249
260
|
# say it waits), and a wait with no message waits past it.
|
|
250
|
-
def leave_open(session_id)
|
|
251
|
-
|
|
252
|
-
|
|
261
|
+
def leave_open(session_id, via: ChiInvocation::HERE)
|
|
262
|
+
web = via.remote? ? via.web : "chi web"
|
|
263
|
+
"or leave it open: tell your user it waits in #{web} (session #{session_id[0, 8]}); " \
|
|
264
|
+
"#{via.command("send --wait --format json #{session_id}")} waits until they answer"
|
|
253
265
|
end
|
|
254
266
|
|
|
255
267
|
# @param question [PendingQuestion, Hash, nil] a pending question, or a
|
|
@@ -267,13 +279,13 @@ module Samagotchi
|
|
|
267
279
|
end
|
|
268
280
|
|
|
269
281
|
# What a message does to a step-limit question.
|
|
270
|
-
def message_drops(session_id)
|
|
271
|
-
"a message instead (
|
|
282
|
+
def message_drops(session_id, via: ChiInvocation::HERE)
|
|
283
|
+
"a message instead (#{via.command("send #{session_id} -m …")}) drops it and starts a new turn"
|
|
272
284
|
end
|
|
273
285
|
|
|
274
286
|
# What a wait that ended without an answer says, one line.
|
|
275
|
-
def detail(result, session_id:, timeout: nil, turn_id: nil, strategy: nil)
|
|
276
|
-
|
|
287
|
+
def detail(result, session_id:, timeout: nil, turn_id: nil, strategy: nil, via: ChiInvocation::HERE)
|
|
288
|
+
shows = via.shows(session_id)
|
|
277
289
|
case result.status
|
|
278
290
|
when :waiting_for_answer
|
|
279
291
|
q = question(result.question, strategy: strategy)
|
|
@@ -281,20 +293,20 @@ module Samagotchi
|
|
|
281
293
|
"waiting for an approval: #{first_line(q[:text])}; #{approval_advice(q, strategy: strategy)}"
|
|
282
294
|
elsif continue?(q) && !may_continue?(q)
|
|
283
295
|
"waiting at the step limit: #{first_line(q[:text])}; a parent may only stop it here " \
|
|
284
|
-
"(#{policy(strategy).label}): #{stop_command(session_id, q)}, or tell your user it waits"
|
|
296
|
+
"(#{policy(strategy).label}): #{stop_command(session_id, q, via: via)}, or tell your user it waits"
|
|
285
297
|
elsif continue?(q)
|
|
286
298
|
"waiting at the step limit: #{first_line(q[:text])}; " \
|
|
287
|
-
"answer Continue or Stop:
|
|
299
|
+
"answer Continue or Stop: #{answer_command(session_id, q, "Continue", via: via)}"
|
|
288
300
|
else
|
|
289
|
-
"waiting for an answer: #{first_line(q[:text])}; open it: #{
|
|
301
|
+
"waiting for an answer: #{first_line(q[:text])}; open it: #{via.open_it(session_id)}"
|
|
290
302
|
end
|
|
291
|
-
when :no_reply then "#{no_reply_line(result, session_id, strategy: strategy)}; #{
|
|
292
|
-
when :error then "the worker failed: #{result.text}; #{
|
|
293
|
-
when :worker_gone then "the worker is gone; #{
|
|
303
|
+
when :no_reply then "#{no_reply_line(result, session_id, strategy: strategy, via: via)}; #{shows} shows it"
|
|
304
|
+
when :error then "the worker failed: #{result.text}; #{shows} shows what happened"
|
|
305
|
+
when :worker_gone then "the worker is gone; #{shows} shows what happened"
|
|
294
306
|
when :stopped then "the session was stopped (chi sessions stop)"
|
|
295
307
|
else
|
|
296
|
-
wait_again = "
|
|
297
|
-
hint = wait_again ||
|
|
308
|
+
wait_again = via.command("send --wait --turn #{turn_id} #{session_id}") unless turn_id.to_s.empty?
|
|
309
|
+
hint = wait_again || shows
|
|
298
310
|
timeout ? "still running after #{format("%g", timeout)} s: #{hint}" : "still running: #{hint}"
|
|
299
311
|
end
|
|
300
312
|
end
|
|
@@ -302,7 +314,7 @@ module Samagotchi
|
|
|
302
314
|
# The whole question for stderr: header, text, numbered options, what
|
|
303
315
|
# else it takes, and the commands that answer it.
|
|
304
316
|
# @return [String] lines, newline-terminated
|
|
305
|
-
def question_text(pending, session_id:, strategy: nil)
|
|
317
|
+
def question_text(pending, session_id:, strategy: nil, via: ChiInvocation::HERE)
|
|
306
318
|
q = question(pending, strategy: strategy)
|
|
307
319
|
lines = ["waiting for an answer (#{q[:kind]}): #{q[:header] || first_line(q[:text])}"]
|
|
308
320
|
text_lines = q[:text].strip.lines.map(&:rstrip)
|
|
@@ -315,31 +327,31 @@ module Samagotchi
|
|
|
315
327
|
lines << " allowing it is up to your user: #{DENY_AND_TELL}"
|
|
316
328
|
else
|
|
317
329
|
lines << " #{approval_advice(q, strategy: strategy)}"
|
|
318
|
-
allows.each { |label| lines << " allow:
|
|
330
|
+
allows.each { |label| lines << " allow: #{answer_command(session_id, q, option_arg(label), via: via)}" }
|
|
319
331
|
end
|
|
320
|
-
lines << " deny: #{answer_with(session_id, q)}"
|
|
321
|
-
lines << " #{leave_open(session_id)}"
|
|
332
|
+
lines << " deny: #{answer_with(session_id, q, via: via)}"
|
|
333
|
+
lines << " #{leave_open(session_id, via: via)}"
|
|
322
334
|
elsif continue?(q)
|
|
323
335
|
lines << if may_continue?(q)
|
|
324
|
-
" continue: #{answer_with(session_id, q)}"
|
|
336
|
+
" continue: #{answer_with(session_id, q, via: via)}"
|
|
325
337
|
else
|
|
326
338
|
" continuing it is up to your user (#{policy(strategy).label}): stop it, or tell your user it waits"
|
|
327
339
|
end
|
|
328
|
-
lines << " stop: #{stop_command(session_id, q)} (the text is optional; the model reads it)"
|
|
329
|
-
lines << " #{message_drops(session_id)}"
|
|
340
|
+
lines << " stop: #{stop_command(session_id, q, via: via)} (the text is optional; the model reads it)"
|
|
341
|
+
lines << " #{message_drops(session_id, via: via)}"
|
|
330
342
|
else
|
|
331
343
|
lines << " more than one allowed: repeat --option" if q[:multi_select]
|
|
332
344
|
lines << " free text allowed: --text" if q[:allow_freeform]
|
|
333
345
|
if q[:allowed_options]
|
|
334
346
|
lines << " a parent may pick #{q[:allowed_options].map { |o| option_arg(o) }.join(", ")}; the others are up to your user"
|
|
335
347
|
end
|
|
336
|
-
lines << " answer: #{answer_with(session_id, q)}"
|
|
337
|
-
lines << " or open it:
|
|
348
|
+
lines << " answer: #{answer_with(session_id, q, via: via)}"
|
|
349
|
+
lines << " or open it: #{via.open_it(session_id)}"
|
|
338
350
|
end
|
|
339
351
|
lines.map { |line| "#{line}\n" }.join
|
|
340
352
|
end
|
|
341
353
|
|
|
342
|
-
def no_reply_line(result, session_id = nil, strategy: nil)
|
|
354
|
+
def no_reply_line(result, session_id = nil, strategy: nil, via: ChiInvocation::HERE)
|
|
343
355
|
return command_line(result) if result.origin == TurnCheckpoint::COMMAND_ORIGIN
|
|
344
356
|
|
|
345
357
|
case result.outcome
|
|
@@ -354,7 +366,7 @@ module Samagotchi
|
|
|
354
366
|
ran_out = "the turn ran out of iterations#{" (#{result.limit} steps)" if result.limit} before it answered"
|
|
355
367
|
strategy = policy(strategy)
|
|
356
368
|
if strategy.allow?(Guardrails::ParentPolicy::SPEND)
|
|
357
|
-
"#{ran_out};
|
|
369
|
+
"#{ran_out}; #{via.command("send #{session_id} -m '/continue yes'")} continues it, a message drops it"
|
|
358
370
|
else
|
|
359
371
|
# No /continue yes hint: a parent may not continue it here.
|
|
360
372
|
"#{ran_out}; continuing it is up to your user (#{strategy.label}), a message drops it"
|