samagotchi 0.87.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1bfd7070b1c55cbb0e0647a79ec729996a78318ca2b2f4ccbf19c24ce7240eb9
4
- data.tar.gz: 5fa4d754f3c9bb79767978d3b9fa6ac1c7091c84897a9043f3ad3129bd98c24c
3
+ metadata.gz: f395ab65a9e00861ae1dda52090efbbe363946df8098702f14c41cdc665f7bb3
4
+ data.tar.gz: 9f22902c72237fd51481b78f1cc91197fdc474e7f5b816e0a4429d354eb5c0aa
5
5
  SHA512:
6
- metadata.gz: 787b7debd71f8a408ffaa801bd7d352de1f3769d018959b35e3933b16641efb194220630482d14f5ec32d4c50b5fce8f56c8a78b88a1fc1c2de58c2e05e6b361
7
- data.tar.gz: 5f3759bd7c6e1e9fea4e5b7af8eab1c107d5296f9eba85b0b99fd42381c5a1c3dbb01b7efb86b52ec14b11958333110a97cfab80b5d8fd5a3a1489cab2f50bdd
6
+ metadata.gz: 2db614ca70a821eb84905eef2d8a8fead4ac4c95b9521cb32417e034fd9f6c923dd97974f25aa2a401d3131fa479ac1911bc8e8f41aa1f383b4e34f925c51078
7
+ data.tar.gz: 0a2625e7ce2e7022628f5a1e14c6277eeab21caf21ae4968e908cfe68735be5c39dea2f638137f96b63a0d0ed0796f60bc7c1d3a4e71db87dc453716012957cc
data/CHANGELOG.md CHANGED
@@ -8,6 +8,20 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.88.0] - 2026-10-19
12
+
13
+ ### Added
14
+
15
+ - A `delegate` child's hook question (the cost cap's Continue/Stop, a bundle's `ctx.ask_user`) now reaches its parent,
16
+ in the background too: reported to the parent's model when it may pick something other than a deny under the
17
+ child's `parent_answers` (so under the default `soft` a background parent may Continue its child's cost cap), else
18
+ relayed to the parent's user as a card, as approvals are.
19
+
20
+ ### Fixed
21
+
22
+ - A session file whose `parent_answers` isn't `hard`, `standard` or `soft` (a hand edit) now gets one warning when
23
+ its worker loads it, instead of being read as `hard` silently.
24
+
11
25
  ## [0.87.0] - 2026-10-19
12
26
 
13
27
  ### Added
@@ -3481,7 +3495,8 @@ and long-lived sessions.
3481
3495
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
3482
3496
  hotkey panel that send selected text or the clipboard to your sessions.
3483
3497
 
3484
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.87.0...HEAD
3498
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.88.0...HEAD
3499
+ [0.88.0]: https://github.com/dm1try/samagotchi/compare/v0.87.0...v0.88.0
3485
3500
  [0.87.0]: https://github.com/dm1try/samagotchi/compare/v0.86.0...v0.87.0
3486
3501
  [0.86.0]: https://github.com/dm1try/samagotchi/compare/v0.85.0...v0.86.0
3487
3502
  [0.85.0]: https://github.com/dm1try/samagotchi/compare/v0.84.0...v0.85.0
data/docs/cli.md CHANGED
@@ -491,9 +491,9 @@ file and index line stay as they are; only this session doesn't see it.
491
491
  - Names are checked before the launch, warnings only: `Warning: --memory 'x'
492
492
  not found`, `Warning: --mute 'x' matches no memory`, `Warning: 'x' is both
493
493
  --memory and --mute; muted`.
494
- - `--attach ID` or `--resume ID` with either flag: the session's prompt is
495
- already built, so the flags are ignored with one line,
496
- `(--mute applies to a new session; <id>'s prompt is already built)`.
494
+ - `--attach ID` or `--resume ID` with either flag: the session keeps its
495
+ own, so the flags are ignored with one line,
496
+ `(--mute applies to a new session; <id> keeps its own)`.
497
497
  - The status row shows `mem: <used and preloaded>` and `muted: <names>`; the
498
498
  Web UI's info-bar tooltip shows `memories: … · preloaded: … · muted: …`.
499
499
  - The `read` tool on `memories/<name>.md` is not refused (a guardrails rule
data/docs/guardrails.md CHANGED
@@ -103,7 +103,12 @@ The file tools can't write the store.
103
103
  ### Answers from a parent agent
104
104
 
105
105
  A chi parent's `delegate` children don't need any of this for approvals: they
106
- go to the user on the parent's own card (the approval relay). This section is
106
+ go to the user on the parent's own card (the approval relay), as do the hook
107
+ questions (the cost cap's Continue/Stop, a bundle's `ctx.ask_user`) where the
108
+ parent may pick nothing but a deny under the child's `parent_answers`; the
109
+ others come back to the parent's model with the block below. A dismissal
110
+ always passes (the hook decides what no answer means): the strategy is a
111
+ convention for an honest parent, not a barrier. This section is
107
112
  the path for other agents, and for a chi parent that can't host the relay (a
108
113
  `--no-shared` REPL, `--non-interactive`): there the model gets the question
109
114
  block and the rules below. With the relay, the same rules still hold for a
data/docs/sessions.md CHANGED
@@ -278,13 +278,13 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
278
278
  - A wait that timed out or was canceled still gets its delegate report: neither moves the parent's cursor, so the child's reply comes as a report when its turn ends. The result says so (`chi brings its reply here by itself as a delegate report …`; with `queue`, that it joins the next step or turn) and that `delegate_result` waits again only if the turn needs the reply now. A parent that gets no reports (`session.delegate_reports: off`, a `--no-shared` REPL, `--non-interactive`) is told `delegate_result <id> waits again` instead, and `wait: false`'s result says `delegate_result waits for its reply`.
279
279
  - What a wait returns, `session: <id>` then `status:` and the text, with the status words `chi send --wait --format json` uses: `answered` with the reply after a `---` line; `question` as soon as the child opens a question (the model's own, or a hook's), with the question's text, options and the `chi answer` command for it (nothing is answered for it: `chi answer`, attach or use the web, then wait again); `failed`, `canceled` or `no_answer` when the child's turn ended with nothing to say (its session shows what happened); `error` (its worker crashed) or `stopped` (`chi sessions stop`) at once; `worker_gone` when the child has had no worker for 15 seconds (it crashed without saying so, or idle-exited): `delegate` with `session:` starts it again; `running` on timeout, or when the parent's own turn is canceled (the child keeps running; the next turn can wait again). A reply saved before the child's worker idle-exited is returned at once.
280
280
  - **A child's step-limit question goes back to the parent model**, not to its user: `delegate_result` returns `status: question` with the `chi answer` commands for Continue and Stop, and says the model may instead send a narrower follow-up (`delegate session:`, which drops the question) or report back. A parent's `chi answer` follows `guardrails.parent_answers`, or the child's own when stricter (`hard`: Stop only; see [chi as a sub-agent](sub-agent.md#the-step-limit)).
281
- - **A child at its cost cap** (the [cost-cap bundle](plugins.md#the-cost-cap-bundle), the child's own cap: `child:` in its settings, or `/cap`) asks a `hook` question, "Cost cap": a parent waiting on it (`delegate`, `delegate_result`) gets it back as `status: question` with the `chi answer` commands, `--option Continue` or `--option Stop` (under `guardrails.parent_answers: hard`, config.yml's or the child's own, a parent's Continue is refused: Stop only). A **background** child (`wait: false`) paused at its cap does not wake its parent: delegate reports skip hook questions (they are the user's, on the child's card) and the approval relay carries approvals only, so the child waits on its card, with the web's bell. The parent sees it at its next `delegate_result`.
282
- - **A child's approvals go to the parent's user** (the approval relay). While the parent waits (`delegate`, `delegate_result`), a guardrail approval the child opens reopens as the parent session's own approval: the same card in the parent's web page and attached terminal, with the same facts and diff, a line naming the delegate and its task (`delegate ab12 · fix the flaky spec…`, linked to its session) and the header `Approve delegate ab12's tool call?`. The options are the child's, by index; "for the session" says it is the delegate's session (the session scope keys on the child; repo and rule approvals are one shared store). The user answers once there; the parent's model never sees the approval, only one line per relayed approval in the tool's result, before the reply: `approval relayed to your user: execute: git push origin main → allowed once` (or `denied ("why")`, `dismissed (denied)`, `answered on the child`, `not delivered (the child's worker is gone)`, `refused by the child …`, `still open (your turn was stopped)`). The time the card is open doesn't count against `timeout`. While the parent waits on one child, its other running children's approvals are relayed too, oldest first, one card at a time (`+1 more delegate waiting`); each outcome comes in that child's next result.
281
+ - **A child at its cost cap** (the [cost-cap bundle](plugins.md#the-cost-cap-bundle), the child's own cap: `child:` in its settings, or `/cap`) asks a `hook` question, "Cost cap", `Continue` or `Stop`. Like every hook's question on a `delegate` child, it goes where it can be decided, under the child's `guardrails.parent_answers` (config.yml's, or the child's own when stricter): when the parent may pick an option other than a deny (Stop) there, `standard` and `soft` here, it comes back to the parent's model as `status: question` with the `chi answer` commands; when it may not, `hard` here (a parent's Continue is refused), it goes to the parent's user as a card, as approvals do (the relay below), and the model sees one line: `hook question relayed to your user: Cost cap → Stop` (or `dismissed`, which the hook reads as it likes, cost-cap as Stop; `refused by the child …`, `answered on the child`, `still open (your turn was stopped)`). Free text alone doesn't count as an option the parent may pick. A **background** child (`wait: false`) paused at its cap rings its parent as a question does (delegate reports, below): the parent's model gets it as a report when it may answer; otherwise the parent's worker relays it to its user at its next step boundary, or in a wake turn's first boundary when idle (before the model is called), and the report the model gets is the outcome line. While that card is open the parent's turn waits on it; a Stop closes it and leaves the question for your next input (or a `delegate_result`): no wake turn offers that question again meanwhile, while other children's reports and attached-context wakes go on. Under the default `soft` that means a background parent's model now hears its child's cost cap, and may Continue it.
282
+ - **A child's approvals go to the parent's user** (the approval relay), and so do the hook questions its parent can't usefully answer (above: a card headed `delegate ab12 asks: Cost cap`, the child's options and kind, no approval facts). While the parent waits (`delegate`, `delegate_result`), a guardrail approval the child opens reopens as the parent session's own approval: the same card in the parent's web page and attached terminal, with the same facts and diff, a line naming the delegate and its task (`delegate ab12 · fix the flaky spec…`, linked to its session) and the header `Approve delegate ab12's tool call?`. The options are the child's, by index; "for the session" says it is the delegate's session (the session scope keys on the child; repo and rule approvals are one shared store). The user answers once there; the parent's model never sees the approval, only one line per relayed approval in the tool's result, before the reply: `approval relayed to your user: execute: git push origin main → allowed once` (or `denied ("why")`, `dismissed (denied)`, `answered on the child`, `not delivered (the child's worker is gone)`, `refused by the child …`, `still open (your turn was stopped)`). The time the card is open doesn't count against `timeout`. While the parent waits on one child, its other running children's approvals are relayed too, oldest first, one card at a time (`+1 more delegate waiting`); each outcome comes in that child's next result.
283
283
  - The child's own card stays answerable (the first answer wins: answered there, the parent's card closes as "answered in ab12"). While relayed it says `Waiting for approval in parent ab12: answering here works too`; `chi sessions list` shows the child as `waiting (in parent ab12)` (`--format json`: `relayed_to`), and the web's bell rings for the parent's card, not the child's.
284
284
  - How the child trusts it: the parent's POST that says "answered" (Bridge `POST /session/<child>/relay`) carries no answer. The child asks its parent session's live Bridge (`POST /session/<parent>/relay/status`), which reports only what the parent's question flow took from a UI (`RelayDesk`, in memory only), and takes it only for this child, the question pending now, answered. A parent agent's answer on the relay card (a piped or marked `chi --attach PARENT`, `chi answer`) is held to `guardrails.parent_answers` (each session's own, when stricter) by the parent and again by the child.
285
285
  - A Stop of the parent's turn closes its card; the child's approval stays open there. A child's worker that goes closes the card (`… worker is gone`); a parent's worker that goes clears the child's "waiting in parent" line within 2 s. A later `delegate_result` relays it again.
286
- - No relay where the parent can't host one (a `--no-shared` REPL, `--non-interactive`) or isn't waiting (`wait: false` until the next `delegate_result`): the approval waits on the child, with the web's bell, and the result's question block says to deny it or leave it for the user. The model's own questions and hooks' questions come back to the model as before. A grandchild's (a plugin's `ctx.sessions.fork` can make one) is relayed hop by hop: `delegate ab12 → cd34 asks:`.
287
- - **Delegate reports: a child's reply comes back by itself.** When a child ends a turn that was its parent's (its task, a follow-up, a continue the parent answered, a reminder: not a turn its user typed into it directly), it rings the parent: a small file in the parent's `children/` dir, written after the child's turn is saved. So does a question the parent's model decides (the child's own `ask_user_question`, the step-limit continue; not an approval or a hook's question, which the user answers on the child's card), the turn after such a question whoever starts it, and the child's worker crashing. The parent reads the child as `delegate_result` would and hands its model a **report**: the same text (`session:`, `status:`, the reply), under its own header (`[Delegate report, delivered by chi …; not your user's message. Tell your user what it found …]`), as an input message with `source: "delegate_report"`.
286
+ - No relay where the parent can't host one (a `--no-shared` REPL, `--non-interactive`) or, for an approval, isn't waiting (`wait: false` until the next `delegate_result`; a hook's question it may only deny is relayed from a delegate report instead, above): the approval waits on the child, with the web's bell, and the result's question block says to deny it or leave it for the user. The model's own questions, and the hooks' questions the parent may answer, come back to the model as before. A hook's question already answered on one of the parent's relay cards (a refused answer: it waits for the user on the child) is not relayed again; one whose card closed (a Stop, the child gone) is, and an approval always is, as before. A grandchild's (a plugin's `ctx.sessions.fork` can make one) is relayed hop by hop: `delegate ab12 → cd34 asks:`.
287
+ - **Delegate reports: a child's reply comes back by itself.** When a child ends a turn that was its parent's (its task, a follow-up, a continue the parent answered, a reminder: not a turn its user typed into it directly), it rings the parent: a small file in the parent's `children/` dir, written after the child's turn is saved. So does a question the parent decides (the child's own `ask_user_question`, the step-limit continue, a hook's question, relayed to the parent's user first when the parent may only deny it; not an approval, which the relay or the user on the child's card answers, nor a card the child relays from its own child), the turn after such a question whoever starts it, and the child's worker crashing. The parent reads the child as `delegate_result` would and hands its model a **report**: the same text (`session:`, `status:`, the reply), under its own header (`[Delegate report, delivered by chi …; not your user's message. Tell your user what it found …]`), as an input message with `source: "delegate_report"`.
288
288
  - Parent mid-turn: the report joins the turn at its next iteration boundary (a "delegate report" bubble in the web, `delegate report> ab12cd34 answered: …` in the attached terminal).
289
289
  - Parent idle: it runs a turn for the report (a *wake turn*, `last_turn.origin: "delegate_report"`) within the worker's 5 s tick, or wakes a worker for it when the parent's has idle-exited. Several reports that wait together are one turn and one message. A stopped, archived or scratch parent, or one open in a `--no-shared` REPL, is not woken: the rings wait on disk and join the next turn it runs (the user's message first). An archived parent that a later link continues wakes that link instead, which takes the rings its moved delegates left there (see *Session chains*). A worker just started (`chi send` to a stopped parent) runs no wake turn in its first 2 s, so the message it was started for goes first.
290
290
  - The parent's cursor per child (the newest reply it was given and the child as it was then) is in its `delegates.json`, shared with `delegate_result` and `wait: true`: a reply the model already had is never reported again, also after the parent's worker restarts. A ring is deleted only when the turn that took its report stays in the conversation (completed, canceled, or failed but kept: one that got to tool steps, or a reminder or continue turn, which keep what they read); a turn rolled back (a failed one that got nowhere, a canceled continue turn) leaves it for the next one (and the report isn't handed back to the composer). So the model reads each report once, unless a later `!rollback` erases a kept turn (what it read goes with it).
data/docs/sub-agent.md CHANGED
@@ -13,6 +13,15 @@ worker keeps the turn paused. The parent answers with `chi answer`, from what it
13
13
  knows or after asking its own user, and chi goes on from there. The model gets
14
14
  the answer as its tool result, as if you had answered in the web.
15
15
 
16
+ A chi parent's own `delegate` children work the same way, with one shortcut:
17
+ their approvals, and the hook questions the parent may only deny under the
18
+ child's `guardrails.parent_answers`, go to the parent's user as a card in the
19
+ parent's UIs instead (the relay, [Sessions](sessions.md#delegating)). When an
20
+ agent runs that parent with `chi send --wait`, the relay card is the parent's
21
+ own question: exit 3 with its text (`delegate ab12 … asks:`), options and
22
+ `answer_with`; the JSON has no `relay` key. Its `chi answer` is held to the
23
+ parent's setting, then to the child's.
24
+
16
25
  The other direction, chi sending a part of its work to a Claude Code session
17
26
  you already work with (to validate, as context, or with a question), is the
18
27
  [claude-peer bundle](plugins.md#the-claude-peer-bundle): Claude pairs with the
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.87.0
3
+ version: 0.88.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -130,7 +130,13 @@ module Samagotchi
130
130
  # (DelegateWait.finish, its report variant), the cursor it moves the child to,
131
131
  # and the cursor it was read from (a commit after the model moved it
132
132
  # itself, with delegate_result, leaves it alone).
133
- Report = Data.define(:child_id, :text, :before, :after, :rings, :claim_id) do
133
+ # +relay+: the child's question when it goes to the parent's user
134
+ # (Tools::DelegateRelay.relay?): #resolve relays it on the turn thread
135
+ # and gives the report its outcome line; +text+ is the question's
136
+ # report until then (what a parent with no relay gets).
137
+ Report = Data.define(:child_id, :text, :before, :after, :rings, :claim_id, :relay) do
138
+ def initialize(relay: nil, **) = super
139
+
134
140
  def line(mark: nil) = Steer::Line.new(text: text, source: ChildReports::SOURCE, mark: mark)
135
141
  def origin = { client_id: "#{ClientId::CHILD_PREFIX}#{child_id[0, 8]}" }
136
142
  end
@@ -146,14 +152,19 @@ module Samagotchi
146
152
  Tools::DelegateCursors.clear_report_claims(@session_id, state_dir: @state_dir)
147
153
  # child id → the Report this turn took for it (rings merged).
148
154
  @taken = {}
155
+ # child id → the question id whose relay card a Stop closed (#hold_back).
156
+ @held = {}
149
157
  end
150
158
 
159
+ # The user's next input: questions held back after a Stop are offered again.
160
+ def release_holds = @held.clear
161
+
151
162
  # Whether a ring this turn hasn't taken waits (the idle loop's check).
152
163
  def waiting?
153
164
  return false if ChildRing.mode == "off"
154
165
 
155
166
  taken = taken_rings
156
- ring_files.any? { |file| !taken.include?(file) }
167
+ ring_files.any? { |file| !taken.include?(file) && !held?(file) }
157
168
  end
158
169
 
159
170
  # The reports for rings this turn hasn't taken yet, recorded as taken.
@@ -164,7 +175,7 @@ module Samagotchi
164
175
  return [] if ChildRing.mode == "off"
165
176
 
166
177
  taken = taken_rings
167
- fresh = ring_files.reject { |file| taken.include?(file) }
178
+ fresh = ring_files.reject { |file| taken.include?(file) || held?(file) }
168
179
  by_child = fresh.group_by { |file| SessionInbox.read_ring(file)&.dig(:child_id) }
169
180
  by_child.filter_map do |child_id, rings|
170
181
  report = child_id && report_for(child_id, rings)
@@ -207,8 +218,79 @@ module Samagotchi
207
218
  @taken = {}
208
219
  end
209
220
 
221
+ # A report whose question goes to the parent's user, settled: relayed
222
+ # through +relay+ (DelegateRelay.call: blocks until the card is
223
+ # answered, closed or the turn stopped) and given the outcome line as
224
+ # its text. Runs on the turn thread, before the boundary merges it.
225
+ # The child's question answered since the ring was taken opens no card;
226
+ # nor does one already answered on a relay (RelayDesk#settled?).
227
+ # @param relay [#open_question, #relay_desk, nil] Engine#relay_peer;
228
+ # nil: the report's question text, as without P4's relay
229
+ # @return [Report, nil] nil when the parent's turn was stopped with the
230
+ # card open: the report is released (its ring and the cursor stay), so
231
+ # the next turn offers it again
232
+ def resolve(report, relay:)
233
+ return report unless report.relay
234
+ return report.with(relay: nil) unless relay
235
+
236
+ question = report.relay
237
+ what = question.header.to_s.empty? ? question.hook.to_s : question.header.to_s
238
+ line = if Session.load(report.child_id, state_dir: @state_dir).pending_question&.id.to_s != question.id.to_s
239
+ Tools::DelegateRelay.line(what, "answered on the child", hook: true)
240
+ elsif relay.relay_desk.settled?(report.child_id, question.id)
241
+ Tools::DelegateRelay.line(what, "answered on a relay already; it waits for the user on the child", hook: true)
242
+ else
243
+ outcome = Tools::DelegateRelay.call(report.child_id, question, relay: relay, state_dir: @state_dir)
244
+ return hold_back(report) if outcome.stopped
245
+
246
+ outcome.line
247
+ end
248
+ resolved = report.with(relay: nil, text: relayed_text(report.child_id, line))
249
+ @taken[report.child_id] = resolved if @taken[report.child_id]&.claim_id == report.claim_id
250
+ resolved
251
+ rescue ArgumentError => e
252
+ # The child is gone (or a bug): its question's report, as without a relay.
253
+ Log.info(:worker, "delegate_relay_unresolved", child: report.child_id[0, 8], error: e.message)
254
+ report.with(relay: nil)
255
+ end
256
+
257
+ # A relay report the parent's turn stopped on, or one behind it in the
258
+ # same take: released (its ring and the cursor stay) and held back, so
259
+ # no wake turn offers that question again until the user's next input
260
+ # (#release_holds); other children's rings go on.
261
+ # @return [nil]
262
+ def hold_back(report)
263
+ @held[report.child_id] = report.relay.id.to_s
264
+ release_report(report)
265
+ end
266
+
210
267
  private
211
268
 
269
+ # A ring of a child whose held-back question still waits.
270
+ def held?(file)
271
+ child_id = SessionInbox.read_ring(file)&.dig(:child_id)
272
+ question_id = @held[child_id] or return false
273
+ Session.load(child_id, state_dir: @state_dir).pending_question&.id.to_s == question_id
274
+ rescue ArgumentError
275
+ false
276
+ end
277
+
278
+ # A relayed question's report: the outcome line, then the next reply's
279
+ # promise (as a report's question text ends).
280
+ def relayed_text(child_id, line)
281
+ Tools::DelegateWait.result(child_id, "question", "#{line}\n#{Tools::DelegateWait::REPORT_NEXT_REPLY}")
282
+ end
283
+
284
+ # One taken report back: its cursor claim goes, its rings stay untaken.
285
+ # @return [nil]
286
+ def release_report(report)
287
+ @taken.delete(report.child_id)
288
+ Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
289
+ cursor.report_claim&.fetch("id", nil) == report.claim_id ? cursor.with(report_claim: nil) : cursor
290
+ end
291
+ nil
292
+ end
293
+
212
294
  def taken_rings
213
295
  @taken.values.flat_map(&:rings)
214
296
  end
@@ -260,22 +342,33 @@ module Samagotchi
260
342
  # a later wait on it would repeat its reply.
261
343
  after = after.with(report_claim: claim)
262
344
  text = Tools::DelegateWait.finish(wait, child_id, timeout: 0, report: true)
263
- Report.new(child_id: child_id, text: text, before: before, after: after, rings: rings, claim_id: claim_id)
345
+ Report.new(child_id: child_id, text: text, before: before, after: after, rings: rings, claim_id: claim_id,
346
+ relay: relay?(wait) ? wait.question : nil)
264
347
  rescue ArgumentError
265
348
  nil # the child is gone
266
349
  end
267
350
 
268
351
  # A reply, a turn that left none, a crash, or a question the parent's
269
- # model decides (its own ask_user_question, the step-limit continue).
270
- # Approvals and hooks' questions are the user's, on the child's card.
352
+ # model decides (its own ask_user_question, the step-limit continue, a
353
+ # hook's question). Approvals are the user's (the relay, while the
354
+ # parent waits; else on the child's card).
271
355
  def reportable?(wait)
272
356
  case wait.status
273
357
  when :done, :no_reply, :error then true
274
- when :waiting_for_answer then [nil, "", "continue"].include?(wait.question&.kind&.to_s)
358
+ when :waiting_for_answer then REPORTED_KINDS.include?(wait.question&.kind&.to_s)
275
359
  else false
276
360
  end
277
361
  end
278
362
 
363
+ REPORTED_KINDS = [nil, "", "continue", Tools::DelegateRelay::HOOK_KIND].freeze
364
+
365
+ # Whether the child's question goes to this parent's user rather than
366
+ # its model (Tools::DelegateRelay.relay?, under the child's strategy).
367
+ def relay?(wait)
368
+ wait.status == :waiting_for_answer && wait.question.kind.to_s == Tools::DelegateRelay::HOOK_KIND &&
369
+ Tools::DelegateRelay.relay?(wait.question, strategy: Guardrails::ParentPolicy.for_session(wait.session))
370
+ end
371
+
279
372
  def delete_rings(rings)
280
373
  rings.each { |file| FileUtils.rm_f(file) }
281
374
  end
@@ -105,6 +105,13 @@ module Samagotchi
105
105
  STRATEGIES.key?(word) ? word : HARD
106
106
  end
107
107
 
108
+ # Whether a session's own value is set but not a level (a hand-edited
109
+ # file: read as hard); the worker says so once when it loads.
110
+ def unknown_level?(value)
111
+ word = value.to_s.strip.downcase
112
+ !word.empty? && word != UNSET && !STRATEGIES.key?(word)
113
+ end
114
+
108
115
  # What a parent may answer on a session whose own value is
109
116
  # +session_value+: config.yml's (+config+) unless the session's is
110
117
  # stricter. Config's own object when it binds, so its label stays.
@@ -243,6 +250,19 @@ module Samagotchi
243
250
  end
244
251
  end
245
252
 
253
+ # Whether a parent may usefully answer +pending+ under +strategy+:
254
+ # some option it may pick is not a deny (free text alone is a deny
255
+ # with a reason). A delegate's question it may is reported to the
256
+ # parent's model; one it may not goes to the parent's user
257
+ # (Tools::DelegateRelay.relay?). Who may answer what stays #refusal's.
258
+ def parent_decides?(pending, strategy:)
259
+ deny = deny_labels(pending)
260
+ allowed_options(pending, strategy: strategy).any? { |label| !deny.include?(label) }
261
+ end
262
+
263
+ # The deny-tagged options' labels.
264
+ def deny_labels(pending) = Array(pending.options).zip(effects_of(pending)).filter_map { |label, tag| label.to_s if tag == DENY }
265
+
246
266
  # The question's deny-tagged option's label, or nil.
247
267
  def deny_label(pending)
248
268
  index = effects_of(pending).index(DENY)
@@ -16,6 +16,8 @@ module Samagotchi
16
16
  # How much of the child's task the card keeps, and the question line.
17
17
  TASK_CHARS = 200
18
18
  TASK_LINE_CHARS = 80
19
+ # A hook's question (QuestionDesk#ask_for_hook), relayed as such (P4).
20
+ HOOK_KIND = "hook"
19
21
 
20
22
  module_function
21
23
 
@@ -33,6 +35,14 @@ module Samagotchi
33
35
  asked = inner ? inner[:asked].to_s : pending.question.to_s
34
36
  task = inner ? inner[:task].to_s : task_of(child)
35
37
  who = chain.reverse.join(" → ")
38
+ relay = { id: relay_id, child_id: child.id, child_short: short, child_question_id: pending.id.to_s,
39
+ task: task, chain: chain, asked: asked, more: more.to_i }
40
+ if pending.kind.to_s == HOOK_KIND
41
+ # The asking hook's own header, kept down a chain (a relay of a relay).
42
+ relay[:header] = inner ? inner[:header].to_s : pending.header.to_s
43
+ return hook_card(pending, who, more, relay)
44
+ end
45
+
36
46
  {
37
47
  question: question_text(who, task, asked),
38
48
  options: relabel(Array(pending.options), facts[:scopes], who),
@@ -43,11 +53,33 @@ module Samagotchi
43
53
  **{ effects: pending.effects, user_only: pending.user_only }.compact,
44
54
  kind: Guardrails::Approval::KIND,
45
55
  approval: facts,
46
- relay: { id: relay_id, child_id: child.id, child_short: short, child_question_id: pending.id.to_s,
47
- task: task, chain: chain, asked: asked, more: more.to_i }
56
+ relay: relay
48
57
  }
49
58
  end
50
59
 
60
+ # A hook's question (P4: one its parent can't usefully answer): its
61
+ # kind, hook, options and effects as the child asked them, and no
62
+ # approval facts (ParentApprovals.approval? stays false on it, so the
63
+ # policy judges it by its own tags, as on the child).
64
+ def hook_card(pending, who, more, relay)
65
+ {
66
+ question: question_text(who, relay[:task], relay[:asked]),
67
+ options: Array(pending.options).map(&:to_s),
68
+ header: hook_header(who, relay[:header], more),
69
+ multi_select: !!pending.multi_select,
70
+ allow_freeform: !!pending.allow_freeform,
71
+ **{ effects: pending.effects, user_only: pending.user_only, hook: pending.hook }.compact,
72
+ kind: HOOK_KIND,
73
+ relay: relay
74
+ }
75
+ end
76
+
77
+ # delegate ab12 asks: Cost cap (+1 more delegate waiting)
78
+ def hook_header(who, header, more)
79
+ text = "delegate #{who} asks: #{header.to_s.strip.empty? ? "a question" : header}"
80
+ more.to_i.positive? ? "#{text} (+#{more} more delegate#{"s" if more.to_i > 1} waiting)" : text
81
+ end
82
+
51
83
  # delegate ab12 ("fix the flaky spec in …") asks:
52
84
  # execute: git push origin main
53
85
  # in /path/repo (repo samagotchi, branch main)
@@ -63,6 +63,19 @@ module Samagotchi
63
63
  end
64
64
  end
65
65
 
66
+ # Whether the parent's user (or a parent agent) answered or dismissed a
67
+ # relay of this child's question: it isn't relayed again (a refused
68
+ # answer leaves it on the child's card). A closed one (a Stop, the
69
+ # child gone) is not settled. Forgotten after KEEP_SECONDS or a restart.
70
+ def settled?(child_id, child_question_id)
71
+ @mutex.synchronize do
72
+ prune
73
+ @relays.each_value.any? do |relay|
74
+ relay[:state] == "answered" && relay[:child_id] == child_id.to_s && relay[:child_question_id] == child_question_id.to_s
75
+ end
76
+ end
77
+ end
78
+
66
79
  # Whether a relay is open, or settled recently enough that its child
67
80
  # may still ask about it: a restart would drop them.
68
81
  def active?
@@ -195,7 +195,7 @@ module Samagotchi
195
195
 
196
196
  verb = flags.size == 1 ? "applies" : "apply"
197
197
  names = flags.size > 2 ? "#{flags[0..-2].join(", ")} and #{flags.last}" : flags.join(" and ")
198
- err.puts "(#{names} #{verb} to a new session; #{session_id}'s prompt is already built)"
198
+ err.puts "(#{names} #{verb} to a new session; #{session_id} keeps its own)"
199
199
  end
200
200
 
201
201
  # @return [BridgeClient, nil] the running worker's, or nil when none runs
@@ -96,7 +96,8 @@ module Samagotchi
96
96
  case reason.to_s
97
97
  when "answered_on_child" then "(answered in #{@delegate})"
98
98
  when "child_gone" then "(#{@delegate}'s worker is gone)"
99
- when "dismissed" then "(denied)"
99
+ # A relayed hook question's dismissal: the hook decides what it means.
100
+ when "dismissed" then approval? ? "(denied)" : "(dismissed)"
100
101
  else "(left open in #{@delegate})"
101
102
  end
102
103
  end
@@ -737,7 +737,7 @@ module Samagotchi
737
737
  return if @toolkit_flags.empty?
738
738
 
739
739
  names = @toolkit_flags.size > 1 ? "#{@toolkit_flags[0..-2].join(", ")} and #{@toolkit_flags.last}" : @toolkit_flags.first
740
- warn "(#{names} #{@toolkit_flags.size == 1 ? "applies" : "apply"} to a new session; #{session.id}'s prompt is already built)"
740
+ warn "(#{names} #{@toolkit_flags.size == 1 ? "applies" : "apply"} to a new session; #{session.id} keeps its own)"
741
741
  end
742
742
  private :warn_toolkit_flags_ignored
743
743
 
@@ -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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.87.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?
@@ -1011,19 +1025,26 @@ module Samagotchi
1011
1025
  end
1012
1026
 
1013
1027
  # The Engine published a question (after the question desk saved it).
1014
- # In a turn that reports to the parent, the model's own question and
1015
- # the step-limit continue ring it; approvals and hooks' questions are
1016
- # 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.
1017
1033
  def ring_question(event)
1018
1034
  return unless @reporting_turn
1019
1035
 
1020
- kind = event.dig(:pending_question, :kind).to_s
1021
- 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]
1022
1040
 
1023
1041
  ChildRing.ring(@session, why: "question", state_dir: @state_dir)
1024
1042
  @asked_parent = true
1025
1043
  end
1026
1044
 
1045
+ # The kinds of question that ring a delegate child's parent (#ring_question).
1046
+ RINGING_KINDS = ["", ContinueOffer::KIND, "hook"].freeze
1047
+
1027
1048
  # WorkerInbound.save_or_log: a failed save between turns is logged.
1028
1049
  def save_or_log(at, &) = WorkerInbound.save_or_log(at, &)
1029
1050
 
@@ -1113,17 +1134,37 @@ module Samagotchi
1113
1134
  # the turn as its own).
1114
1135
  # @return [Array(Array<ChildReports::Report>, Hash|nil)] the reports
1115
1136
  # and the mark of their message
1116
- 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)
1117
1143
  if (reports = @wakes.take_handed)
1118
- return [reports, { turn_start: true, turn_id: @turn_id }.compact]
1144
+ return [resolve_relays(reports), { turn_start: true, turn_id: @turn_id }.compact]
1119
1145
  end
1120
1146
 
1121
- [@child_reports.take, nil]
1147
+ taken = @child_reports.take
1148
+ [resolve ? resolve_relays(taken) : taken, nil]
1122
1149
  rescue StandardError => e
1123
1150
  Log.warn(:worker, "delegate_reports_failed", error: e.class.name, msg: e.message)
1124
1151
  [[], nil]
1125
1152
  end
1126
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
+
1127
1168
  # Check again with the event log held, which the Bridge holds while it
1128
1169
  # queues a POST /turn, then close the Bridge's intake so no client can
1129
1170
  # queue one after the check, and stop the idle jobs (reminder callback,
@@ -1245,6 +1286,7 @@ module Samagotchi
1245
1286
 
1246
1287
  ArchiveStore.user_input(@session_id, state_dir: @state_dir)
1247
1288
  @wakes.human_input!(interactive: ClientId.interactive?(client_id))
1289
+ @child_reports&.release_holds
1248
1290
  end
1249
1291
 
1250
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.87.0
4
+ version: 0.88.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov