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 +4 -4
- data/CHANGELOG.md +16 -1
- data/docs/cli.md +3 -3
- data/docs/guardrails.md +6 -1
- data/docs/sessions.md +4 -4
- data/docs/sub-agent.md +9 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +100 -7
- data/lib/samagotchi/guardrails/parent_policy.rb +20 -0
- data/lib/samagotchi/relay.rb +34 -2
- data/lib/samagotchi/relay_desk.rb +13 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +1 -1
- data/lib/samagotchi/terminal_ui/question_prompt.rb +2 -1
- data/lib/samagotchi/terminal_ui.rb +1 -1
- data/lib/samagotchi/tools/delegate_relay.rb +69 -15
- data/lib/samagotchi/tools/delegate_wait.rb +3 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/notify.js +6 -2
- data/lib/samagotchi/web/public/question_card.js +2 -0
- data/lib/samagotchi/web/public/question_cards.js +25 -18
- data/lib/samagotchi/worker.rb +51 -9
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f395ab65a9e00861ae1dda52090efbbe363946df8098702f14c41cdc665f7bb3
|
|
4
|
+
data.tar.gz: 9f22902c72237fd51481b78f1cc91197fdc474e7f5b816e0a4429d354eb5c0aa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
495
|
-
|
|
496
|
-
`(--mute applies to a new session; <id>
|
|
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)
|
|
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"
|
|
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
|
|
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
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
#
|
|
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
|
|
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)
|
data/lib/samagotchi/relay.rb
CHANGED
|
@@ -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:
|
|
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}
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
41
|
-
|
|
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"
|
|
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
|
|
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
|
|
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
|
|
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]
|
|
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
|
-
|
|
198
|
-
|
|
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 &&
|
|
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
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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;
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
1015
|
-
#
|
|
1016
|
-
#
|
|
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
|
-
|
|
1021
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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?
|