samagotchi 0.86.0 → 0.88.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -1
  3. data/bin/chi +39 -2
  4. data/docs/architecture.md +5 -3
  5. data/docs/cli.md +18 -5
  6. data/docs/configuration.md +1 -1
  7. data/docs/guardrails.md +53 -3
  8. data/docs/hooks.md +1 -1
  9. data/docs/plugins.md +1 -1
  10. data/docs/sessions.md +19 -7
  11. data/docs/sub-agent.md +15 -3
  12. data/lib/samagotchi/answer_command.rb +6 -6
  13. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +3 -3
  14. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  16. data/lib/samagotchi/child_reports.rb +100 -7
  17. data/lib/samagotchi/cli/parent_wait.rb +3 -1
  18. data/lib/samagotchi/guardrails/parent_policy.rb +94 -5
  19. data/lib/samagotchi/parent_answers_command.rb +80 -0
  20. data/lib/samagotchi/parent_report.rb +53 -34
  21. data/lib/samagotchi/question_desk.rb +3 -2
  22. data/lib/samagotchi/relay.rb +34 -2
  23. data/lib/samagotchi/relay_desk.rb +13 -0
  24. data/lib/samagotchi/send_command.rb +75 -10
  25. data/lib/samagotchi/session.rb +26 -2
  26. data/lib/samagotchi/session_commands.rb +35 -9
  27. data/lib/samagotchi/session_setup.rb +10 -8
  28. data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -8
  29. data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -5
  30. data/lib/samagotchi/terminal_ui/question_prompt.rb +2 -1
  31. data/lib/samagotchi/terminal_ui.rb +12 -6
  32. data/lib/samagotchi/tools/delegate.rb +4 -1
  33. data/lib/samagotchi/tools/delegate_relay.rb +69 -15
  34. data/lib/samagotchi/tools/delegate_wait.rb +14 -8
  35. data/lib/samagotchi/version.rb +1 -1
  36. data/lib/samagotchi/web/public/notify.js +6 -2
  37. data/lib/samagotchi/web/public/question_card.js +2 -0
  38. data/lib/samagotchi/web/public/question_cards.js +25 -18
  39. data/lib/samagotchi/worker.rb +60 -10
  40. metadata +2 -1
@@ -134,7 +134,10 @@ module Samagotchi
134
134
  "(the most is #{max}, #{MAX_CHILDREN_KEY}): #{ids}. #{full_hint(peers)}"
135
135
  end
136
136
 
137
- setup = toolkit.empty? ? SessionSetup.new : SessionSetup.new(toolkit: Toolkit.new(name: toolkit))
137
+ # The parent's own parent_answers, as it is now (a snapshot): the
138
+ # child's answers are held at least as tight.
139
+ setup = SessionSetup.new(toolkit: toolkit.empty? ? nil : Toolkit.new(name: toolkit),
140
+ parent_answers: parent.parent_answers)
138
141
  child = SessionManager.spawn_session(prompt: task, working_directory: folder,
139
142
  model_name: child_model(model, parent), memories: CHILD_MEMORIES,
140
143
  parent_id: parent.id, delegate: true, setup: setup, state_dir: state_dir)
@@ -4,6 +4,7 @@ require_relative "../session"
4
4
  require_relative "../bridge_client"
5
5
  require_relative "../relay"
6
6
  require_relative "../guardrails/approval"
7
+ require_relative "../guardrails/parent_policy"
7
8
  require_relative "../log"
8
9
  require_relative "../question_answer"
9
10
 
@@ -35,10 +36,39 @@ module Samagotchi
35
36
 
36
37
  module_function
37
38
 
39
+ # Kinds a relay may carry.
40
+ HOOK_KIND = "hook"
41
+
38
42
  # @param relay [#open_question, #relay_desk] Peers#relay
43
+ # @param strategy [Guardrails::ParentPolicy::Strategy] the child's
44
+ # (ParentPolicy.for_session(child))
39
45
  # @return [Boolean] a relay can take this question
40
- def relayable?(relay, question)
41
- !relay.nil? && question.is_a?(PendingQuestion) && question.kind.to_s == Guardrails::Approval::KIND
46
+ # @param child_id [String, nil] the asking child: a hook's question
47
+ # already answered on one of this parent's relays
48
+ # (RelayDesk#settled?) isn't relayed again; an approval is (as before
49
+ # P4: a refused or undelivered answer is offered again)
50
+ def relayable?(relay, question, strategy:, child_id: nil)
51
+ return false if relay.nil? || !relay?(question, strategy: strategy)
52
+ return true unless child_id && question.kind.to_s == HOOK_KIND
53
+
54
+ !relay.relay_desk.settled?(child_id, question.id)
55
+ end
56
+
57
+ # Whether a delegate's question goes to the parent's user rather than
58
+ # its model: every approval, and a hook's question the parent can't
59
+ # usefully answer under the child's strategy
60
+ # (Guardrails::ParentPolicy.parent_decides?). The one place that says
61
+ # which kinds the relay carries: the wait (DelegateWait.call), the
62
+ # look at the other children (Others#waiting) and the delegate
63
+ # reports (ChildReports) all ask it.
64
+ def relay?(question, strategy:)
65
+ return false unless question.is_a?(PendingQuestion)
66
+
67
+ case question.kind.to_s
68
+ when Guardrails::Approval::KIND then true
69
+ when HOOK_KIND then !Guardrails::ParentPolicy.parent_decides?(question, strategy: strategy)
70
+ else false
71
+ end
42
72
  end
43
73
 
44
74
  # Relay the child's pending approval and wait until it settles.
@@ -79,10 +109,11 @@ module Samagotchi
79
109
 
80
110
  # Record the parent's answer, tell the child, and say what came of it.
81
111
  def settle(answer, fields, client:, desk:, relay_id:, qid:)
82
- what = what_text(fields[:approval])
112
+ what = fields[:kind] == HOOK_KIND ? hook_what(fields) : what_text(fields[:approval])
113
+ hook = fields[:kind] == HOOK_KIND
83
114
  if answer.is_a?(Hash) && answer[:error] == "cancelled"
84
115
  desk.close(relay_id)
85
- return closed(answer[:reason].to_s, what, client: client, relay_id: relay_id, qid: qid)
116
+ return closed(answer[:reason].to_s, what, client: client, relay_id: relay_id, qid: qid, hook: hook)
86
117
  end
87
118
 
88
119
  dismissed = !answer.is_a?(QuestionAnswer)
@@ -90,17 +121,18 @@ module Samagotchi
90
121
  desk.record(relay_id, selected_indices: dismissed ? [] : answer.selected_indices,
91
122
  freeform: dismissed ? nil : answer.freeform, dismissed: dismissed, by: by)
92
123
  response = post(client, "answered", relay_id, qid)
93
- Outcome.new(line: line(what, delivered(response, answer, fields, dismissed)), stopped: false)
124
+ Outcome.new(line: line(what, delivered(response, answer, fields, dismissed), hook: hook), stopped: false)
94
125
  end
95
126
 
96
- def closed(reason, what, client:, relay_id:, qid:)
127
+ def closed(reason, what, client:, relay_id:, qid:, hook: false)
97
128
  case reason
98
- when "answered_on_child" then Outcome.new(line: line(what, "answered on the child"), stopped: false)
99
- when "child_gone" then Outcome.new(line: line(what, "not delivered (the child's worker is gone)"), stopped: false)
129
+ when "answered_on_child" then Outcome.new(line: line(what, "answered on the child", hook: hook), stopped: false)
130
+ when "child_gone"
131
+ Outcome.new(line: line(what, "not delivered (the child's worker is gone)", hook: hook), stopped: false)
100
132
  else
101
133
  # A Stop: the child's question stays open there.
102
134
  post(client, "closed", relay_id, qid, reason: "stopped")
103
- Outcome.new(line: line(what, "still open (your turn was stopped)"), stopped: true)
135
+ Outcome.new(line: line(what, "still open (your turn was stopped)", hook: hook), stopped: true)
104
136
  end
105
137
  end
106
138
 
@@ -108,10 +140,16 @@ module Samagotchi
108
140
  def delivered(response, answer, fields, dismissed)
109
141
  return "not delivered (the child's worker is gone)" unless response
110
142
 
143
+ hook = fields[:kind] == HOOK_KIND
111
144
  case response.status
112
- when 200 then dismissed ? "dismissed (denied)" : verdict(answer, fields)
145
+ when 200
146
+ # A dismissed hook question: the hook decides what no answer means.
147
+ return dismissed ? "dismissed" : answer.selected.join(", ") if hook
148
+
149
+ dismissed ? "dismissed (denied)" : verdict(answer, fields)
113
150
  when 409 then "answered on the child"
114
- when 403 then "refused by the child (a parent agent may not allow it); it waits for the user there"
151
+ when 403
152
+ "refused by the child (a parent agent may not #{hook ? "pick it" : "allow it"}); it waits for the user there"
115
153
  else "not delivered (#{response.json&.fetch("error", nil) || response.status})"
116
154
  end
117
155
  end
@@ -134,7 +172,15 @@ module Samagotchi
134
172
  why.empty? ? "denied" : "denied (#{why.inspect})"
135
173
  end
136
174
 
137
- def line(what, outcome) = "approval relayed to your user: #{what} → #{outcome}"
175
+ def line(what, outcome, hook: false)
176
+ "#{hook ? "hook question" : "approval"} relayed to your user: #{what} → #{outcome}"
177
+ end
178
+
179
+ # A hook card's header as the child asked it (Cost cap), or its hook.
180
+ def hook_what(fields)
181
+ header = fields.dig(:relay, :header).to_s
182
+ header.empty? ? fields[:hook].to_s : header
183
+ end
138
184
 
139
185
  # execute: git push origin main
140
186
  def what_text(facts)
@@ -151,6 +197,9 @@ module Samagotchi
151
197
  # (DelegateWait.relayed_outcomes). Every question is relayed once per
152
198
  # wait.
153
199
  class Others
200
+ # The kinds of question a relay may carry (DelegateRelay.relay? decides).
201
+ KINDS = [Guardrails::Approval::KIND, HOOK_KIND].freeze
202
+
154
203
  # @param parent_id [String]
155
204
  # @param except [String] the child waited on (its own approvals come
156
205
  # through ReplyWait)
@@ -191,11 +240,16 @@ module Samagotchi
191
240
  # oldest question first
192
241
  def waiting
193
242
  rows = SessionManager.children_of(@parent_id, state_dir: @state_dir).select do |row|
194
- row[:id] != @except && row[:waiting] == Guardrails::Approval::KIND && !@done[row[:waiting_id].to_s]
243
+ row[:id] != @except && KINDS.include?(row[:waiting].to_s) && !@done[row[:waiting_id].to_s]
195
244
  end
196
245
  rows.filter_map do |row|
197
- pending = Session.load(row[:id], state_dir: @state_dir).pending_question
198
- [row[:id], pending] if pending && pending.id.to_s == row[:waiting_id].to_s
246
+ child = Session.load(row[:id], state_dir: @state_dir)
247
+ pending = child.pending_question
248
+ next nil unless pending && pending.id.to_s == row[:waiting_id].to_s
249
+ next nil unless DelegateRelay.relayable?(@relay, pending, strategy: Guardrails::ParentPolicy.for_session(child),
250
+ child_id: row[:id])
251
+
252
+ [row[:id], pending]
199
253
  rescue ArgumentError
200
254
  nil
201
255
  end.sort_by { |_, pending| pending.created_at.to_s }
@@ -82,7 +82,9 @@ module Samagotchi
82
82
  owner_grace: owner_grace, interject: others && -> { others.poll },
83
83
  turn: cursor.turn)
84
84
  left -= monotonic - started
85
- unless wait.status == :waiting_for_answer && DelegateRelay.relayable?(relay, wait.question)
85
+ unless wait.status == :waiting_for_answer &&
86
+ DelegateRelay.relayable?(relay, wait.question, strategy: Guardrails::ParentPolicy.for_session(wait.session),
87
+ child_id: child_id)
86
88
  outcomes.concat(relayed_outcomes.delete(key) || [])
87
89
  movement = advance(peers.session_id, child_id, wait, state_dir: sd)
88
90
  if movement&.already_reported
@@ -213,6 +215,10 @@ module Samagotchi
213
215
  # @return [String]
214
216
  def finish(wait, child_id, timeout:, report: false, reports: "off")
215
217
  status = ParentReport.status(wait)
218
+ # What the parent may pick on the child's question: config.yml
219
+ # tightened by the child session's own parent_answers (the session
220
+ # the wait read; none: config.yml's).
221
+ strategy = Guardrails::ParentPolicy.for_session(wait.session)
216
222
  case wait.status
217
223
  when :done
218
224
  reply_result(child_id, status, wait.text)
@@ -221,11 +227,11 @@ module Samagotchi
221
227
  when :stopped
222
228
  result(child_id, status, "the child was stopped (chi sessions stop); delegate with session: #{child_id} starts it again with a message")
223
229
  when :waiting_for_answer
224
- result(child_id, status, waiting_text(child_id, wait.question, report: report, reports: reports))
230
+ result(child_id, status, waiting_text(child_id, wait.question, report: report, reports: reports, strategy: strategy))
225
231
  when :canceled
226
232
  canceled_result(child_id, reports: reports)
227
233
  when :no_reply
228
- result(child_id, status, "#{no_reply_text(wait, child_id)}; its session shows what happened")
234
+ result(child_id, status, "#{no_reply_text(wait, child_id, strategy: strategy)}; its session shows what happened")
229
235
  when :worker_gone
230
236
  result(child_id, status,
231
237
  "the child's worker is gone (it stopped or crashed); delegate with session: #{child_id} starts it again with a message")
@@ -266,8 +272,8 @@ module Samagotchi
266
272
  end
267
273
 
268
274
  # ParentReport's line for a turn that left no reply, about the child.
269
- def no_reply_text(wait, child_id)
270
- ParentReport.no_reply_line(wait, child_id).sub(/\Athe turn/, "the child's turn")
275
+ def no_reply_text(wait, child_id, strategy: nil)
276
+ ParentReport.no_reply_line(wait, child_id, strategy: strategy).sub(/\Athe turn/, "the child's turn")
271
277
  end
272
278
 
273
279
  def result(child_id, status, text)
@@ -303,9 +309,9 @@ module Samagotchi
303
309
  # A report's last line differs (+report+): "waits again" made models
304
310
  # call delegate_result right after answering Continue.
305
311
  # The tool's last line follows #reports_mode.
306
- def waiting_text(child_id, pending, report: false, reports: "off")
307
- text = "Child #{child_id} is #{ParentReport.question_text(pending, session_id: child_id)}"
308
- if ParentReport.continue?(pending) && ParentReport.may_continue?(pending)
312
+ def waiting_text(child_id, pending, report: false, reports: "off", strategy: nil)
313
+ text = "Child #{child_id} is #{ParentReport.question_text(pending, session_id: child_id, strategy: strategy)}"
314
+ if ParentReport.continue?(pending) && ParentReport.may_continue?(pending, strategy: strategy)
309
315
  text += "The child ran out of steps before it answered. Decide: continue it (run the chi answer command " \
310
316
  "above with execute) if it is getting somewhere; send it a narrower follow-up with delegate " \
311
317
  "session: #{child_id} (that drops the question); or stop it and report back to your user.\n"
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.86.0"
4
+ VERSION = "0.88.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -44,8 +44,10 @@ export function waitingOn(summary) {
44
44
  // waiting on directly is seen by its look at the others (DelegateRelay
45
45
  // OTHERS_EVERY, 2 s), and the relay reaches the page with the hub's next
46
46
  // scan (SessionHub SCAN_INTERVAL, 1 s): up to ~3 s. Only these approvals
47
- // wait; a child's approval nobody relays rings this late.
47
+ // wait (and a child's hook question, which may be relayed too); one nobody
48
+ // relays rings this late.
48
49
  export const RELAY_HOLD_MS = 3500;
50
+ const RELAYED_KINDS = new Set(["approval", "hook"]);
49
51
 
50
52
  // What changed between two summaries of one session that needs the user.
51
53
  // @param prev the summary held before (null for a session not seen yet)
@@ -60,7 +62,9 @@ export function attentionFor(prev, next) {
60
62
  // Relayed to the parent already: the parent's card notifies.
61
63
  if (q.relayed_to) return null;
62
64
  const attention = { reason: questionReason(q), sessionId: next.id, key: `${next.id}:${q.id}` };
63
- return next.parent_id && q.kind === "approval" ? { ...attention, holdMs: RELAY_HOLD_MS } : attention;
65
+ // A hook's question may be relayed too (when the parent may only deny it);
66
+ // one the parent's model answers rings as late, which does no harm.
67
+ return next.parent_id && RELAYED_KINDS.has(q.kind) ? { ...attention, holdMs: RELAY_HOLD_MS } : attention;
64
68
  }
65
69
  const card = next.pending_card;
66
70
  if (card && card.id && card.id !== prev?.pending_card?.id) {
@@ -119,6 +119,8 @@ export function resultText(pq, { answer = null, cancelled = false, reason = "" }
119
119
  const relay = relayView(pq);
120
120
  if (cancelled && Object.hasOwn(CLOSED_PLAIN, reason)) return CLOSED_PLAIN[reason];
121
121
  if (cancelled && relay && reason !== "dismissed") return relayClosedText(relay, reason);
122
+ // A relayed hook question's dismissal: the hook decides what it means.
123
+ if (cancelled && relay && !approval) return "Dismissed";
122
124
  if (isContinue(pq)) return continueResultText({ answer, cancelled, reason });
123
125
  if (cancelled) {
124
126
  const label = approval ? "Denied" : "Cancelled";
@@ -17,7 +17,7 @@ import { diffTemplate } from "./diff_view.js";
17
17
  import { render } from "./vendor/lit-html.js";
18
18
  import {
19
19
  answerErrorText, approvalAllowed, approvalView, CONTINUE_REASON_PLACEHOLDER, deniedReason, isContinue, keepsAskingText,
20
- nextCardAction, relayedToView, resultText, summaryText,
20
+ nextCardAction, relayedToView, relayView, resultText, summaryText,
21
21
  } from "./question_card.js";
22
22
  import { sessionHash } from "./route.js";
23
23
  import { clientLabel, isOwn } from "./turn_events.js";
@@ -92,9 +92,12 @@ export function createQuestionCards({
92
92
  if (approval) {
93
93
  card.appendChild(renderApprovalDetails(approval));
94
94
  } else {
95
+ // A delegate's hook question relayed here: who asks, then its own text.
96
+ const relay = relayView(pq);
97
+ if (relay) card.appendChild(renderDelegateLine(relay));
95
98
  const q = doc.createElement("div");
96
99
  q.className = "question-text";
97
- q.textContent = pq.question || "";
100
+ q.textContent = (relay && pq.relay.asked) || pq.question || "";
98
101
  card.appendChild(q);
99
102
  }
100
103
 
@@ -273,28 +276,32 @@ export function createQuestionCards({
273
276
  card.appendChild(err);
274
277
  }
275
278
 
279
+ // "delegate ab12 · its task", the name linked to its session (a relayed
280
+ // approval's or hook question's; relayView).
281
+ function renderDelegateLine(delegate) {
282
+ const who = doc.createElement("div");
283
+ who.className = "approval-delegate";
284
+ const label = doc.createElement("span");
285
+ label.className = "approval-label";
286
+ label.textContent = "delegate ";
287
+ who.appendChild(label);
288
+ const name = doc.createElement("a");
289
+ name.className = "approval-delegate-link";
290
+ name.href = sessionHash(delegate.childId);
291
+ name.title = "Open the delegate's session";
292
+ name.textContent = delegate.who;
293
+ who.appendChild(name);
294
+ if (delegate.task) who.appendChild(doc.createTextNode(` · ${delegate.task}`));
295
+ return who;
296
+ }
297
+
276
298
  // The tool, the command (or paths, or args) as code, where and why; no
277
299
  // code box when there is nothing to show.
278
300
  function renderApprovalDetails(view) {
279
301
  const box = doc.createElement("div");
280
302
  box.className = "approval-details";
281
303
  // A delegate's approval: who asks, and its task.
282
- if (view.delegate) {
283
- const who = doc.createElement("div");
284
- who.className = "approval-delegate";
285
- const label = doc.createElement("span");
286
- label.className = "approval-label";
287
- label.textContent = "delegate ";
288
- who.appendChild(label);
289
- const name = doc.createElement("a");
290
- name.className = "approval-delegate-link";
291
- name.href = sessionHash(view.delegate.childId);
292
- name.title = "Open the delegate's session";
293
- name.textContent = view.delegate.who;
294
- who.appendChild(name);
295
- if (view.delegate.task) who.appendChild(doc.createTextNode(` · ${view.delegate.task}`));
296
- box.appendChild(who);
297
- }
304
+ if (view.delegate) box.appendChild(renderDelegateLine(view.delegate));
298
305
  const tool = doc.createElement("div");
299
306
  tool.className = "approval-tool";
300
307
  tool.textContent = view.tool;
@@ -5,6 +5,7 @@ require "fileutils"
5
5
  require_relative "client_id"
6
6
  require_relative "config"
7
7
  require_relative "engine"
8
+ require_relative "guardrails/parent_policy"
8
9
  require_relative "events"
9
10
  require_relative "session"
10
11
  require_relative "session_inbox"
@@ -308,6 +309,7 @@ module Samagotchi
308
309
  # What failed to load and what plugins showed as they loaded, now that
309
310
  # a UI can be there (a late one gets them in the snapshot).
310
311
  @engine.announce_load_events!
312
+ announce_session_warnings
311
313
  @idle_exit = WorkerIdleExit.new(
312
314
  engine: @engine, bridge: @bridge,
313
315
  timeout_minutes: @idle_exit_minutes || SessionManager.config_idle_exit_minutes,
@@ -468,6 +470,16 @@ module Samagotchi
468
470
  save_or_log(:interrupted_turn) { save_session }
469
471
  end
470
472
 
473
+ # A saved parent_answers that isn't a level reads as hard on every
474
+ # answer without a word; said once here, as the session loads.
475
+ def announce_session_warnings
476
+ value = @session.parent_answers
477
+ return unless Guardrails::ParentPolicy.unknown_level?(value)
478
+
479
+ @engine.announce({ type: :guardrail_warning, label: "parent_answers",
480
+ message: "parent_answers #{value.inspect} in session #{@session_id} isn't hard/standard/soft; hard applies" })
481
+ end
482
+
471
483
  # A question saved by a worker that died while it waited (an
472
484
  # ask_user_question, an approval, a hook's question): the turn that
473
485
  # asked is gone, so nobody can answer it, and left in the file it
@@ -600,7 +612,9 @@ module Samagotchi
600
612
  budget_notice if state == :budget
601
613
  return false unless state == :due
602
614
 
603
- reports, = take_child_reports
615
+ # Unresolved: a relay among them opens its card at the turn's first
616
+ # boundary, on the turn thread (a Stop closes it).
617
+ reports, = take_child_reports(resolve: false)
604
618
  # The turn must not generate with nothing to say: the drain gets this
605
619
  # exact list at its first boundary.
606
620
  return false if reports.empty?
@@ -794,7 +808,9 @@ module Samagotchi
794
808
  # as its output
795
809
  def run_command_line(command)
796
810
  @engine.holding_announcements do
797
- command_result(command) { @commands.run(command.line, confirm: confirm?(command), as_parent: as_parent?(command)) }
811
+ command_result(command) do
812
+ @commands.run(command.line, confirm: confirm?(command), as_parent: as_parent?(command), user_ui: user_ui?(command))
813
+ end
798
814
  end
799
815
  end
800
816
 
@@ -808,6 +824,12 @@ module Samagotchi
808
824
  # guardrails.parent_answers (SessionCommands#continue_answer).
809
825
  def as_parent?(command) = command.client_id.to_s == ClientId::CLI_ANSWER
810
826
 
827
+ # Whether +command+ is a user's at a UI (a web tab, a chi TUI, a card's
828
+ # click), not a parent agent's: only such a line loosens
829
+ # /parent-answers. chi send, a delegate's follow-up, a plugin and an
830
+ # input with no client never do (ParentAnswersCommand).
831
+ def user_ui?(command) = ClientId.interactive?(command.client_id) && !as_parent?(command)
832
+
811
833
  # The block's Result for +command+; one that raised, or found no
812
834
  # command for the line, is an error Result saying so.
813
835
  # @return [SessionCommands::Result]
@@ -1003,19 +1025,26 @@ module Samagotchi
1003
1025
  end
1004
1026
 
1005
1027
  # The Engine published a question (after the question desk saved it).
1006
- # In a turn that reports to the parent, the model's own question and
1007
- # the step-limit continue ring it; approvals and hooks' questions are
1008
- # the user's, on this session's card.
1028
+ # In a turn that reports to the parent, the model's own question, the
1029
+ # step-limit continue and a hook's question ring it (the parent reports
1030
+ # or relays a hook's: ChildReports). Approvals don't: the relay takes
1031
+ # them while the parent waits. Nor does a card relayed from this
1032
+ # session's own child (relay set): the hop is the parent's wait.
1009
1033
  def ring_question(event)
1010
1034
  return unless @reporting_turn
1011
1035
 
1012
- kind = event.dig(:pending_question, :kind).to_s
1013
- return unless ["", ContinueOffer::KIND].include?(kind)
1036
+ pending = event[:pending_question] || {}
1037
+ kind = pending[:kind].to_s
1038
+ return unless RINGING_KINDS.include?(kind)
1039
+ return if pending[:relay]
1014
1040
 
1015
1041
  ChildRing.ring(@session, why: "question", state_dir: @state_dir)
1016
1042
  @asked_parent = true
1017
1043
  end
1018
1044
 
1045
+ # The kinds of question that ring a delegate child's parent (#ring_question).
1046
+ RINGING_KINDS = ["", ContinueOffer::KIND, "hook"].freeze
1047
+
1019
1048
  # WorkerInbound.save_or_log: a failed save between turns is logged.
1020
1049
  def save_or_log(at, &) = WorkerInbound.save_or_log(at, &)
1021
1050
 
@@ -1105,17 +1134,37 @@ module Samagotchi
1105
1134
  # the turn as its own).
1106
1135
  # @return [Array(Array<ChildReports::Report>, Hash|nil)] the reports
1107
1136
  # and the mark of their message
1108
- def take_child_reports
1137
+ # A report whose question goes to the parent's user is relayed here
1138
+ # (ChildReports#resolve), before the boundary announces or merges
1139
+ # anything; one whose card a Stop closed is held back until a human's
1140
+ # input (no wake turn re-offers it at once; other wakes go on).
1141
+ # +resolve+ false: a wake turn's take before the turn starts.
1142
+ def take_child_reports(resolve: true)
1109
1143
  if (reports = @wakes.take_handed)
1110
- return [reports, { turn_start: true, turn_id: @turn_id }.compact]
1144
+ return [resolve_relays(reports), { turn_start: true, turn_id: @turn_id }.compact]
1111
1145
  end
1112
1146
 
1113
- [@child_reports.take, nil]
1147
+ taken = @child_reports.take
1148
+ [resolve ? resolve_relays(taken) : taken, nil]
1114
1149
  rescue StandardError => e
1115
1150
  Log.warn(:worker, "delegate_reports_failed", error: e.class.name, msg: e.message)
1116
1151
  [[], nil]
1117
1152
  end
1118
1153
 
1154
+ def resolve_relays(reports)
1155
+ return reports if reports.none?(&:relay)
1156
+
1157
+ relay = @engine.relay_peer
1158
+ stopped = false
1159
+ reports.filter_map do |report|
1160
+ next report unless report.relay
1161
+ # The turn stopped on an earlier card: no more cards in it.
1162
+ next @child_reports.hold_back(report) if stopped
1163
+
1164
+ @child_reports.resolve(report, relay: relay).tap { |resolved| stopped = resolved.nil? }
1165
+ end
1166
+ end
1167
+
1119
1168
  # Check again with the event log held, which the Bridge holds while it
1120
1169
  # queues a POST /turn, then close the Bridge's intake so no client can
1121
1170
  # queue one after the check, and stop the idle jobs (reminder callback,
@@ -1237,6 +1286,7 @@ module Samagotchi
1237
1286
 
1238
1287
  ArchiveStore.user_input(@session_id, state_dir: @state_dir)
1239
1288
  @wakes.human_input!(interactive: ClientId.interactive?(client_id))
1289
+ @child_reports&.release_holds
1240
1290
  end
1241
1291
 
1242
1292
  def stopped_on_disk?
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.86.0
4
+ version: 0.88.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -422,6 +422,7 @@ files:
422
422
  - lib/samagotchi/note_delivery.rb
423
423
  - lib/samagotchi/output_formatter.rb
424
424
  - lib/samagotchi/owner_lock.rb
425
+ - lib/samagotchi/parent_answers_command.rb
425
426
  - lib/samagotchi/parent_report.rb
426
427
  - lib/samagotchi/paths.rb
427
428
  - lib/samagotchi/peer_sources.rb