samagotchi 0.63.0 → 0.64.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 +46 -1
- data/docs/architecture.md +12 -2
- data/docs/cli.md +16 -8
- data/docs/configuration.md +15 -8
- data/docs/internals/engine.md +10 -3
- data/docs/plugins.md +7 -0
- data/docs/sessions.md +11 -8
- data/docs/sub-agent.md +21 -4
- data/docs/testing.md +6 -0
- data/lib/samagotchi/answer_command.rb +5 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
- data/lib/samagotchi/bridge.rb +18 -4
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/child_reports.rb +7 -2
- data/lib/samagotchi/cli/parent_wait.rb +9 -2
- data/lib/samagotchi/composer_route.rb +90 -0
- data/lib/samagotchi/config.rb +6 -3
- data/lib/samagotchi/continue_offer.rb +17 -3
- data/lib/samagotchi/delivery.rb +13 -8
- data/lib/samagotchi/engine.rb +2 -0
- data/lib/samagotchi/host_config.rb +6 -6
- data/lib/samagotchi/host_model.rb +2 -2
- data/lib/samagotchi/interrupted_turn.rb +7 -3
- data/lib/samagotchi/last_turn.rb +5 -2
- data/lib/samagotchi/pending_input_queue.rb +10 -3
- data/lib/samagotchi/plugin/sessions.rb +27 -5
- data/lib/samagotchi/reply_wait.rb +171 -6
- data/lib/samagotchi/send_command.rb +63 -19
- data/lib/samagotchi/session_commands.rb +1 -1
- data/lib/samagotchi/session_inbox.rb +63 -7
- data/lib/samagotchi/session_manager.rb +49 -20
- data/lib/samagotchi/terminal_ui/attached_loop.rb +47 -26
- data/lib/samagotchi/terminal_ui/formatting.rb +3 -19
- data/lib/samagotchi/terminal_ui.rb +72 -57
- data/lib/samagotchi/tool_activity.rb +5 -5
- data/lib/samagotchi/tools/delegate.rb +28 -1
- data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
- data/lib/samagotchi/tools/delegate_wait.rb +17 -5
- data/lib/samagotchi/tools/execute.rb +11 -4
- data/lib/samagotchi/turn_checkpoint.rb +9 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +6 -0
- data/lib/samagotchi/web/public/commands.js +8 -8
- data/lib/samagotchi/web/public/composer.js +3 -3
- data/lib/samagotchi/web/public/timing.js +2 -1
- data/lib/samagotchi/web/public/turn_model.js +13 -3
- data/lib/samagotchi/worker.rb +7 -5
- metadata +2 -1
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "session"
|
|
4
4
|
require_relative "turn_note"
|
|
5
5
|
require_relative "session_inbox"
|
|
6
|
+
require_relative "worker_sidecar"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
# Loaded on first use: session_manager requires terminal_ui, which
|
|
@@ -14,9 +15,15 @@ module Samagotchi
|
|
|
14
15
|
# output/<timestamp>.txt file (one per turn that ended with visible text,
|
|
15
16
|
# written just before its idle save), so "next" means a filename past the
|
|
16
17
|
# cursor the caller holds: the newest one it already has, or nil for any.
|
|
18
|
+
# A wait for one input's turn (turn:, a queued message) looks for that
|
|
19
|
+
# turn's own file instead (output/<timestamp>-<enqueued_id>.txt).
|
|
17
20
|
# The delegate tools and `chi send --wait` wrap it in their own words.
|
|
18
21
|
module ReplyWait
|
|
19
22
|
POLL_INTERVAL = 0.5
|
|
23
|
+
# A worker's sidecar feature (Bridge::FEATURES): its running turn's
|
|
24
|
+
# checkpoint mark (InterruptedTurn#enqueued_id), a continue turn and a
|
|
25
|
+
# Stop's last_turn carry the turn's enqueued_id (#own_turn).
|
|
26
|
+
TURN_IDS_FEATURE = "turn_ids"
|
|
20
27
|
|
|
21
28
|
# @!attribute status [Symbol] :done, :waiting_for_answer, :no_reply,
|
|
22
29
|
# :error, :stopped, :canceled, :timeout or :worker_gone
|
|
@@ -38,7 +45,9 @@ module Samagotchi
|
|
|
38
45
|
# @!attribute retryable [Boolean, nil] whether that error may pass on a
|
|
39
46
|
# retry
|
|
40
47
|
# @!attribute cancel_reason [String, nil] a "canceled" turn's reason
|
|
41
|
-
# (user, hook, ctrl_c, manual
|
|
48
|
+
# (user, hook, ctrl_c, manual, session_stopped: the session was
|
|
49
|
+
# stopped mid-turn), passed through as the worker saved it; any
|
|
50
|
+
# reason is a "canceled" outcome
|
|
42
51
|
# @!attribute stopped_by [String, nil] the hook that canceled it
|
|
43
52
|
# (loop-guard)
|
|
44
53
|
# @!attribute session [Session, nil] the session as the look that
|
|
@@ -70,18 +79,64 @@ module Samagotchi
|
|
|
70
79
|
# @param interject [#call, nil] called once a poll, before the sleep
|
|
71
80
|
# (the delegate wait relays other children's approvals there); the
|
|
72
81
|
# time it takes doesn't count against +timeout+
|
|
82
|
+
# @param turn [String, nil] the enqueued_id of the input whose turn is
|
|
83
|
+
# waited for (a queued message, which runs after the turn running
|
|
84
|
+
# now). With it only that turn ends the wait: its reply file
|
|
85
|
+
# (SessionInbox.reply_for), or an idle session whose last_turn has
|
|
86
|
+
# that enqueued_id (failed, canceled, empty: #no_reply). Other turns'
|
|
87
|
+
# replies and ends are not its, and a pending question counts only
|
|
88
|
+
# when it is that turn's (#own_question?). A last_turn that ended
|
|
89
|
+
# before the baseline's (the turn whose continue offer was just
|
|
90
|
+
# answered) is not the end. An input a running turn merged as a
|
|
91
|
+
# steer (taken, never run as its own) is waited for as without turn:
|
|
92
|
+
# (that turn's reply or question). A turn seen running, then past
|
|
93
|
+
# (another turn runs or ended, last_turn not it): :no_reply, outcome
|
|
94
|
+
# unknown.
|
|
95
|
+
# @param queued [Boolean] the turn: input was sent as a queue (or with
|
|
96
|
+
# images), which a running turn never merges: no fallback, even when
|
|
97
|
+
# its file was taken before the first look
|
|
73
98
|
# @return [Result]
|
|
74
99
|
# @raise [ArgumentError] no such session
|
|
75
100
|
def call(id, state_dir:, cursor:, timeout: nil, poll_interval: POLL_INTERVAL, cancelled: -> { false },
|
|
76
|
-
baseline: nil, owner_grace: nil, interject: nil)
|
|
101
|
+
baseline: nil, owner_grace: nil, interject: nil, turn: nil, queued: false)
|
|
77
102
|
deadline = timeout && (monotonic + timeout.to_f)
|
|
78
103
|
seen_running = false
|
|
79
104
|
gone_since = nil
|
|
105
|
+
turn = turn.to_s.empty? ? nil : turn.to_s
|
|
106
|
+
session_dir = Session.session_dir(id, state_dir: state_dir) if turn
|
|
107
|
+
# Whether the turn's input file has been taken (latched): looked at
|
|
108
|
+
# before each load, so a running session seen after it is that turn
|
|
109
|
+
# or a later one, never the turn that ran when the message was sent.
|
|
110
|
+
claimed = false
|
|
111
|
+
# Its input runs a turn of its own (a queue, images: +queued+, or seen
|
|
112
|
+
# waiting as one): never merged into another turn.
|
|
113
|
+
own_turn_input = queued
|
|
114
|
+
# The turn was seen running (its mark's id), and last_turn's ended_at
|
|
115
|
+
# then: a later end that isn't its is a turn after it.
|
|
116
|
+
seen_own = false
|
|
117
|
+
own_base = nil
|
|
80
118
|
|
|
81
119
|
loop do
|
|
120
|
+
if turn && !claimed
|
|
121
|
+
waiting = SessionInbox.waiting_input(session_dir, turn)
|
|
122
|
+
own_turn_input ||= waiting == :turn_end
|
|
123
|
+
claimed = waiting.nil?
|
|
124
|
+
end
|
|
82
125
|
session = Session.load(id, state_dir: state_dir)
|
|
126
|
+
if turn && !seen_own && session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == turn
|
|
127
|
+
seen_own = true
|
|
128
|
+
own_base = session.last_turn&.ended_at
|
|
129
|
+
end
|
|
130
|
+
# Taken but never run as a turn of its own: a running turn's drain
|
|
131
|
+
# merged it as a steer, so that turn's reply (or question) is its
|
|
132
|
+
# answer. Waited for as the next reply past the cursor, as without
|
|
133
|
+
# turn:, never as a turn that will not come.
|
|
134
|
+
if turn && claimed && !seen_own && !own_turn_input && merged_elsewhere?(session, turn, baseline, session_dir)
|
|
135
|
+
turn = nil
|
|
136
|
+
seen_running = session.status == Session::STATUS_RUNNING
|
|
137
|
+
end
|
|
83
138
|
|
|
84
|
-
if (reply = reply_past(id, state_dir: state_dir, cursor: cursor))
|
|
139
|
+
if (reply = turn ? reply_for_turn(id, state_dir: state_dir, turn: turn) : reply_past(id, state_dir: state_dir, cursor: cursor))
|
|
85
140
|
reply.session = settled(session, id, state_dir: state_dir)
|
|
86
141
|
return reply
|
|
87
142
|
end
|
|
@@ -98,13 +153,31 @@ module Samagotchi
|
|
|
98
153
|
# REPL's is answered there.
|
|
99
154
|
pending = session.pending_question
|
|
100
155
|
if pending && !(baseline && pending[:id] == baseline[:question_id]) &&
|
|
156
|
+
(turn.nil? || own_question?(session, turn, claimed)) &&
|
|
101
157
|
session.waiting_question(live: SessionManager.worker_live?(id, state_dir: state_dir))
|
|
102
158
|
return Result.new(status: :waiting_for_answer, question: pending, session: session)
|
|
103
159
|
end
|
|
104
160
|
|
|
105
161
|
return Result.new(status: :canceled) if cancelled.call
|
|
106
162
|
|
|
107
|
-
if
|
|
163
|
+
if turn
|
|
164
|
+
# Only the turn's own end: the session runs another turn when the
|
|
165
|
+
# wait starts, and that one's end is not the answer. A last_turn
|
|
166
|
+
# with its id is its end, even with a next queued turn running
|
|
167
|
+
# by now (the look between the two missed the idle).
|
|
168
|
+
# Only an end after the baseline: a continue offer answered from
|
|
169
|
+
# idle leaves the exhausted turn's last_turn, with the same id,
|
|
170
|
+
# until the continue turn (or a Stop) records its own.
|
|
171
|
+
if session.last_turn&.enqueued_id == turn && ended_since_baseline?(session, baseline)
|
|
172
|
+
return reply_for_turn(id, state_dir: state_dir, turn: turn)&.tap { |r| r.session = session } ||
|
|
173
|
+
no_reply(session, baseline, fresh: true)
|
|
174
|
+
end
|
|
175
|
+
# Seen running, and now another turn runs or a later turn ended
|
|
176
|
+
# while last_turn (looked at first) isn't its: lost (its worker
|
|
177
|
+
# died), or it ended with no reply and a next turn ran and ended
|
|
178
|
+
# between two looks. Waiting on would sit through those turns.
|
|
179
|
+
return Result.new(status: :no_reply, session: session) if seen_own && past_own?(session, turn, own_base)
|
|
180
|
+
elsif session.status == Session::STATUS_RUNNING
|
|
108
181
|
seen_running = true
|
|
109
182
|
elsif (seen_running && !baseline&.key?(:last_turn)) || turn_ended_since?(session, baseline)
|
|
110
183
|
# It ran and is idle again with no new reply: canceled, failed or
|
|
@@ -164,11 +237,103 @@ module Samagotchi
|
|
|
164
237
|
!!(baseline[:messages] && session.messages.size > baseline[:messages])
|
|
165
238
|
end
|
|
166
239
|
|
|
240
|
+
# The turn a caller that sent nothing means (chi answer, chi send --wait
|
|
241
|
+
# with no message): the one running now (its checkpoint mark's
|
|
242
|
+
# enqueued_id), or, idle, the turn whose continue offer stands (its
|
|
243
|
+
# last_turn's), when the worker's sidecar says it ties a continue turn
|
|
244
|
+
# to that id (TURN_IDS_FEATURE). nil otherwise (no id known, a worker
|
|
245
|
+
# from before): the caller waits for the next reply as before.
|
|
246
|
+
# @param session [Session] the session as loaded now
|
|
247
|
+
# @return [String, nil] an enqueued_id for call's turn:
|
|
248
|
+
def own_turn(session, session_dir:)
|
|
249
|
+
if session.status == Session::STATUS_RUNNING
|
|
250
|
+
# Only a worker that writes it has one: no feature check.
|
|
251
|
+
return session.interrupted_turn&.enqueued_id
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
pending = session.pending_question
|
|
255
|
+
return nil unless pending && pending[:kind].to_s == "continue"
|
|
256
|
+
return nil unless turn_ids?(session_dir)
|
|
257
|
+
|
|
258
|
+
session.last_turn&.enqueued_id
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Whether the session's worker sidecar names TURN_IDS_FEATURE.
|
|
262
|
+
def turn_ids?(session_dir)
|
|
263
|
+
WorkerSidecar.read(session_dir)&.features&.include?(TURN_IDS_FEATURE) || false
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# A pending question is a turn: wait's (its enqueued_id) only when that
|
|
267
|
+
# turn asked it. While a turn runs its checkpoint mark says which turn
|
|
268
|
+
# that is (a continue turn carries its offer turn's id); a mark with no
|
|
269
|
+
# id (a worker from before) falls back to the claimed rule (its input
|
|
270
|
+
# claimed, the session running, last_turn not already it). Idle: only
|
|
271
|
+
# its own continue offer (last_turn has the id). Any other is another
|
|
272
|
+
# turn's, whose questions are the user's: the wait goes on.
|
|
273
|
+
def own_question?(session, turn, claimed)
|
|
274
|
+
ended = session.last_turn&.enqueued_id == turn
|
|
275
|
+
return ended unless session.status == Session::STATUS_RUNNING
|
|
276
|
+
|
|
277
|
+
running_id = session.interrupted_turn&.enqueued_id
|
|
278
|
+
return running_id == turn if running_id
|
|
279
|
+
|
|
280
|
+
# Ended, running again: a next turn.
|
|
281
|
+
!ended && claimed
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# The turn's end, after the baseline was taken (with no baseline, or
|
|
285
|
+
# one without last_turn, any).
|
|
286
|
+
def ended_since_baseline?(session, baseline)
|
|
287
|
+
return true unless baseline&.key?(:last_turn)
|
|
288
|
+
|
|
289
|
+
session.last_turn&.ended_at != baseline[:last_turn]
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# A turn: wait's taken input ran in another turn (a steer its drain
|
|
293
|
+
# merged): that turn runs (its mark has another id, or none from a
|
|
294
|
+
# worker that writes ids), or, idle, a turn not its ended since the
|
|
295
|
+
# baseline. last_turn or a reply file being its own is never that.
|
|
296
|
+
def merged_elsewhere?(session, turn, baseline, session_dir)
|
|
297
|
+
return false if session.last_turn&.enqueued_id == turn || SessionInbox.reply_for(session_dir, turn)
|
|
298
|
+
|
|
299
|
+
if session.status == Session::STATUS_RUNNING
|
|
300
|
+
running_id = session.interrupted_turn&.enqueued_id
|
|
301
|
+
return running_id != turn if running_id
|
|
302
|
+
|
|
303
|
+
return turn_ids?(session_dir)
|
|
304
|
+
end
|
|
305
|
+
baseline&.key?(:last_turn) ? turn_ended_since?(session, baseline) : false
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# A turn seen running (+own_base+: last_turn's ended_at then) is past:
|
|
309
|
+
# last_turn isn't its, and another turn runs (a mark with another id)
|
|
310
|
+
# or a turn ended since.
|
|
311
|
+
def past_own?(session, turn, own_base)
|
|
312
|
+
return false if session.last_turn&.enqueued_id == turn
|
|
313
|
+
|
|
314
|
+
running_id = session.status == Session::STATUS_RUNNING ? session.interrupted_turn&.enqueued_id : nil
|
|
315
|
+
return true if running_id && running_id != turn
|
|
316
|
+
|
|
317
|
+
ended = session.last_turn&.ended_at
|
|
318
|
+
!ended.nil? && ended != own_base
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# @return [Result, nil] :done with the reply of the turn that ran the
|
|
322
|
+
# input +turn+ (its enqueued_id)
|
|
323
|
+
def reply_for_turn(id, state_dir:, turn:)
|
|
324
|
+
file = SessionInbox.reply_for(Session.session_dir(id, state_dir: state_dir), turn)
|
|
325
|
+
return nil unless file
|
|
326
|
+
|
|
327
|
+
Result.new(status: :done, text: File.read(File.join(reply_dir(id, state_dir: state_dir), file)), file: file)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# @param fresh [Boolean, nil] the last_turn is known to be the turn
|
|
331
|
+
# waited for (a turn: wait); nil decides it from the baseline
|
|
167
332
|
# @return [Result] :no_reply with the outcome the session's last_turn
|
|
168
333
|
# records, when it is the turn waited for
|
|
169
|
-
def no_reply(session, baseline)
|
|
334
|
+
def no_reply(session, baseline, fresh: nil)
|
|
170
335
|
last = session.last_turn || LastTurn.new
|
|
171
|
-
fresh = baseline.nil? || !baseline.key?(:last_turn) || (last.ended_at && last.ended_at != baseline[:last_turn])
|
|
336
|
+
fresh = baseline.nil? || !baseline.key?(:last_turn) || (last.ended_at && last.ended_at != baseline[:last_turn]) if fresh.nil?
|
|
172
337
|
outcome = fresh ? last.outcome : nil
|
|
173
338
|
return Result.new(status: :no_reply, outcome: "exhausted", limit: last.limit, session: session) if fresh && last.exhausted
|
|
174
339
|
|
|
@@ -38,10 +38,11 @@ module Samagotchi
|
|
|
38
38
|
CUT_OUTCOMES = %w[now waits off].freeze
|
|
39
39
|
|
|
40
40
|
USAGE = <<~TEXT
|
|
41
|
-
Usage: chi send [-m TEXT] [--image PATH]... [--cut] (ID|PREFIX)...
|
|
41
|
+
Usage: chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...
|
|
42
42
|
chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [--thinking LEVEL] [-m TEXT] [--image PATH]...
|
|
43
43
|
chi send --new --continues (ID|PREFIX|last:ID) [-m TEXT] [--image PATH]...
|
|
44
44
|
chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
|
|
45
|
+
chi send --queue --wait [--timeout S] [--format json] [-m TEXT] ID
|
|
45
46
|
chi send --wait [--timeout S] [--format json] ID
|
|
46
47
|
Sends a message to each session, as if typed in it: a turn starts,
|
|
47
48
|
or a running one takes it at its next step. A stopped session's
|
|
@@ -92,6 +93,12 @@ module Samagotchi
|
|
|
92
93
|
next step and cuts nothing. To an idle session it
|
|
93
94
|
sends normally. Refused with --wait into a running
|
|
94
95
|
turn (the steer's turn is the running one)
|
|
96
|
+
--queue run the message as a turn of its own after the running
|
|
97
|
+
turn ends, instead of merging it at its next step. To
|
|
98
|
+
an idle session it runs at once. With --wait it
|
|
99
|
+
waits for that turn's own answer, so a running turn
|
|
100
|
+
is fine. Not with --cut. Refused for a worker older
|
|
101
|
+
than queueing (it would steer; restart it)
|
|
95
102
|
--timeout S (--wait) give up after S seconds, exit 4 (the turn
|
|
96
103
|
goes on); default no limit
|
|
97
104
|
--format json
|
|
@@ -99,8 +106,9 @@ module Samagotchi
|
|
|
99
106
|
status answered (text), question (question,
|
|
100
107
|
answer_with), running, or failed, canceled,
|
|
101
108
|
limit, no_answer, error, worker_gone, stopped,
|
|
102
|
-
command (detail); with --cut
|
|
103
|
-
the worker applied
|
|
109
|
+
command (detail); with --cut or --queue also
|
|
110
|
+
delivery (what the worker applied: next_step, cut,
|
|
111
|
+
queue), with --cut also cut (now, waits, off)
|
|
104
112
|
Exit with --wait: 0 answered, 1 failed or gone, 2 usage, 3 a
|
|
105
113
|
question waits, 4 still running (--timeout), 130 Ctrl-C.
|
|
106
114
|
Only sessions on this machine. Answers show in the attached TUI
|
|
@@ -121,6 +129,7 @@ module Samagotchi
|
|
|
121
129
|
f.value "--image", key: :images, repeat: true
|
|
122
130
|
f.switch "--wait"
|
|
123
131
|
f.switch "--cut"
|
|
132
|
+
f.switch "--queue"
|
|
124
133
|
f.value "--timeout"
|
|
125
134
|
f.value "--format"
|
|
126
135
|
# Starting a turn in every live session at once is too easy to do
|
|
@@ -181,6 +190,7 @@ module Samagotchi
|
|
|
181
190
|
# With --wait stdout is the answer alone.
|
|
182
191
|
@info = options[:wait] ? @stderr : @stdout
|
|
183
192
|
@cut = options[:cut] == true
|
|
193
|
+
@queue = options[:queue] == true
|
|
184
194
|
prompt = compose(utf8(read_stdin), utf8(options[:message]))
|
|
185
195
|
# Before the wait-only case, which would drop the images.
|
|
186
196
|
return usage_error("--image needs a message: pass -m TEXT or pipe it in") if prompt.nil? && !options[:images].empty?
|
|
@@ -236,6 +246,8 @@ module Samagotchi
|
|
|
236
246
|
return parsed if parsed.is_a?(Integer)
|
|
237
247
|
|
|
238
248
|
options = parsed.options.merge(ids: parsed.args)
|
|
249
|
+
return usage_error("--cut and --queue: choose one") if options[:cut] && options[:queue]
|
|
250
|
+
|
|
239
251
|
if options[:format]
|
|
240
252
|
return usage_error("--format needs --wait") unless options[:wait]
|
|
241
253
|
return usage_error("--format takes text or json") unless FORMATS.include?(options[:format])
|
|
@@ -385,12 +397,15 @@ module Samagotchi
|
|
|
385
397
|
# are taken before the message goes in, so neither an older reply nor a
|
|
386
398
|
# turn that ends before the first look is mistaken for the answer. A
|
|
387
399
|
# running turn is refused: a message that misses it runs next, and the
|
|
388
|
-
# running one's reply would come back as the answer.
|
|
400
|
+
# running one's reply would come back as the answer. Not with --queue:
|
|
401
|
+
# the message's own turn is waited for (ReplyWait's turn:, its
|
|
402
|
+
# enqueued_id), whatever runs before it.
|
|
389
403
|
# @return [Integer] the exit status
|
|
390
404
|
def run_wait(prompt, options)
|
|
391
405
|
id = resolve(options[:ids].first) or return 1
|
|
392
406
|
session = Session.load(id, state_dir: @state_dir)
|
|
393
|
-
if session.status == Session::STATUS_RUNNING && (owner = SessionManager.session_owner(id, state_dir: @state_dir))
|
|
407
|
+
if session.status == Session::STATUS_RUNNING && (owner = SessionManager.session_owner(id, state_dir: @state_dir)) &&
|
|
408
|
+
(!@queue || session.waiting_question(live: owner.worker?))
|
|
394
409
|
# The turn waits on a question: the message would only queue
|
|
395
410
|
# behind it, and the answer goes in with chi answer.
|
|
396
411
|
if (waiting = session.waiting_question(live: owner.worker?))
|
|
@@ -412,7 +427,11 @@ module Samagotchi
|
|
|
412
427
|
return 1 unless deliver(id, prompt)
|
|
413
428
|
return report_command(id) if @sent_command
|
|
414
429
|
|
|
415
|
-
|
|
430
|
+
# The ack's id names the queued message's turn, also when the turn
|
|
431
|
+
# running at the check ended before it went in (an idle worker's ack
|
|
432
|
+
# says next_step, and the file still runs as that turn).
|
|
433
|
+
turn = @queue ? @sent_enqueued_id : nil
|
|
434
|
+
wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout], turn: turn, queued: !turn.nil?)
|
|
416
435
|
end
|
|
417
436
|
|
|
418
437
|
# --wait with no message: the session's next reply, sending nothing,
|
|
@@ -421,15 +440,18 @@ module Samagotchi
|
|
|
421
440
|
# was already reported. No message count: a note landing between turns
|
|
422
441
|
# grows the messages without a turn. An idle session with no worker
|
|
423
442
|
# waits for whatever wakes one (the web, chi send) rather than calling
|
|
424
|
-
# it gone.
|
|
443
|
+
# it gone. The turn running now, or the one whose continue offer
|
|
444
|
+
# stands, is the one waited for (ReplyWait.own_turn): its reply, not a
|
|
445
|
+
# turn's queued behind it; with none known, the next reply.
|
|
425
446
|
# @return [Integer] the exit status
|
|
426
447
|
def run_wait_only(options)
|
|
427
448
|
id = resolve(options[:ids].first) or return 1
|
|
428
449
|
session = Session.load(id, state_dir: @state_dir)
|
|
429
450
|
live = session.status == Session::STATUS_RUNNING || SessionManager.session_owner(id, state_dir: @state_dir)
|
|
451
|
+
turn = ReplyWait.own_turn(session, session_dir: Session.session_dir(id, state_dir: @state_dir))
|
|
430
452
|
wait_for_reply(id, cursor: ReplyWait.newest_reply(id, state_dir: @state_dir),
|
|
431
453
|
baseline: baseline_of(session).merge(messages: nil),
|
|
432
|
-
timeout: options[:timeout], owner_grace: live ? self.class::WORKER_GONE_AFTER : nil)
|
|
454
|
+
timeout: options[:timeout], owner_grace: live ? self.class::WORKER_GONE_AFTER : nil, turn: turn)
|
|
433
455
|
end
|
|
434
456
|
|
|
435
457
|
# The session as it was before the message went in (ReplyWait's
|
|
@@ -535,13 +557,21 @@ module Samagotchi
|
|
|
535
557
|
owner = SessionManager.session_owner(id, state_dir: @state_dir)
|
|
536
558
|
running = owner && session.status == Session::STATUS_RUNNING
|
|
537
559
|
refs = copy_images(Session.session_dir(id, state_dir: @state_dir))
|
|
538
|
-
# --cut asks the running turn for a cut
|
|
539
|
-
# ordinary message
|
|
540
|
-
|
|
560
|
+
# --cut asks the running turn for a cut (to an idle session an
|
|
561
|
+
# ordinary message: nothing to cut). --queue always goes as a queue,
|
|
562
|
+
# idle or not: the Bridge acks next_step to an idle worker and the
|
|
563
|
+
# file still carries it, so a turn another client starts first never
|
|
564
|
+
# merges it, and its turn and reply carry its enqueued_id (the
|
|
565
|
+
# --wait's turn:); a worker older than queueing is refused.
|
|
566
|
+
delivery = if @queue then Delivery::QUEUE
|
|
567
|
+
elsif @cut && running then Delivery::CUT
|
|
568
|
+
end
|
|
541
569
|
result = SessionManager.deliver_turn(id, prompt: prompt, client_id: CLIENT_ID, images: refs, state_dir: @state_dir,
|
|
542
570
|
delivery: delivery)
|
|
543
571
|
unless result[:status] == :accepted
|
|
544
|
-
|
|
572
|
+
# A worker older than queueing would steer it: refused, not sent.
|
|
573
|
+
word = result.dig(:ack, "error") == Delivery::QUEUE_UNAVAILABLE["error"] ? "refused" : "failed"
|
|
574
|
+
@info.puts("#{short} #{word}: #{result.dig(:ack, "detail") || "could not queue it"}")
|
|
545
575
|
return false
|
|
546
576
|
end
|
|
547
577
|
|
|
@@ -556,6 +586,7 @@ module Samagotchi
|
|
|
556
586
|
# (next_step for an idle session) and a cut's outcome.
|
|
557
587
|
@sent_delivery = ack["delivery"] || delivery
|
|
558
588
|
@sent_cut = ack["cut"]
|
|
589
|
+
@sent_enqueued_id = (ack["enqueued_id"] || ack[:enqueued_id])&.to_s
|
|
559
590
|
@info.puts("#{short} sent#{with_images}#{delivery_note(ack, delivery: delivery, running: running, owner: owner)}")
|
|
560
591
|
true
|
|
561
592
|
rescue SessionManager::OwnedByTUI
|
|
@@ -568,12 +599,14 @@ module Samagotchi
|
|
|
568
599
|
|
|
569
600
|
# What the line says became of the message: a plain one goes in at the
|
|
570
601
|
# running turn's next step (nothing cuts by default); a --cut one names
|
|
571
|
-
# the outcome from the ack (now, waits, off);
|
|
572
|
-
#
|
|
602
|
+
# the outcome from the ack (now, waits, off); a --queue one runs after
|
|
603
|
+
# the turn (or now, when the turn ended before it went in); images keep
|
|
604
|
+
# their own wording; an idle session's turn simply runs.
|
|
573
605
|
# @param ack [Hash] the Bridge's 202 body ({} for the input file)
|
|
574
606
|
# @return [String] "" or " (...)"
|
|
575
607
|
def delivery_note(ack, delivery:, running:, owner:)
|
|
576
608
|
return " (started its worker)" if owner.nil?
|
|
609
|
+
return queue_note(ack, running: running) if Delivery.queue?(delivery)
|
|
577
610
|
return "" unless running
|
|
578
611
|
|
|
579
612
|
# A busy worker runs a message with images as its own next turn
|
|
@@ -589,12 +622,23 @@ module Samagotchi
|
|
|
589
622
|
" #{TerminalUI::Formatting.steer_cut_note(outcome.to_sym, ack["cut_after"])}"
|
|
590
623
|
end
|
|
591
624
|
|
|
592
|
-
#
|
|
593
|
-
#
|
|
594
|
-
#
|
|
595
|
-
#
|
|
625
|
+
# A --queue, worded from the Bridge's ack: queue when a turn ran as it
|
|
626
|
+
# went in (one another client started too), next_step when none did
|
|
627
|
+
# (it runs now). The input file (no Bridge) carries the queue to
|
|
628
|
+
# whichever turn runs: worded from the session as chi read it.
|
|
629
|
+
def queue_note(ack, running:)
|
|
630
|
+
queued = ack.key?("delivery") ? ack["delivery"] == Delivery::QUEUE : running
|
|
631
|
+
return " (queued: runs after the current turn)" if queued
|
|
632
|
+
|
|
633
|
+
running ? " (the running turn had ended: it runs now)" : " (nothing running: it runs now)"
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
# More keys for --format json, with --cut or --queue only (the line is
|
|
637
|
+
# unchanged otherwise): the delivery the worker applied (next_step to
|
|
638
|
+
# an idle session) and a cut's outcome (now, waits, off). --cut --wait
|
|
639
|
+
# is refused for a running turn, so its json line says next_step.
|
|
596
640
|
def report_extra
|
|
597
|
-
return {} unless @cut
|
|
641
|
+
return {} unless @cut || @queue
|
|
598
642
|
|
|
599
643
|
extra = { delivery: @sent_delivery || Delivery::NEXT_STEP }
|
|
600
644
|
extra[:cut] = @sent_cut if @sent_cut
|
|
@@ -116,7 +116,7 @@ module Samagotchi
|
|
|
116
116
|
registry.register("/detach", "leave and keep the worker running", local: true, uis: [:attached],
|
|
117
117
|
match: ->(text) { text.casecmp?("/detach") })
|
|
118
118
|
# /cut TEXT and /queue TEXT while a turn runs: a delivery the UIs parse
|
|
119
|
-
# themselves (
|
|
119
|
+
# themselves (ComposerRoute.steer_choice) before any command check; the
|
|
120
120
|
# entries exist for Tab completion and /help. The web composer sends
|
|
121
121
|
# /cut too. /queue is a real after-the-turn list only in the local
|
|
122
122
|
# REPL (the attached TUI's worker doesn't skip queued files yet), so
|
|
@@ -158,11 +158,39 @@ module Samagotchi
|
|
|
158
158
|
nil
|
|
159
159
|
end
|
|
160
160
|
|
|
161
|
-
|
|
161
|
+
# A turn's reply: output/<timestamp>.txt, or
|
|
162
|
+
# output/<timestamp>-<enqueued_id>.txt for a turn that ran an input
|
|
163
|
+
# with an id, so a wait for that input's turn (ReplyWait's turn:) finds
|
|
164
|
+
# its own reply. The names still sort by time ("-" sorts before ".",
|
|
165
|
+
# so an id'd name and a bare one of the same timestamp keep order);
|
|
166
|
+
# every reader globs *.txt.
|
|
167
|
+
# @param enqueued_id [String, nil] the turn's input id (LastTurn#enqueued_id)
|
|
168
|
+
def self.write_output(session_dir, response, enqueued_id: nil)
|
|
162
169
|
output_dir = File.join(session_dir, OUTPUT_DIR)
|
|
163
170
|
FileUtils.mkdir_p(output_dir)
|
|
164
171
|
timestamp = Time.now.strftime("%Y%m%d%H%M%S%9N")
|
|
165
|
-
AtomicFile.write(File.join(output_dir, "#{timestamp}.txt"), response.to_s)
|
|
172
|
+
AtomicFile.write(File.join(output_dir, "#{output_name(timestamp, enqueued_id)}.txt"), response.to_s)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# An id that is safe in a file name (a Bridge's or deliver_turn's
|
|
176
|
+
# uuid); anything else leaves the name bare.
|
|
177
|
+
ENQUEUED_ID_NAME = /\A[A-Za-z0-9_-]{1,64}\z/
|
|
178
|
+
|
|
179
|
+
def self.output_name(timestamp, enqueued_id)
|
|
180
|
+
id = enqueued_id.to_s
|
|
181
|
+
ENQUEUED_ID_NAME.match?(id) ? "#{timestamp}-#{id}" : timestamp
|
|
182
|
+
end
|
|
183
|
+
private_class_method :output_name
|
|
184
|
+
|
|
185
|
+
# @return [String, nil] the reply filename of the turn that ran the
|
|
186
|
+
# input +enqueued_id+, or nil while it has none
|
|
187
|
+
def self.reply_for(session_dir, enqueued_id)
|
|
188
|
+
dir = File.join(session_dir, OUTPUT_DIR)
|
|
189
|
+
id = enqueued_id.to_s
|
|
190
|
+
return nil if id.empty? || !Dir.exist?(dir)
|
|
191
|
+
|
|
192
|
+
suffix = "-#{id}.txt"
|
|
193
|
+
Dir.children(dir).select { |f| f.end_with?(suffix) }.max
|
|
166
194
|
end
|
|
167
195
|
|
|
168
196
|
# The output files' text, oldest first; with +since_time+ only files
|
|
@@ -218,12 +246,40 @@ module Samagotchi
|
|
|
218
246
|
end
|
|
219
247
|
|
|
220
248
|
# Whether a mid-turn drain leaves this unclaimed input file for a turn
|
|
221
|
-
# of its own instead of merging it at a step boundary
|
|
222
|
-
# image message (steering merges text only)
|
|
223
|
-
#
|
|
224
|
-
#
|
|
249
|
+
# of its own instead of merging it at a step boundary: a queued message
|
|
250
|
+
# (`delivery: queue`) or an image message (steering merges text only).
|
|
251
|
+
# The drain skips such a file without stopping at it, so a later text
|
|
252
|
+
# line still merges; the drain's file filter reads this one predicate,
|
|
253
|
+
# so what a boundary leaves changes in one place.
|
|
225
254
|
def self.waits_for_turn_end?(input_file)
|
|
226
|
-
|
|
255
|
+
data = JSON.parse(File.read(input_file))
|
|
256
|
+
data.is_a?(Hash) && (Array(data["images"]).any? || Delivery.queue?(data["delivery"]))
|
|
257
|
+
rescue JSON::ParserError, SystemCallError
|
|
258
|
+
false
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Whether an input file carrying +enqueued_id+ still waits unclaimed in
|
|
262
|
+
# input/ (no worker has taken it for a turn or a step yet).
|
|
263
|
+
def self.input_waiting?(session_dir, enqueued_id) = !waiting_input(session_dir, enqueued_id).nil?
|
|
264
|
+
|
|
265
|
+
# How the unclaimed input file carrying +enqueued_id+ waits: :turn_end
|
|
266
|
+
# (a queue or an image message: it runs as a turn of its own, never
|
|
267
|
+
# merged, #waits_for_turn_end?), :merges (a running turn's drain may
|
|
268
|
+
# take it as a steer), or nil when none waits.
|
|
269
|
+
# @return [Symbol, nil]
|
|
270
|
+
def self.waiting_input(session_dir, enqueued_id)
|
|
271
|
+
id = enqueued_id.to_s
|
|
272
|
+
return nil if id.empty?
|
|
273
|
+
|
|
274
|
+
find_new_input_files(session_dir).each do |file|
|
|
275
|
+
data = JSON.parse(File.read(file))
|
|
276
|
+
next unless data.is_a?(Hash) && data["enqueued_id"] == id
|
|
277
|
+
|
|
278
|
+
return Array(data["images"]).any? || Delivery.queue?(data["delivery"]) ? :turn_end : :merges
|
|
279
|
+
rescue JSON::ParserError, SystemCallError
|
|
280
|
+
next
|
|
281
|
+
end
|
|
282
|
+
nil
|
|
227
283
|
end
|
|
228
284
|
|
|
229
285
|
def self.claim_input_file(input_file)
|