samagotchi 0.84.0 → 0.86.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/docs/architecture.md +44 -6
  4. data/docs/bundle-authoring.md +125 -0
  5. data/docs/cli.md +54 -4
  6. data/docs/configuration.md +8 -5
  7. data/docs/guardrails.md +63 -35
  8. data/docs/hooks.md +20 -4
  9. data/docs/internals/anthropic.md +2 -2
  10. data/docs/internals/bundles.md +11 -2
  11. data/docs/internals/context-telemetry.md +19 -1
  12. data/docs/internals/engine.md +4 -3
  13. data/docs/memory.md +1 -1
  14. data/docs/plugins.md +59 -2
  15. data/docs/releasing.md +1 -1
  16. data/docs/sessions.md +8 -6
  17. data/docs/sub-agent.md +35 -24
  18. data/docs/testing.md +7 -2
  19. data/lib/samagotchi/answer_command.rb +21 -25
  20. data/lib/samagotchi/bridge/client_event.rb +50 -0
  21. data/lib/samagotchi/bridge/sse_writer.rb +6 -2
  22. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -0
  23. data/lib/samagotchi/bridge.rb +76 -12
  24. data/lib/samagotchi/bridge_client.rb +14 -2
  25. data/lib/samagotchi/bundle_command/check.rb +348 -0
  26. data/lib/samagotchi/bundle_command/scaffold.rb +135 -0
  27. data/lib/samagotchi/bundle_command/test_run.rb +89 -0
  28. data/lib/samagotchi/bundle_command.rb +125 -2
  29. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -4
  31. data/lib/samagotchi/bundles/cost-cap/plugin.rb +4 -1
  32. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -2
  34. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  35. data/lib/samagotchi/bundles/ruby/manifest.yml +11 -0
  36. data/lib/samagotchi/bundles/ruby/plugin.rb +74 -0
  37. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  38. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  39. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  40. data/lib/samagotchi/client_id.rb +1 -1
  41. data/lib/samagotchi/config.rb +195 -115
  42. data/lib/samagotchi/context_status.rb +1 -1
  43. data/lib/samagotchi/continue_offer.rb +2 -1
  44. data/lib/samagotchi/engine.rb +5 -5
  45. data/lib/samagotchi/guardrail_wiring.rb +4 -0
  46. data/lib/samagotchi/guardrails/approval.rb +12 -2
  47. data/lib/samagotchi/guardrails/load_failures.rb +17 -3
  48. data/lib/samagotchi/guardrails/parent_approvals.rb +14 -70
  49. data/lib/samagotchi/guardrails/parent_policy.rb +258 -0
  50. data/lib/samagotchi/guardrails/verdict.rb +6 -3
  51. data/lib/samagotchi/hook_routes.rb +5 -3
  52. data/lib/samagotchi/hooks/bundle_loader.rb +3 -1
  53. data/lib/samagotchi/hooks/registry.rb +13 -6
  54. data/lib/samagotchi/hooks.rb +5 -0
  55. data/lib/samagotchi/kernel_loop.rb +10 -3
  56. data/lib/samagotchi/llm/anthropic_assembly.rb +1 -1
  57. data/lib/samagotchi/llm/chat_loop.rb +5 -1
  58. data/lib/samagotchi/log_subscriber.rb +1 -0
  59. data/lib/samagotchi/memory_bundle/shas.rb +127 -0
  60. data/lib/samagotchi/memory_bundle.rb +1 -0
  61. data/lib/samagotchi/parent_report.rb +92 -16
  62. data/lib/samagotchi/pending_question.rb +8 -4
  63. data/lib/samagotchi/plugin/api.rb +16 -0
  64. data/lib/samagotchi/plugin/context.rb +22 -12
  65. data/lib/samagotchi/plugin/loader.rb +5 -3
  66. data/lib/samagotchi/question_desk.rb +37 -24
  67. data/lib/samagotchi/queued_command.rb +5 -4
  68. data/lib/samagotchi/relay.rb +5 -1
  69. data/lib/samagotchi/relay_verifier.rb +4 -5
  70. data/lib/samagotchi/reply_wait.rb +22 -6
  71. data/lib/samagotchi/send_command.rb +34 -8
  72. data/lib/samagotchi/session_commands.rb +21 -7
  73. data/lib/samagotchi/session_inbox.rb +68 -0
  74. data/lib/samagotchi/terminal_ui/attach_launcher.rb +1 -1
  75. data/lib/samagotchi/terminal_ui/attached_loop.rb +102 -12
  76. data/lib/samagotchi/terminal_ui/thinking_line.rb +5 -9
  77. data/lib/samagotchi/terminal_ui.rb +15 -9
  78. data/lib/samagotchi/testing/boundary.rb +0 -3
  79. data/lib/samagotchi/testing/bundle.rb +7 -0
  80. data/lib/samagotchi/testing/host.rb +6 -4
  81. data/lib/samagotchi/testing/install.rb +119 -0
  82. data/lib/samagotchi/testing/recorder.rb +13 -4
  83. data/lib/samagotchi/testing.rb +1 -0
  84. data/lib/samagotchi/tools/builtins.rb +2 -1
  85. data/lib/samagotchi/tools/delegate_wait.rb +6 -1
  86. data/lib/samagotchi/tools/task_runtime.rb +5 -3
  87. data/lib/samagotchi/turn_checkpoint.rb +64 -2
  88. data/lib/samagotchi/turn_flow.rb +10 -0
  89. data/lib/samagotchi/version.rb +1 -1
  90. data/lib/samagotchi/web/app.rb +76 -10
  91. data/lib/samagotchi/web/public/app.js +17 -0
  92. data/lib/samagotchi/web/public/bubbles.js +9 -0
  93. data/lib/samagotchi/web/public/chunk_router.js +8 -19
  94. data/lib/samagotchi/web/public/commands.js +1 -0
  95. data/lib/samagotchi/web/public/ctx.js +12 -26
  96. data/lib/samagotchi/web/public/data.js +11 -0
  97. data/lib/samagotchi/web/public/index.html +9 -0
  98. data/lib/samagotchi/web/public/stage_view.js +6 -0
  99. data/lib/samagotchi/web/public/stream_handlers.js +4 -1
  100. data/lib/samagotchi/web/public/turn_events.js +4 -0
  101. data/lib/samagotchi/web/public/turn_html.js +9 -1
  102. data/lib/samagotchi/web/public/unqueue.js +56 -0
  103. data/lib/samagotchi/web/server.rb +4 -0
  104. data/lib/samagotchi/worker.rb +19 -5
  105. data/lib/samagotchi/worker_inbound.rb +4 -4
  106. metadata +12 -2
  107. data/lib/samagotchi/guardrails/parent_continue.rb +0 -43
@@ -5,7 +5,6 @@ require_relative "session"
5
5
  require_relative "bridge_client"
6
6
  require_relative "worker_sidecar"
7
7
  require_relative "question_desk"
8
- require_relative "guardrails/parent_approvals"
9
8
  require_relative "log"
10
9
 
11
10
  module Samagotchi
@@ -24,8 +23,8 @@ module Samagotchi
24
23
  end
25
24
 
26
25
  # @return [Array(Hash, Integer, Hash)] headers, status, body: 200
27
- # answered, 409 no longer pending, 403 refused (a parent agent's allow
28
- # beyond this worker's guardrails.parent_approvals), 422
26
+ # answered, 409 no longer pending, 403 refused (a parent agent's pick
27
+ # beyond this worker's Guardrails::ParentPolicy), 422
29
28
  # relay_unverified (logged; the question stays open)
30
29
  def answer(relay_id:, question_id:)
31
30
  parent_id = own_parent_id or return unverified("this session has no parent", relay_id)
@@ -59,8 +58,8 @@ module Samagotchi
59
58
  not_pending(question_id)
60
59
  rescue QuestionDesk::Refused => e
61
60
  log("relay_refused", relay_id, id: question_id, reason: e.reason)
62
- [{}, 403, { error: "parent_approval_refused", reason: e.reason.to_s,
63
- detail: Guardrails::ParentApprovals.message(e.reason) }]
61
+ [{}, 403, { error: "parent_answer_refused", reason: e.reason.to_s,
62
+ detail: e.detail }]
64
63
  rescue ArgumentError => e
65
64
  unverified("the answer doesn't fit the question: #{e.message}", relay_id)
66
65
  end
@@ -3,6 +3,7 @@
3
3
  require_relative "session"
4
4
  require_relative "turn_note"
5
5
  require_relative "session_inbox"
6
+ require_relative "turn_checkpoint"
6
7
  require_relative "worker_sidecar"
7
8
 
8
9
  module Samagotchi
@@ -32,10 +33,13 @@ module Samagotchi
32
33
  # @!attribute question [PendingQuestion, nil] the pending question
33
34
  # (:waiting_for_answer)
34
35
  # @!attribute outcome [String, nil] how the turn ended when it left no
35
- # reply (:no_reply): "failed", "canceled", "completed" (empty),
36
+ # reply (:no_reply): "failed", "canceled", "completed" (empty turn or
37
+ # file-delivered command ran without a turn),
36
38
  # "exhausted" (it ran out of iterations, with nobody asked whether to
37
- # continue) or "not_continued" (a Stop answered the step-limit
38
- # question; no turn ran), from the session's last_turn; nil when unknown. With
39
+ # continue), "not_continued" (a Stop answered the step-limit
40
+ # question; no turn ran), from the session's last_turn, or "removed"
41
+ # (turn:'s queued input was taken back before it ran, its tombstone:
42
+ # SessionInbox.removed?; no turn ran); nil when unknown. With
39
43
  # "failed", text is the failure's summary when the turn note has one.
40
44
  # @!attribute limit [Integer, nil] the iteration limit an "exhausted"
41
45
  # turn ran out at
@@ -45,8 +49,9 @@ module Samagotchi
45
49
  # retry
46
50
  # @!attribute cancel_reason [String, nil] a "canceled" turn's reason
47
51
  # (user, hook, ctrl_c, manual, session_stopped: the session was
48
- # stopped mid-turn), passed through as the worker saved it; any
49
- # reason is a "canceled" outcome
52
+ # stopped mid-turn; worker_stopped: turn:'s worker died in it and the
53
+ # next one settled it, TurnCheckpoint.settled_turn), passed through
54
+ # as the worker saved it; any reason is a "canceled" outcome
50
55
  # @!attribute stopped_by [String, nil] the hook that canceled it
51
56
  # (loop-guard)
52
57
  # @!attribute session [Session, nil] the session as the look that
@@ -132,6 +137,11 @@ module Samagotchi
132
137
  claimed = waiting.nil?
133
138
  end
134
139
  session = Session.load(id, state_dir: state_dir)
140
+ # Taken back before it ran (POST turn/dequeue): no turn will come.
141
+ if turn && claimed && !seen_own && SessionInbox.removed?(session_dir, turn)
142
+ return Result.new(status: :no_reply, outcome: "removed", session: session)
143
+ end
144
+
135
145
  if turn && !seen_own && session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == turn
136
146
  seen_own = true
137
147
  own_base = session.last_turn&.ended_at
@@ -149,6 +159,12 @@ module Samagotchi
149
159
  reply.session = settled(session, id, state_dir: state_dir)
150
160
  return reply
151
161
  end
162
+ # Its worker died in it and the next one settled it, or an input
163
+ # command finished without a turn. After the reply check: a worker
164
+ # killed between writing the reply and its idle save left the reply.
165
+ if turn && (settled = TurnCheckpoint.settled_turn(session_dir, turn))
166
+ return Result.new(status: :no_reply, outcome: settled.outcome, session: session, **settled.stop_facts)
167
+ end
152
168
 
153
169
  case session.status
154
170
  when Session::STATUS_ERROR
@@ -307,7 +323,7 @@ module Samagotchi
307
323
 
308
324
  running = session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == id
309
325
  queued = SessionInbox.waiting_input(session_dir, id) == :turn_end
310
- ended = session.last_turn&.enqueued_id == id
326
+ ended = session.last_turn&.enqueued_id == id || !TurnCheckpoint.settled_turn(session_dir, id).nil?
311
327
  running || queued || ended
312
328
  end
313
329
 
@@ -12,6 +12,9 @@ require_relative "session_chain"
12
12
  require_relative "context_quote"
13
13
  require_relative "reply_wait"
14
14
  require_relative "parent_report"
15
+ require_relative "turn_flow"
16
+ require_relative "guardrails/parent_approvals"
17
+ require_relative "guardrails/parent_policy"
15
18
  require_relative "image_store"
16
19
  require_relative "host_registry"
17
20
  require_relative "model_profile"
@@ -241,6 +244,14 @@ module Samagotchi
241
244
  if prompt && (local = local_command(prompt))
242
245
  return refuse_local_command(local, options)
243
246
  end
247
+
248
+ # A parent agent's /continue yes is chi answer --option Continue by
249
+ # another route: held to guardrails.parent_answers here, and marked
250
+ # as chi answer's (#client_id_for) so the worker checks it again.
251
+ if prompt && parent_continue?(prompt) && (refusal = Guardrails::ParentPolicy.continue_refusal)
252
+ @stderr.puts("chi send: #{refusal}")
253
+ return CLI::Exit::FAILED
254
+ end
244
255
  # A file that is not there or not an image: a run-time failure (1),
245
256
  # reported in the --format json line too; --image itself was fine.
246
257
  return CLI::Exit::FAILED unless ingest_images(options[:images])
@@ -414,7 +425,8 @@ module Samagotchi
414
425
  return report_command(session.id) if @sent_command
415
426
 
416
427
  wait_for_reply(session.id, cursor: nil, baseline: baseline_of(session, question_id: nil),
417
- timeout: options[:timeout], report_turn_id: @sent_enqueued_id)
428
+ timeout: options[:timeout], turn: @sent_input_file ? @sent_enqueued_id : nil,
429
+ report_turn_id: @sent_enqueued_id)
418
430
  end
419
431
 
420
432
  # The model the new session will run, for the images check: --model
@@ -461,11 +473,13 @@ module Samagotchi
461
473
  # The turn waits on a question: the message would only queue
462
474
  # behind it, and the answer goes in with chi answer.
463
475
  if (waiting = session.waiting_question(live: owner.worker?))
476
+ # The whole question (its options, its tags) for what a parent may give.
477
+ question = ParentReport.question(session.pending_question)
464
478
  if ParentReport.approval?(waiting)
465
- error_line("#{id[0, 8]} waiting for an approval: #{ParentReport::DENY_AND_TELL} " \
466
- "(#{ParentReport.answer_with(id, waiting)})")
479
+ error_line("#{id[0, 8]} waiting for an approval: #{ParentReport.approval_advice(question)} " \
480
+ "(#{ParentReport.answer_with(id, question)})")
467
481
  else
468
- error_line("#{id[0, 8]} waiting for an answer: #{ParentReport.answer_with(id, waiting)} (or --text); " \
482
+ error_line("#{id[0, 8]} waiting for an answer: #{ParentReport.answer_with(id, question)} (or --text); " \
469
483
  "chi --attach #{id} shows it")
470
484
  end
471
485
  else
@@ -485,7 +499,9 @@ module Samagotchi
485
499
  # The ack's id names the queued message's turn, also when the turn
486
500
  # running at the check ended before it went in (an idle worker's ack
487
501
  # says next_step, and the file still runs as that turn).
488
- turn = @queue ? @sent_enqueued_id : nil
502
+ # A file fallback has no delivery field; use its id to see terminal
503
+ # outcomes for commands that finish without starting a turn.
504
+ turn = @sent_enqueued_id if @queue || @sent_input_file
489
505
  report_turn_id = if @queue
490
506
  turn
491
507
  elsif session.status == Session::STATUS_RUNNING
@@ -493,7 +509,8 @@ module Samagotchi
493
509
  else
494
510
  @sent_enqueued_id
495
511
  end
496
- wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout], turn: turn, queued: !turn.nil?,
512
+ wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout], turn: turn,
513
+ queued: @queue && !turn.nil?,
497
514
  report_turn_id: report_turn_id)
498
515
  end
499
516
 
@@ -550,7 +567,7 @@ module Samagotchi
550
567
  begin
551
568
  refs = copy_images(dir)
552
569
  if BridgeClient.wait_for(session.id, session_dir: dir, timeout: SessionManager::TURN_BRIDGE_WAIT)
553
- result = SessionManager.deliver_turn(session.id, prompt: prompt, client_id: CLIENT_ID, images: refs,
570
+ result = SessionManager.deliver_turn(session.id, prompt: prompt, client_id: client_id_for(prompt), images: refs,
554
571
  state_dir: @state_dir)
555
572
  end
556
573
  rescue StandardError => e
@@ -569,6 +586,7 @@ module Samagotchi
569
586
  end
570
587
 
571
588
  ack = result[:ack].is_a?(Hash) ? result[:ack] : {}
589
+ @sent_input_file = !ack.key?("delivery")
572
590
  @sent_enqueued_id = (ack["enqueued_id"] || ack[:enqueued_id])&.to_s
573
591
  @sent_command = command_ack?(result[:ack])
574
592
  @info.puts("#{session.id} started#{continued_words(session)}#{with_images}#{"; #{SENT_COMMAND}" if @sent_command}")
@@ -581,6 +599,13 @@ module Samagotchi
581
599
  # /turn whose line is one answers with its command_id).
582
600
  def command_ack?(ack) = ack.is_a?(Hash) && !ack["command_id"].to_s.empty?
583
601
 
602
+ # chi send's client id, or chi answer's for a parent agent's /continue
603
+ # yes (the worker holds that to its guardrails.parent_answers).
604
+ def client_id_for(prompt) = parent_continue?(prompt) ? ClientId::CLI_ANSWER : CLIENT_ID
605
+
606
+ # A /continue yes (or bare /continue) from a parent agent's process.
607
+ def parent_continue?(prompt) = TurnFlow.continue_yes?(prompt) && Guardrails::ParentApprovals.parent_process?
608
+
584
609
  # Whether +text+ may be a session command, which only its worker knows
585
610
  # (a bundle's too).
586
611
  def command_like?(text) = text.to_s.lstrip.start_with?("/", "!")
@@ -648,7 +673,7 @@ module Samagotchi
648
673
  delivery = if @queue then Delivery::QUEUE
649
674
  elsif @cut && running then Delivery::CUT
650
675
  end
651
- result = SessionManager.deliver_turn(id, prompt: prompt, client_id: CLIENT_ID, images: refs, state_dir: @state_dir,
676
+ result = SessionManager.deliver_turn(id, prompt: prompt, client_id: client_id_for(prompt), images: refs, state_dir: @state_dir,
652
677
  delivery: delivery)
653
678
  unless result[:status] == :accepted
654
679
  # A worker older than queueing would steer it: refused, not sent.
@@ -664,6 +689,7 @@ module Samagotchi
664
689
  end
665
690
 
666
691
  ack = result[:ack].is_a?(Hash) ? result[:ack] : {}
692
+ @sent_input_file = !ack.key?("delivery")
667
693
  # What the worker applied, for --format json: its ack's delivery
668
694
  # (next_step for an idle session) and a cut's outcome.
669
695
  @sent_delivery = ack["delivery"] || delivery
@@ -14,6 +14,7 @@ require_relative "llm_context_command"
14
14
  require_relative "thinking_command"
15
15
  require_relative "toolkit_command"
16
16
  require_relative "reasoning_history_command"
17
+ require_relative "guardrails/parent_policy"
17
18
 
18
19
  module Samagotchi
19
20
  # The session commands a REPL and a session worker both run: /model,
@@ -80,7 +81,7 @@ module Samagotchi
80
81
  next reply("nothing to continue") unless @turn_flow.awaiting_continue?
81
82
 
82
83
  answer = text.delete_prefix(CONTINUE_COMMAND).strip
83
- continue_answer(answer.empty? ? CONTINUE_COMMAND : answer)
84
+ continue_answer(answer.empty? ? CONTINUE_COMMAND : answer, as_parent: @as_parent)
84
85
  end
85
86
  # It reads the hosts only (the registry is mutex-guarded): mid-turn too.
86
87
  registry.register(MODELS_COMMAND, "list the hosts' models (/models <text> filters)", anytime: true) do |text|
@@ -123,13 +124,16 @@ module Samagotchi
123
124
  # /cut TEXT and /queue TEXT while a turn runs: a delivery the UIs parse
124
125
  # themselves (ComposerRoute.steer_choice) before any command check; the
125
126
  # entries exist for Tab completion and /help. The web composer sends
126
- # /cut too. /queue is a real after-the-turn list only in the local
127
- # REPL (the attached TUI's worker doesn't skip queued files yet), so
128
- # it is offered there.
127
+ # /cut too (its queue is Alt+Enter). Both terminals queue: the REPL in
128
+ # its own list, the attached TUI's worker in its input files.
129
129
  registry.register("/cut", "cut the running generation and go in now (/cut TEXT)",
130
130
  local: true, uis: %i[repl attached web], match: ->(text) { text.casecmp?("/cut") || text.match?(%r{\A/cut\s+\S}) })
131
131
  registry.register("/queue", "run as the next turn after this one (/queue TEXT)",
132
- local: true, uis: [:repl], match: ->(text) { text.casecmp?("/queue") || text.match?(%r{\A/queue\s+\S}) })
132
+ local: true, uis: %i[repl attached], match: ->(text) { text.casecmp?("/queue") || text.match?(%r{\A/queue\s+\S}) })
133
+ # Takes back the newest message this attached TUI queued, before it
134
+ # runs (the worker's POST turn/dequeue); the web has × on the bubble.
135
+ registry.register("/unqueue", "remove your newest queued message before it runs", local: true, uis: [:attached],
136
+ match: ->(text) { text.casecmp?("/unqueue") })
133
137
  registry
134
138
  end
135
139
 
@@ -251,8 +255,12 @@ module Samagotchi
251
255
  # change asks first (CacheConfirm): true for a person at a web tab or
252
256
  # a TUI; false applies it at once (chi send, a delegate, a plugin,
253
257
  # chi -p)
258
+ # @param as_parent [Boolean] a parent agent sent it (client cli:answer):
259
+ # a /continue yes is held to guardrails.parent_answers, as chi
260
+ # answer --option Continue is (Guardrails::ParentPolicy)
254
261
  # @return [Result, nil] nil when +line+ is not one of these commands
255
- def run(line, confirm: true)
262
+ def run(line, confirm: true, as_parent: false)
263
+ @as_parent = as_parent
256
264
  text = line.to_s.strip
257
265
  entry = @registry.lookup(text)
258
266
  return nil unless entry&.handler
@@ -265,9 +273,15 @@ module Samagotchi
265
273
  end
266
274
 
267
275
  # Answer the pending continue offer (yes / no / no, <reason>).
276
+ # @param as_parent [Boolean] a parent agent's answer: a yes its strategy
277
+ # doesn't allow is refused (the offer stays; decision :invalid)
268
278
  # @return [Result] resume: true on yes
269
- def continue_answer(text)
279
+ def continue_answer(text, as_parent: false)
270
280
  decision, reason = TurnFlow.continue_decision(text)
281
+ if decision == :resume && as_parent && (refusal = Guardrails::ParentPolicy.continue_refusal)
282
+ return reply(refusal, status: :error).tap { |result| result.decision = :invalid }
283
+ end
284
+
271
285
  result = case decision
272
286
  when :resume then Result.new(status: :ok, changed: [], model_name: model_name, resume: true, shell: false)
273
287
  when :abort
@@ -6,6 +6,7 @@ require "time"
6
6
  require "securerandom"
7
7
  require_relative "atomic_file"
8
8
  require_relative "delivery"
9
+ require_relative "log"
9
10
  require_relative "session"
10
11
 
11
12
  module Samagotchi
@@ -276,5 +277,72 @@ module Samagotchi
276
277
  rescue Errno::ENOENT, Errno::EACCES
277
278
  nil
278
279
  end
280
+
281
+ # Where a removed queued input leaves its tombstone: input/removed/<id>,
282
+ # an empty file outside the *.json glob, so a wait for that input's
283
+ # turn (ReplyWait's turn:) learns it will never run (#removed?).
284
+ REMOVED_DIR = "removed"
285
+ # The worker's sidecar feature for POST turn/dequeue (Bridge::FEATURES).
286
+ UNQUEUE_FEATURE = "unqueue"
287
+
288
+ # Take back the unclaimed input file carrying +enqueued_id+ before a
289
+ # turn takes it (the Bridge's POST turn/dequeue, under the event log):
290
+ # only one that waits for the turn's end (a queue or an image message,
291
+ # #waits_for_turn_end?); a next-step file may be merged at any boundary.
292
+ # The file is claimed first, as the drain does (the rename is the race's
293
+ # one winner), then deleted, and its tombstone written.
294
+ # @return [Array(Symbol, Input)] [:removed, the input it held],
295
+ # [:not_queued, nil] for a next-step file, [:claimed, nil] when a
296
+ # turn took it (its *.json.processing is there), [:none, nil] for
297
+ # none
298
+ def self.take_queued_input(session_dir, enqueued_id)
299
+ id = enqueued_id.to_s
300
+ return [:none, nil] if id.empty?
301
+
302
+ find_new_input_files(session_dir).sort.each do |file|
303
+ data = JSON.parse(File.read(file))
304
+ next unless data.is_a?(Hash) && data["enqueued_id"] == id
305
+ return [:not_queued, nil] unless Array(data["images"]).any? || Delivery.queue?(data["delivery"])
306
+
307
+ claimed = claim_input_file(file) or return [:claimed, nil]
308
+ input = read_input(claimed)
309
+ mark_removed(session_dir, id)
310
+ FileUtils.rm_f(claimed)
311
+ return [:removed, input]
312
+ rescue JSON::ParserError, SystemCallError
313
+ next
314
+ end
315
+ claimed_input?(session_dir, id) ? [:claimed, nil] : [:none, nil]
316
+ end
317
+
318
+ # Whether a claimed input file (*.json.processing: a turn's, now or
319
+ # left by a crash) carries +id+.
320
+ def self.claimed_input?(session_dir, id)
321
+ Dir.glob(File.join(session_dir, INPUT_DIR, "*.json.processing")).any? do |file|
322
+ data = JSON.parse(File.read(file))
323
+ data.is_a?(Hash) && data["enqueued_id"] == id
324
+ rescue JSON::ParserError, SystemCallError
325
+ false
326
+ end
327
+ end
328
+ private_class_method :claimed_input?
329
+
330
+ def self.mark_removed(session_dir, id)
331
+ return unless ENQUEUED_ID_NAME.match?(id)
332
+
333
+ dir = File.join(session_dir, INPUT_DIR, REMOVED_DIR)
334
+ FileUtils.mkdir_p(dir)
335
+ AtomicFile.write(File.join(dir, id), "")
336
+ rescue SystemCallError => e
337
+ Log.warn(:bridge, "removed_tombstone_write_failed", error: e.class.name, msg: e.message)
338
+ end
339
+ private_class_method :mark_removed
340
+
341
+ # @return [Boolean] whether the queued input +enqueued_id+ was removed
342
+ # before it ran (#take_queued_input's tombstone)
343
+ def self.removed?(session_dir, enqueued_id)
344
+ id = enqueued_id.to_s
345
+ ENQUEUED_ID_NAME.match?(id) && File.exist?(File.join(session_dir, INPUT_DIR, REMOVED_DIR, id))
346
+ end
279
347
  end
280
348
  end
@@ -77,7 +77,7 @@ module Samagotchi
77
77
  first_command: first_command, no_interrupt: no_interrupt,
78
78
  default_input: default_input && !prompt && !attach && !resume,
79
79
  wait_at_eof: !$stdin.tty?,
80
- parent_answers: Guardrails::ParentApprovals.parent_process?, state_dir: state_dir)
80
+ as_parent: Guardrails::ParentApprovals.parent_process?, state_dir: state_dir)
81
81
  ended = begin
82
82
  attached.run
83
83
  ensure
@@ -5,6 +5,7 @@ require_relative "../composer_route"
5
5
  require_relative "event_renderer"
6
6
  require_relative "formatting"
7
7
  require_relative "../prompt_history"
8
+ require_relative "../session_inbox"
8
9
  require_relative "attached_view"
9
10
  require_relative "status_row"
10
11
  require_relative "input_support"
@@ -104,7 +105,7 @@ module Samagotchi
104
105
  # @param state_dir [String] the state dir the worker was found in: its
105
106
  # session folder (@path images, saved records) is under it
106
107
  def initialize(client:, screen:, client_id:, first_prompt: nil, first_command: nil, no_interrupt: false,
107
- default_input: false, wait_at_eof: false, parent_answers: false,
108
+ default_input: false, wait_at_eof: false, as_parent: false,
108
109
  clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
109
110
  delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) },
110
111
  archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) },
@@ -120,8 +121,8 @@ module Samagotchi
120
121
  @client_id = client_id
121
122
  # A parent agent drives this attach (AttachLauncher:
122
123
  # ParentApprovals.parent_process?): its answers go as chi answer's,
123
- # so the worker holds them to guardrails.parent_approvals.
124
- @parent_answers = parent_answers
124
+ # so the worker holds them to Guardrails::ParentPolicy.
125
+ @as_parent = as_parent
125
126
  @view = AttachedView.new(screen)
126
127
  @renderer = EventRenderer.new(@view)
127
128
  @shown_enqueued = Set.new
@@ -149,6 +150,10 @@ module Samagotchi
149
150
  # Prompts this run sent, by enqueued_id: only those come back into
150
151
  # the input when their turn fails (a replayed event must not).
151
152
  @sent_ids = Set.new
153
+ # This run's prompts queued for after the running turn (their ack
154
+ # said queue), oldest first, until their turn starts or they are
155
+ # removed: /unqueue takes back the newest.
156
+ @my_queued = []
152
157
  @wait_at_eof = wait_at_eof
153
158
  # Sent and not ended yet (by enqueued_id), the ones merged into the
154
159
  # running turn, whether the input has ended, and whether one of this
@@ -260,6 +265,7 @@ module Samagotchi
260
265
  take_session_state(event[:session_state_snapshot] || {})
261
266
  return render_snapshot(event[:snapshot] || {}, reset: event[:type] == :reset)
262
267
  when :turn_enqueued then show_enqueued(event)
268
+ when :turn_dequeued then turn_dequeued(event)
263
269
  when :turn_started
264
270
  @status.take_event(event)
265
271
  start_turn(event)
@@ -419,6 +425,8 @@ module Samagotchi
419
425
  show_stats
420
426
  elsif local == :recap
421
427
  show_recap
428
+ elsif local == :unqueue
429
+ unqueue_newest
422
430
  elsif route.kind == :command
423
431
  # The history keeps !cmds, as the REPL's does (prompts: #send_prompt).
424
432
  persist_recent_history(text) if PromptHistory.shell_line?(text)
@@ -584,7 +592,10 @@ module Samagotchi
584
592
  # The worker runs it and every UI renders its :command_ran, this one
585
593
  # too, so nothing waits here.
586
594
  def send_command(line)
587
- reply = @client.post_command(line: line, client_id: @client_id)
595
+ # A parent agent's /continue yes goes as chi answer's: the worker
596
+ # holds it to guardrails.parent_answers.
597
+ client_id = @as_parent && TurnFlow.continue_yes?(line) ? ClientId::CLI_ANSWER : @client_id
598
+ reply = @client.post_command(line: line, client_id: client_id)
588
599
  if reply.status == 202
589
600
  # Input from a pipe ends once its output is in (#command_ran).
590
601
  command_id = reply.json&.fetch("command_id", nil)
@@ -620,6 +631,58 @@ module Samagotchi
620
631
  QUEUE_RUNS_NOW = "(the turn had ended: runs as the next turn)"
621
632
  # /queue to a worker from before queueing, which would steer it.
622
633
  QUEUE_NEEDS_NEWER_WORKER = "(/queue needs a newer worker: restart it with chi sessions restart, then send again)"
634
+ UNQUEUE_NEEDS_NEWER_WORKER = "(/unqueue needs a newer worker: restart it with chi sessions restart)"
635
+ NOTHING_QUEUED = "(nothing queued: /queue TEXT queues a message for after the running turn)"
636
+ UNQUEUE_REFUSED = { "already_started" => "(not removed: it already started)",
637
+ "unknown_enqueued_id" => "(not removed: it is already gone)",
638
+ "not_queued" => "(not removed: it goes in at the next step)" }.freeze
639
+
640
+ # /unqueue: take back the newest prompt this run queued for after the
641
+ # turn, before it runs (POST turn/dequeue). Its :turn_dequeued comes
642
+ # to every UI; here the reply says what became of it. A worker whose
643
+ # sidecar lacks "unqueue" is not asked.
644
+ def unqueue_newest
645
+ return @screen.commit(UNQUEUE_NEEDS_NEWER_WORKER) unless worker_unqueues?
646
+
647
+ id = @my_queued.last
648
+ return @screen.commit(NOTHING_QUEUED) unless id
649
+
650
+ reply = @client.dequeue(enqueued_id: id, client_id: @client_id)
651
+ error = reply.json&.fetch("error", nil)
652
+ return @screen.commit(UNQUEUE_NEEDS_NEWER_WORKER) if reply.status == 404 && error == "not_found"
653
+
654
+ # Removed, started or gone: no longer this run's to take back.
655
+ @my_queued.delete(id) if [200, 404, 409].include?(reply.status)
656
+ return unqueued(reply.json&.fetch("prompt", nil).to_s) if reply.status == 200
657
+
658
+ @screen.commit(UNQUEUE_REFUSED[error] || "could not remove the queued message (#{[reply.status, error].compact.join(" ")})")
659
+ rescue SystemCallError, IOError => e
660
+ @screen.commit("could not remove the queued message (#{worker_down(e)})")
661
+ end
662
+
663
+ # The removal's line, and its text back in the input to change and
664
+ # send again (as a failed turn's prompt comes back, #restore_prompt):
665
+ # not over typed text, and not for lines from a pipe. Its images are
666
+ # not put back.
667
+ def unqueued(prompt)
668
+ @screen.commit("removed: #{first_line_clip(prompt)}")
669
+ return if @wait_at_eof || prompt.strip.empty?
670
+
671
+ @screen.commit("(the removed message is in the input history: ↑)") unless @reader&.prefill(prompt)
672
+ end
673
+
674
+ def first_line_clip(text, max = 60)
675
+ line = text.to_s.strip.lines.first.to_s.strip
676
+ line.length > max ? "#{line[0, max]}…" : line
677
+ end
678
+
679
+ # Whether the attached worker takes queued messages back (its sidecar
680
+ # names SessionInbox::UNQUEUE_FEATURE). No sidecar to read: true, its Bridge
681
+ # answers (an older one's 404 not_found says so).
682
+ def worker_unqueues?
683
+ sidecar = WorkerSidecar.read(Session.session_dir(@client.session_id, state_dir: @state_dir))
684
+ sidecar.nil? || sidecar.features.include?(SessionInbox::UNQUEUE_FEATURE)
685
+ end
623
686
 
624
687
  # A command queued for after the running turn (command_queued with
625
688
  # waits): its line now (another client's) and a note; its command_ran
@@ -760,6 +823,7 @@ module Samagotchi
760
823
  enqueued_id = reply.json&.fetch("enqueued_id", nil)
761
824
  @sent_ids << enqueued_id if enqueued_id
762
825
  @open_ids << enqueued_id if enqueued_id && @wait_at_eof
826
+ @my_queued << enqueued_id if enqueued_id && reply.json&.fetch("delivery", nil) == Delivery::QUEUE
763
827
  note = sent_note(reply.json, delivery: delivery, images: images, running: running)
764
828
  @screen.commit(note) if note
765
829
  return
@@ -929,7 +993,7 @@ module Samagotchi
929
993
  return @screen.commit(answer.error)
930
994
  end
931
995
 
932
- parent = @parent_answers ? { client_id: ClientId::CLI_ANSWER } : {}
996
+ parent = @as_parent ? { client_id: ClientId::CLI_ANSWER } : {}
933
997
  reply = @client.answer(id: @question.id, selected: answer.selected, freeform: answer.freeform, **parent)
934
998
  case reply.status
935
999
  when 200
@@ -1095,6 +1159,12 @@ module Samagotchi
1095
1159
 
1096
1160
  def render_snapshot(snapshot, reset:)
1097
1161
  @view.finish_thinking_spinner
1162
+ queued = Array(snapshot[:queued])
1163
+ known_queued = @my_queued.to_set
1164
+ @my_queued = queued.filter_map do |entry|
1165
+ id = entry[:enqueued_id]
1166
+ id if id && (known_queued.include?(id) || (entry.key?(:client_id) && own?(entry[:client_id])))
1167
+ end
1098
1168
  # A new worker's first snapshot is the session as it now stands.
1099
1169
  moved = @worker_changed
1100
1170
  reset ||= moved
@@ -1131,7 +1201,7 @@ module Samagotchi
1131
1201
  # A question between turns (the step-limit one) belongs to no turn.
1132
1202
  ask(snapshot[:pending_question]) if snapshot[:current_turn].nil? && snapshot[:pending_question]
1133
1203
  render_snapshot_cards(cards.select { |card| card[:current] }, joining: !reset)
1134
- Array(snapshot[:queued]).each do |entry|
1204
+ queued.each do |entry|
1135
1205
  next if own?(entry[:client_id])
1136
1206
 
1137
1207
  @shown_enqueued << entry[:enqueued_id]
@@ -1453,10 +1523,31 @@ module Samagotchi
1453
1523
  @screen.commit(event[:delivery] == Delivery::QUEUE ? "#{line} #{QUEUED_NOTE}" : line)
1454
1524
  end
1455
1525
 
1526
+ # A queued prompt was taken back before it ran (the web's ×, /unqueue
1527
+ # in a terminal). One of this run's (wait_at_eof) will have no turn:
1528
+ # it ends the wait, as a failed one does. Another client's shown
1529
+ # line says so.
1530
+ def turn_dequeued(event)
1531
+ id = event[:enqueued_id]
1532
+ @my_queued.delete(id)
1533
+ if @open_ids.delete?(id)
1534
+ if own?(event[:client_id])
1535
+ @own_empty = true
1536
+ @screen.commit("(removed before it ran)")
1537
+ else
1538
+ @own_failed = true
1539
+ @screen.commit("(your queued message was removed before it ran)")
1540
+ end
1541
+ elsif @shown_enqueued.delete?(id)
1542
+ @screen.commit("(a queued message was removed)")
1543
+ end
1544
+ end
1545
+
1456
1546
  def start_turn(event)
1457
1547
  @running = true
1458
1548
  @joined_mid_turn = false
1459
1549
  origin = event[:origin] || {}
1550
+ @my_queued.delete(origin[:enqueued_id])
1460
1551
  if event[:prompt].nil?
1461
1552
  # A continue turn (after the offer's yes) has no prompt to show; a
1462
1553
  # reminder turn shows its reminders (:reminder_injected).
@@ -1466,14 +1557,13 @@ module Samagotchi
1466
1557
  elsif !(own?(origin[:client_id]) || @shown_enqueued.include?(origin[:enqueued_id]))
1467
1558
  @screen.commit(prompt_line(origin[:client_id], event[:prompt]))
1468
1559
  end
1469
- # Another client's prompt line is up (here or at its enqueue); an
1470
- # own send has none, so its image lines are its echo.
1471
- @renderer.call(own?(origin[:client_id]) ? event : without_named_images(event, event[:prompt]))
1560
+ # The own-send echo already names an image-only prompt; another
1561
+ # client's prompt line does too, here or at its enqueue.
1562
+ @renderer.call(without_named_images(event, event[:prompt]))
1472
1563
  end
1473
1564
 
1474
- # The turn's start without its image lines when the prompt line shown
1475
- # for it is the web's "[image: name]" text of a turn of images alone:
1476
- # that line names them already.
1565
+ # The turn's start without its image lines when the shown prompt is
1566
+ # the image-only "[image: name]" placeholder: that line names them.
1477
1567
  def without_named_images(event, shown_prompt)
1478
1568
  ImageRef.placeholder_only?(shown_prompt) ? event.except(:images) : event
1479
1569
  end
@@ -11,9 +11,9 @@ module Samagotchi
11
11
  # ThinkingTicker (so it changes at most once per dwell). Both TUIs use it
12
12
  # in place of the raw tail of the stream.
13
13
  #
14
- # A chunk carries :text (and :thinking): both loops split their stream,
15
- # for every profile. A chunk without them (an older worker) has its raw
16
- # :content, markup stripped, stand for thinking. A switch of lane starts the next sentence from the new
14
+ # A chunk carries :text and :thinking: both loops split their stream,
15
+ # for every profile (the raw :content stays in the worker,
16
+ # Bridge::ClientEvent). A switch of lane starts the next sentence from the new
17
17
  # lane's text; the line shown keeps the label of the lane it came from.
18
18
  class ThinkingLine
19
19
  include Formatting
@@ -43,12 +43,8 @@ module Samagotchi
43
43
  # @return [Boolean] whether the line changed
44
44
  def chunk(event)
45
45
  # The Bridge's TurnAccumulator reads the lanes by the same rule.
46
- if event.key?(:text)
47
- changed = add(:thinking, event[:thinking])
48
- add(:writing, event[:text]) || changed
49
- else
50
- add(:thinking, event[:content], raw: true)
51
- end
46
+ changed = add(:thinking, event[:thinking])
47
+ add(:writing, event[:text]) || changed
52
48
  end
53
49
 
54
50
  # A turn joined mid-way: the newest thinking or text part so far.