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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -1
  3. data/docs/architecture.md +12 -2
  4. data/docs/cli.md +16 -8
  5. data/docs/configuration.md +15 -8
  6. data/docs/internals/engine.md +10 -3
  7. data/docs/plugins.md +7 -0
  8. data/docs/sessions.md +11 -8
  9. data/docs/sub-agent.md +21 -4
  10. data/docs/testing.md +6 -0
  11. data/lib/samagotchi/answer_command.rb +5 -1
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  13. data/lib/samagotchi/bridge.rb +18 -4
  14. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
  16. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/child_reports.rb +7 -2
  19. data/lib/samagotchi/cli/parent_wait.rb +9 -2
  20. data/lib/samagotchi/composer_route.rb +90 -0
  21. data/lib/samagotchi/config.rb +6 -3
  22. data/lib/samagotchi/continue_offer.rb +17 -3
  23. data/lib/samagotchi/delivery.rb +13 -8
  24. data/lib/samagotchi/engine.rb +2 -0
  25. data/lib/samagotchi/host_config.rb +6 -6
  26. data/lib/samagotchi/host_model.rb +2 -2
  27. data/lib/samagotchi/interrupted_turn.rb +7 -3
  28. data/lib/samagotchi/last_turn.rb +5 -2
  29. data/lib/samagotchi/pending_input_queue.rb +10 -3
  30. data/lib/samagotchi/plugin/sessions.rb +27 -5
  31. data/lib/samagotchi/reply_wait.rb +171 -6
  32. data/lib/samagotchi/send_command.rb +63 -19
  33. data/lib/samagotchi/session_commands.rb +1 -1
  34. data/lib/samagotchi/session_inbox.rb +63 -7
  35. data/lib/samagotchi/session_manager.rb +49 -20
  36. data/lib/samagotchi/terminal_ui/attached_loop.rb +47 -26
  37. data/lib/samagotchi/terminal_ui/formatting.rb +3 -19
  38. data/lib/samagotchi/terminal_ui.rb +72 -57
  39. data/lib/samagotchi/tool_activity.rb +5 -5
  40. data/lib/samagotchi/tools/delegate.rb +28 -1
  41. data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
  42. data/lib/samagotchi/tools/delegate_wait.rb +17 -5
  43. data/lib/samagotchi/tools/execute.rb +11 -4
  44. data/lib/samagotchi/turn_checkpoint.rb +9 -3
  45. data/lib/samagotchi/version.rb +1 -1
  46. data/lib/samagotchi/web/public/app.js +6 -0
  47. data/lib/samagotchi/web/public/commands.js +8 -8
  48. data/lib/samagotchi/web/public/composer.js +3 -3
  49. data/lib/samagotchi/web/public/timing.js +2 -1
  50. data/lib/samagotchi/web/public/turn_model.js +13 -3
  51. data/lib/samagotchi/worker.rb +7 -5
  52. 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 session.status == Session::STATUS_RUNNING
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 also delivery (what
103
- the worker applied) and cut (now, waits, off)
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
- wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout])
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; to an idle session it is an
539
- # ordinary message (there is nothing to cut).
540
- delivery = @cut && running ? Delivery::CUT : nil
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
- @info.puts("#{short} failed: #{result.dig(:ack, "detail") || "could not queue it"}")
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); images keep their own
572
- # wording; an idle session's turn simply runs.
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
- # More keys for --format json, with --cut only (the line is unchanged
593
- # otherwise): the delivery the worker applied (next_step to an idle
594
- # session) and a cut's outcome (now, waits, off). In P1 --cut --wait is
595
- # refused for a running turn, so the json line says next_step.
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 (Formatting#steer_choice) before any command check; the
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
- def self.write_output(session_dir, response)
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. Today that is an
222
- # image message (steering merges text only); the drain's file filter
223
- # reads this one predicate, so what a boundary refuses changes in one
224
- # place.
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
- input_has_images?(input_file)
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)