silas 0.6.2 → 0.6.3

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: 7a34eec0f8444f42ef91b1db155bc2786c28ce0de5ed345d9e63b39af9b29756
4
- data.tar.gz: 94c9b0464629b6f23eab8f4811e5dd63cdec63d480388cf01c8b0b9111a43376
3
+ metadata.gz: 8544d250988c08dfa8766c6ccfd512391f91977d301c976b9a11bbdf2120a64d
4
+ data.tar.gz: 15ff50ad44a2c1357d8b950c7d6bda8a326f6b1b54e4f4cbb1b02c3d2a8f4a1b
5
5
  SHA512:
6
- metadata.gz: 62c5e530ccc6cfbc3cbfa7b1388b72e53545172acc054b7565930eef56541f8e839846435435585db3fcc3161d40994345b0da5e24fcdc0de1710988efc7ab35
7
- data.tar.gz: e8c83c1733efd76810fe2d0cd5e5e57c8b1d929d1a5556d685e376c704108c550494f07e6eb332d6968e458f1bfbb85b5fc024016d1f951dfb1d6a351b6b4683
6
+ metadata.gz: b14467a4ebbc1001a6e627b2c06d2ee95f1ecb7fdd086fbd39f36061e02325c7910295158d5cfa775f10e1f0f84a09aa0c43167ec5fe4cb0600ecb7dfba8f707
7
+ data.tar.gz: 32ad0ff55610960130a8427271645a4fe30e264cd9832f14c049d5ee7909165785c62921a235ca80fd81e2746e222ed12534e1a7174bfeec88f2f047b571c473
data/CHANGELOG.md CHANGED
@@ -1,5 +1,214 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.3 (2026-07-29)
4
+
5
+ Two safety declarations were failing open, and one approval card could be
6
+ spent twice. The rest of the release is the console, the channel layer and the
7
+ docs catching up with what the framework already does.
8
+
9
+ ### Fixed
10
+
11
+ - **Tool declarations now inherit — a base class silently disarmed both
12
+ gates.** Ruby does not inherit class-level instance variables, and
13
+ `Silas::Tool` stored all four of its declarations in them. So the ordinary
14
+ Rails move of factoring shared declarations into a base class — a `MoneyTool`
15
+ declaring `transactional!` and `approval :always`, with
16
+ `class IssueRefund < MoneyTool; end` under it — left `IssueRefund` running at
17
+ `effect_mode :at_most_once` and `approval :never`: no database transaction
18
+ around the write, no human gate, no error and no warning. The two
19
+ declarations the Ledger acts on both failed **open**, into their least safe
20
+ defaults, and the transcript looked entirely normal. `param` refinements had
21
+ the same defect, which compounds the string-default trap: a subclass lost its
22
+ base's `param :amount_pence, :integer`, so the model was told to send a
23
+ string and any numeric comparison in an approval lambda raised outside the
24
+ ledger's rescue. Settings now resolve up the ancestry with the nearest
25
+ explicit declaration winning; param refinements merge down, so a subclass can
26
+ override one param without dropping the rest. Direct subclasses that declare
27
+ nothing keep the documented defaults. **If your app has a tool base class,
28
+ read `YourTool.effect_mode` and `YourTool.approval_policy` in a console
29
+ before you deploy — that is what those tools have been running as.**
30
+ - **`approve!`, `answer!` and `decline!` are compare-and-swap — one approval
31
+ could be spent twice.** The guard read in-memory state and the settle paths
32
+ then wrote with a bare `update!`, so two holders of the same rendered
33
+ approval card — two people, a double-click, a retried POST — both passed it,
34
+ both wrote, and both reached `resume_turn!`. Each then found no invocation
35
+ still `required` and enqueued a fresh `AgentLoopJob`: **one turn ran twice**,
36
+ two paid model calls, the second minting `tool_call` ids the ledger has never
37
+ seen and therefore cannot dedup. That is the double-execution the
38
+ unsafe-queue-adapter boot guard exists to prevent, arriving instead through
39
+ the approval path — the one path whose entire purpose is to be the safe place
40
+ a human intervenes. A verdict now settles through an `update_all` scoped to
41
+ `approval_state "required"` and raises naming the approver who won, rather
42
+ than silently overwriting a decline with an approval; `resume_turn!` moves
43
+ the turn out of `waiting`/`in_doubt` with a second compare-and-swap and
44
+ enqueues only if it won that one, which covers the case a per-invocation
45
+ claim cannot — two gated invocations on one step, settled concurrently, each
46
+ legitimately winning its own row and both then seeing no remaining gate.
47
+ Chaos on the fixed path: worker 25/25 and parked 15/15, all completed, all
48
+ transcripts byte-identical, zero duplicate side effects.
49
+ - **The installer sent you to a doctor it had already failed.** Rails defaults
50
+ development to the in-process `:async` queue adapter, and the doctor fails
51
+ `:async` — correctly, because a re-enqueued continuation runs concurrently
52
+ with the original there and double-executes steps. The install generator's
53
+ last words were "then `bin/rails silas:doctor` to verify the whole setup", so
54
+ the first thing the framework told a new developer was a red X for a
55
+ condition it knew about before it printed the instruction. The generator now
56
+ detects the adapter and prints the exact config to paste — printed, never
57
+ written — and the doctor prints the same text beside the failure, from the
58
+ same constant, so the two cannot drift. The doctor's unrecognised-adapter
59
+ branch also stops reading as a style preference: a Sidekiq or GoodJob app is
60
+ now told what it actually loses, which is `DeadJobRescuerJob` (it returns
61
+ early unless Solid Queue is defined), so a job killed with its worker is
62
+ never retried and a turn stranded mid-tool stays `running` with its in-doubt
63
+ invocation unswept. It stays a warning, and the boot guard stays scoped to
64
+ `:async`.
65
+ - **Six published claims about the guarantees were false** — all of them in the
66
+ material a reader uses to decide whether the durability contract can be
67
+ trusted. Chaos does not run "hundreds of times per release": `ci.yml` has no
68
+ chaos job and `release.yml` runs only rspec, so the harness is real and
69
+ reproducible and a human runs it. The matrix is **275 runs**, not 295 —
70
+ `bin/full_gate` is 100 + 100 + 10 + 10 + 5 + 25 + 25, and `RESULTS.md`'s own
71
+ table summed to 275 under the inflated total. Handoffs are **at-most-once**,
72
+ not "exactly-once-guarded"; a crash mid-handoff parks in doubt for a person.
73
+ `Silas::Mcp::Server` was documented as a shipped seam when nothing outside
74
+ its specs calls `.start`, so an app cannot turn it on at all — the code stays
75
+ because a mounted endpoint is the planned shape, and both pages now say it is
76
+ not wired up. Two pages still read 0.5.x. `guarantees.md` also told readers
77
+ the gate figures were the last batch in each results file: `results/*.jsonl`
78
+ is append-only and every per-change run appends to it, so the last batch is
79
+ whatever ran most recently. The page now says what is true — batches start
80
+ where `run` restarts at 1, no field marks which one was a gate, and
81
+ `chaos_host/RESULTS.md` is the record — and the rows stay unedited, including
82
+ the inconvenient ones. `CONTRIBUTING.md` promised the chaos gate only "where
83
+ the loop changed" against four published pages promising the full gate before
84
+ every release; it now matches the public promise.
85
+ - **The README's `IssueRefund` example never declared its integer param.** Param
86
+ types default to string, so the model was told to send `"6400"` and the
87
+ example's own `input[:amount_pence] > 2_500` raised `ArgumentError` — inside
88
+ `approval_verdict`, outside `run_tool!`'s rescue, which kills the loop job
89
+ and (in development, where no rescuer is scheduled) strands the turn in
90
+ `running` with no diagnostic. `docs/tools.md`, the skill and
91
+ `templates/desk.rb` all had it right; the one place it was missing was the
92
+ most-copied snippet in the project.
93
+ - **`docs/headless.md` named a scope helper that does not exist** (`with_scope`
94
+ for `Silas.with_agent_scope`) and suggested `Session.where(status: "waiting")`
95
+ — `waiting` is a turn status, and a session query for it validates fine and
96
+ silently returns nothing. Corrected to the approval queue and parked turns,
97
+ with both vocabularies named so the trap is visible rather than inferred.
98
+ - **A named agent asking for a tool it doesn't have raised a bare `KeyError`.**
99
+ It now raises `Silas::Error` naming both the tool and the agent, which is
100
+ what the operator needs to see in the log.
101
+
102
+ ### Upgrading
103
+
104
+ - **Settle parked turns for your named agents before deploying this.** Named
105
+ agents gain `ask_question` and the connection tools (below), and adding tools
106
+ to a scope changes that scope's definitions digest — the nondeterminism guard
107
+ refuses to resume a turn against a different agent than the one that started
108
+ it. A turn parked under a named agent — waiting on an approval, a question,
109
+ or an in-doubt call — is failed with `definitions_changed` when it wakes after
110
+ the upgrade, not resumed. Clear the inbox for each named agent and let those
111
+ turns finish first. Root-agent and subagent digests are unchanged, so their
112
+ parked turns are unaffected.
113
+
114
+ ### Added
115
+
116
+ - **Channels route to named staff.** `Silas::Channel.dispatch` hardcoded
117
+ `Silas.agent.start` and neither first-party caller could say otherwise, so
118
+ every Slack thread and every email woke the root agent: an app could employ
119
+ staff with their own tools, instructions and cron and still had no way to
120
+ send `#billing` or `billing@` to the bookkeeper. `config.channel_routes` maps
121
+ a transport-specific key to an agent name
122
+ (`{"slack" => {"C0BILLING" => "bookkeeper"}}`); unmatched threads wake the
123
+ root agent, so nothing changes for an app that sets nothing. Routes are
124
+ checked at boot against the `app/agents/` roster — a typo fails the deploy
125
+ and names the staff that do exist, rather than raising inside a webhook,
126
+ where Slack retries, the retry guard drops the retry, and the message is
127
+ gone. The continuation token now carries the agent as well as the channel
128
+ (`slack:bookkeeper:C1:ts`), and a lookup that misses the new form falls back
129
+ to the old one, so a live thread keeps the session — and the agent — it
130
+ already had. Only new threads route.
131
+ - **Named agents get `ask_question` and the remote connections.** A named
132
+ agent (`app/agents/<name>/`) is staff, not a lesser agent: it can park to
133
+ ask a person, and it can reach the MCP tools declared in
134
+ `app/agent/connections/` — one set of credentials for the whole app,
135
+ resolved the same way the root agent resolves them. Its scope was built by
136
+ the same builder subagents use, which grants only `load_skill`, `run_code`,
137
+ `remember`, `recall` and `handoff` — so the member of staff woken by its own
138
+ Slack channel or email address was structurally the one that could not stop
139
+ and ask, in a framework whose premise is that a turn parks for days waiting
140
+ on a human. And the remote credentials are the app's, not the root agent's,
141
+ yet only the root agent could spend them. `config.ask_question` governs the
142
+ builtin exactly as it governs the root agent. `delegate` stays root-only:
143
+ subagents belong to the root agent's turn.
144
+ - **`docs/headless.md` — Silas without the inbox.** The inbox is a mountable
145
+ engine, not a requirement, and nothing said so. The page names the pattern —
146
+ drive `approve!`/`decline!`/`answer!` from your own controllers, since the
147
+ inbox, the JSON API, the Slack controller and the signed email links are four
148
+ callers of the same three `ToolInvocation` methods — and what you take on
149
+ instead of pretending it is free: rendering model-authored arguments, your
150
+ own authorization, live updates (the engine's broadcast targets are internal,
151
+ not a contract), and the real coupling, which is that
152
+ `Channel.approval_url` mints links against `Engine.routes`. It also covers
153
+ `Silas.with_agent_scope` for apps whose capabilities vary per user or tenant,
154
+ with the two constraints that bite: connection credentials are paths into the
155
+ app's own credentials rather than per-end-user OAuth tokens, and changing an
156
+ agent's capabilities fails its parked turns loudly on resume — while teaching
157
+ it facts does not, because memory is not in the definitions digest.
158
+ - **The console accounts for a handoff.** `Session` has carried
159
+ `parent_session` and `child_sessions` since 0.5 and the JSON API serializes
160
+ both, but no view read either — so the one relationship a multi-agent
161
+ framework exists to express appeared in the inbox as a new row with a
162
+ different agent's name, no explanation and no route back to the session that
163
+ started it. Three places state it now: a child session names the agent that
164
+ handed over and links to its session, the call that made a child renders that
165
+ child in the feed at the point it happened (with its agent and its turn's
166
+ state), and the index marks child rows with the parent's agent, so a row that
167
+ read as a stray reads as the second half of a handoff. The pairing is
168
+ recovered from `session_id` in the handoff's own result and accepted only
169
+ when that session's parent is the calling session — `session_id` is a
170
+ plausible key for any tool to return, and a lineage line that is merely
171
+ probably true is worse than none. `handoff` is at-most-once, so a crash
172
+ between `Session.create!` and the result leaves a colleague started with
173
+ nothing in the trace naming it; those children are listed under the feed
174
+ rather than dropped.
175
+ - **A render-side test for every live-update target.** Turbo addresses an
176
+ element that must already be on the page: broadcast to an id no view renders
177
+ and nothing raises, nothing logs, nothing updates — the trace stops moving
178
+ while every spec stays green. That is exactly how 0.6.1's held-pill bug
179
+ shipped, and seven of the ten targets had never been checked against a real
180
+ render. Each is now pinned to the partial that must render it, the five
181
+ `replace` targets additionally to that partial's root element (the only shape
182
+ that survives a swap), and the contract runs backwards too — every
183
+ broadcasting transition is driven with the seam captured and the emitted set
184
+ held to the nine covered — so a target added with no view to receive it fails
185
+ as well.
186
+
187
+ ### Changed
188
+
189
+ - **The session page shows what the agent did, not the trace.** Every tool call
190
+ rendered identically — name, status pill, a key/value table of arguments, a
191
+ disclosure around the result — so a read that returned a row and a
192
+ transactional write that moved money carried the same weight, and the
193
+ sentence an operator came for had to be reassembled from the parts on every
194
+ row. Each invocation is now one line: `issue_refund · order #4821 · GBP 64.00
195
+ → approved by Dana · refunded`, with arguments and result behind the
196
+ disclosure. Salience comes from effect mode, the property that says whether a
197
+ row mattered: an idempotent call that worked is furniture, `at_most_once` is
198
+ loud, and `transactional` earns the heaviest rule on the page, because its
199
+ write and its ledger row commit together. A failure is loud whatever it
200
+ touched, and so is anything still owing a person a verdict. Both readings
201
+ ship in the DOM with a checkbox choosing between them in CSS — a broadcast
202
+ render has no params and no reader to ask, so a choice baked into the render
203
+ would snap back on the first replace. A settled turn also states its own
204
+ audit: when it was asked, where it stands, how many tools ran and how many of
205
+ them wrote. Three absences now say so out loud instead of showing as gaps — a
206
+ turn that called no tools, a completed step that produced neither text nor a
207
+ call, and a tool that returned nothing — because an unexplained hole in a
208
+ feed reads as a rendering bug. `running` reads **WORKING** in the pill, the
209
+ same word the index rail's Working group uses for the same turn; the database
210
+ strings and the JSON API are untouched.
211
+
3
212
  ## 0.6.2 (2026-07-26)
4
213
 
5
214
  The providers guide and the remaining community files.
data/README.md CHANGED
@@ -75,6 +75,7 @@ Create a tool at `app/agent/tools/issue_refund.rb` — the keyword signature
75
75
  ```ruby
76
76
  class Agent::Tools::IssueRefund < Silas::Tool
77
77
  description "Refund part or all of an order."
78
+ param :amount_pence, :integer, desc: "Amount in pence (1800 = £18.00)"
78
79
  approval ->(session:, input:) { input[:amount_pence] > 2_500 ? :user_approval : :approved }
79
80
  transactional! # DB effect + ledger commit atomically -> exactly-once
80
81
 
@@ -95,8 +96,9 @@ bin/rails silas:chat
95
96
  Refunds over £25 hold for a person — in the operator inbox the gem mounts at
96
97
  `/silas/inbox`, in Slack, or over the JSON API — and approving resumes the
97
98
  turn exactly where it stopped, with exactly one refund row in your database.
98
- How that's guaranteed (and verified with a `kill -9` chaos harness on every
99
- release): [guarantees](https://danielstpaul.github.io/silas/guarantees).
99
+ How that's guaranteed and verified by a `kill -9` chaos harness run before
100
+ each release, results committed under `chaos_host/results/`:
101
+ [guarantees](https://danielstpaul.github.io/silas/guarantees).
100
102
 
101
103
  ## Status
102
104
 
@@ -17,6 +17,8 @@ module Silas
17
17
  thread_key = "#{params[:team_id]}:#{event[:channel]}:#{event[:thread_ts] || event[:ts]}"
18
18
  channel_class.dispatch(
19
19
  thread_key: thread_key, input: event[:text].to_s,
20
+ # Which staff member owns this Slack channel (nil -> the root agent).
21
+ agent: Silas::Channel.route_for("slack", event[:channel]),
20
22
  metadata: { "slack" => { "channel" => event[:channel],
21
23
  "thread_ts" => event[:thread_ts] || event[:ts],
22
24
  "user" => event[:user] } }
@@ -22,7 +22,9 @@ module Silas
22
22
 
23
23
  # One query for the rows + turns, one for the pending counts — the
24
24
  # per-row active_turn/turns.last/counts pattern was ~4 queries a card.
25
- @sessions = scope.limit(PER_PAGE).includes(:turns).to_a
25
+ # parent_session rides along for the lineage line: every child row
26
+ # names the agent that handed to it, and that must not cost a query.
27
+ @sessions = scope.limit(PER_PAGE).includes(:turns, :parent_session).to_a
26
28
  @next_before = @sessions.last&.id if @sessions.size == PER_PAGE
27
29
  @pending_counts = Silas::ToolInvocation.joins(:turn)
28
30
  .where(approval_state: "required",
@@ -18,8 +18,10 @@ module Silas
18
18
  # UI-only relabels — the database strings and the JSON API are untouched
19
19
  # (an operator who reads "held" here and greps the API will find
20
20
  # `waiting`; docs name both). Safety-system vocabulary: a turn is held
21
- # at the signal until a person clears it.
22
- UI_LABEL = { "waiting" => "held", "completed" => "clear" }.freeze
21
+ # at the signal until a person clears it. `running` reads WORKING so the
22
+ # pill and the rail's Working group say the same word about the same
23
+ # turn; the disclosure on every tool row prints the raw enum.
24
+ UI_LABEL = { "waiting" => "held", "running" => "working", "completed" => "clear" }.freeze
23
25
 
24
26
  def status_label(status)
25
27
  UI_LABEL[status.to_s] || status.to_s.tr("_", " ")
@@ -52,6 +54,226 @@ module Silas
52
54
  end
53
55
  end
54
56
 
57
+ # ---- the feed ----------------------------------------------------
58
+ #
59
+ # One invocation, one sentence: what ran, on what, how it ended.
60
+ #
61
+ # issue_refund · order #4821 · GBP 64.00 → held for approval
62
+ # issue_refund · order #4821 · GBP 64.00 → approved by Dana · refunded
63
+ #
64
+ # Everything these return is PLAIN TEXT. Arguments and results are
65
+ # model-authored, so they reach the page only through ERB's escape —
66
+ # nothing here may be marked html_safe.
67
+
68
+ # A verdict outranks the status it wrote: `decline!` sets status=failed,
69
+ # but a person refusing is not a crash and must not read like one.
70
+ INVOCATION_STATE = {
71
+ "pending" => "working", "started" => "working", "completed" => "clear",
72
+ "failed" => "failed", "in_doubt" => "in_doubt"
73
+ }.freeze
74
+
75
+ STATE_ASPECT = {
76
+ "held" => "amber", "working" => "blue", "clear" => "green", "failed" => "red",
77
+ "declined" => "red", "expired" => "quiet", "in_doubt" => "violet"
78
+ }.freeze
79
+
80
+ # The states are settled quietly ONLY here; every other state either
81
+ # needs a person or lost one.
82
+ SETTLED_QUIETLY = %w[clear working].freeze
83
+
84
+ MONEY_KEYS = %w[amount total price subtotal].freeze
85
+ CURRENCY_KEYS = %w[currency currency_code].freeze
86
+
87
+ def invocation_state(invocation)
88
+ return "held" if invocation.awaiting_approval?
89
+ return invocation.approval_state if %w[declined expired].include?(invocation.approval_state)
90
+
91
+ INVOCATION_STATE.fetch(invocation.status, invocation.status)
92
+ end
93
+
94
+ def invocation_state_class(invocation)
95
+ "deed-#{STATE_ASPECT.fetch(invocation_state(invocation), 'quiet')}"
96
+ end
97
+
98
+ # Salience. A read that worked is furniture; a tool that moved money or
99
+ # sent a message is not, and neither is anything a person still owes a
100
+ # verdict on. A FAILED read is loud too — having no effect doesn't make
101
+ # a failure quiet. `idempotent` is the mode a tool declares when it only
102
+ # reads; `transactional` is the one whose write and ledger row commit
103
+ # together, and it gets the heaviest rule on the page.
104
+ def invocation_weight(invocation)
105
+ return "deed-exact" if invocation.effect_mode == "transactional"
106
+ return "deed-loud" unless SETTLED_QUIETLY.include?(invocation_state(invocation))
107
+
108
+ invocation.effect_mode == "idempotent" ? "deed-quiet" : "deed-loud"
109
+ end
110
+
111
+ def invocation_outcome(invocation)
112
+ state = invocation_state(invocation)
113
+ case state
114
+ when "held" then invocation.question? ? "held for an answer" : "held for approval"
115
+ when "working" then "working"
116
+ when "in_doubt" then "in doubt — nobody knows whether it ran"
117
+ when "declined" then declined_phrase(invocation)
118
+ when "expired" then invocation.question? ? "expired — nobody answered" : "expired — nobody cleared it"
119
+ else [ verdict_phrase(invocation), result_or_failure_phrase(invocation, state) ].compact.join(" · ")
120
+ end
121
+ end
122
+
123
+ # The middle of the sentence: enough of the call to recognise the job
124
+ # without opening anything. Non-scalars are left out — a nested hash
125
+ # flattened onto one line is noise — and wait in the disclosure.
126
+ def invocation_arg_phrases(invocation, limit: 3)
127
+ args = invocation.arguments
128
+ return [] unless args.is_a?(Hash) && args.any?
129
+
130
+ merged, phrases = money_phrase(args)
131
+ args.each do |key, value|
132
+ break if phrases.size >= limit
133
+ next if merged.include?(key.to_s) || !scalar_arg?(value)
134
+
135
+ phrases << arg_phrase(key.to_s, value)
136
+ end
137
+ phrases
138
+ end
139
+
140
+ # "GBP 64.00" is what an operator scans a refund line for; as two
141
+ # separate phrases the currency and the number sit apart and neither
142
+ # reads as money. Returns [keys consumed, phrases].
143
+ def money_phrase(args)
144
+ currency = args.find { |k, v| CURRENCY_KEYS.include?(k.to_s) && v.to_s.match?(/\A[A-Za-z]{3}\z/) }
145
+ amount = args.find { |k, v| MONEY_KEYS.include?(k.to_s) && v.is_a?(Numeric) }
146
+ return [ [], [] ] unless currency && amount
147
+
148
+ [ [ currency[0].to_s, amount[0].to_s ], [ format("%s %.2f", currency[1].to_s.upcase, amount[1]) ] ]
149
+ end
150
+
151
+ # `order_id: 4821` reads as "order #4821". A bare flag reads as itself:
152
+ # "dry run" beats "dry run true", and "no dry run" beats "dry run false".
153
+ def arg_phrase(key, value)
154
+ return "#{key.delete_suffix("_id").tr("_", " ")} ##{value}" if key.end_with?("_id")
155
+
156
+ label = key.tr("_", " ")
157
+ return label if value == true
158
+ return "no #{label}" if value == false
159
+
160
+ "#{label} #{value.to_s.truncate(48)}"
161
+ end
162
+
163
+ # What came back. A tool that returned nothing SAYS so — the absence is
164
+ # a fact about the run, not a gap in the sentence.
165
+ def invocation_result_phrase(invocation)
166
+ result = invocation.result
167
+ return "returned nothing" if result.nil? || result == {} || result == ""
168
+ return result.to_s.truncate(48) unless result.is_a?(Hash)
169
+
170
+ key, value = result.find { |_k, v| scalar_arg?(v) }
171
+ key ? arg_phrase(key.to_s, value) : "#{result.size} #{"field".pluralize(result.size)} returned"
172
+ end
173
+
174
+ # Where the turn stands, in the words the pill has no room for. Never
175
+ # says "waiting": the enum is `waiting`, the operator's word is HELD.
176
+ def turn_progress(turn)
177
+ case turn.status
178
+ when "queued" then "queued for a worker"
179
+ when "running" then "working now"
180
+ when "waiting" then "held — a person is needed"
181
+ when "in_doubt" then "in doubt — a tool crashed mid-call"
182
+ when "canceled" then "stopped before it finished"
183
+ when "failed" then turn.failure_reason.present? ? "failed — #{turn.failure_reason}" : "failed"
184
+ when "completed" then turn.finished_at ? "answered #{silas_relative_time(turn.finished_at)}" : "answered"
185
+ else status_label(turn.status)
186
+ end
187
+ end
188
+
189
+ # A settled turn's one-line audit: how much ran, and how much of it
190
+ # wrote. Reads off the loaded steps so the session page adds no queries.
191
+ def turn_effects_summary(turn)
192
+ invocations = turn.steps.flat_map(&:tool_invocations)
193
+ return "no tools ran — answered directly" if invocations.empty?
194
+
195
+ wrote = invocations.count { |i| i.effect_mode != "idempotent" && i.status == "completed" }
196
+ "#{pluralize(invocations.size, "tool")} · #{wrote.zero? ? "nothing written" : "#{wrote} wrote"}"
197
+ end
198
+
199
+ # ---- lineage -----------------------------------------------------
200
+ #
201
+ # A handoff or a delegation starts a SECOND session. Nothing on the child
202
+ # points back at the call that made it — the only link is the invocation
203
+ # result — so the feed recovers the pair from there, and the child's
204
+ # metadata says which of the two it was.
205
+
206
+ LINEAGE_FROM = { "handoff" => "handed over by", "delegation" => "delegated by" }.freeze
207
+ LINEAGE_TO = { "handoff" => "handed to", "delegation" => "delegated to" }.freeze
208
+
209
+ def lineage_relation(child)
210
+ meta = child.metadata
211
+ return nil unless meta.is_a?(Hash)
212
+ return "handoff" if meta.key?("handoff_from")
213
+
214
+ "delegation" if meta.key?("delegated_from")
215
+ end
216
+
217
+ def lineage_from_phrase(child) = LINEAGE_FROM.fetch(lineage_relation(child), "started by")
218
+ def lineage_to_phrase(child) = LINEAGE_TO.fetch(lineage_relation(child), "started")
219
+
220
+ def invocation_child_session(invocation)
221
+ result = invocation.result
222
+ return nil unless result.is_a?(Hash) && result["session_id"].present?
223
+
224
+ child = Silas::Session.find_by(id: result["session_id"])
225
+ # Any tool may return a key called `session_id` meaning something of its
226
+ # own. Only a session whose parent IS this call's session was started
227
+ # by this call.
228
+ child if child && child.parent_session_id == invocation.turn.session_id
229
+ end
230
+
231
+ # Children the feed has no place for. An at_most_once handoff that
232
+ # crashes after Session.create! records no result, so the call that made
233
+ # the child names nothing — without this the child is visible only from
234
+ # the index, which is the orphan the lineage work exists to end.
235
+ def stranded_child_sessions(session, turns)
236
+ placed = turns.flat_map(&:steps).flat_map(&:tool_invocations).filter_map do |i|
237
+ i.result["session_id"].to_i if i.result.is_a?(Hash) && i.result["session_id"].present?
238
+ end
239
+ session.child_sessions.reject { |child| placed.include?(child.id) }
240
+ end
241
+
242
+ # The turn a session is judged by: the one in flight, else the last one
243
+ # to settle. Reads off the loaded association — the index depends on that.
244
+ def session_state_turn(session)
245
+ session.turns.detect(&:active?) || session.turns.last
246
+ end
247
+
248
+ def scalar_arg?(value)
249
+ case value
250
+ when String then value.present?
251
+ when Numeric, true, false then true
252
+ else false
253
+ end
254
+ end
255
+
256
+ def verdict_phrase(invocation)
257
+ case invocation.approval_state
258
+ when "approved"
259
+ invocation.approved_by.present? ? "approved by #{invocation.approved_by}" : "auto-approved by policy"
260
+ when "answered"
261
+ invocation.approved_by.present? ? "answered by #{invocation.approved_by}" : "answered"
262
+ end
263
+ end
264
+
265
+ def declined_phrase(invocation)
266
+ who = invocation.approved_by.present? ? "declined by #{invocation.approved_by}" : "declined"
267
+ invocation.decline_reason.present? ? "#{who} — “#{invocation.decline_reason}”" : who
268
+ end
269
+
270
+ def result_or_failure_phrase(invocation, state)
271
+ return "failed — #{invocation.error.to_s.lines.first.to_s.strip.truncate(72).presence || "no reason recorded"}" if state == "failed"
272
+ return nil if invocation.approval_state == "answered" # the answer IS the result
273
+
274
+ invocation_result_phrase(invocation)
275
+ end
276
+
55
277
  # Prefixed: this helper is registered host-wide (broadcast renders need
56
278
  # it), and "relative_time" is exactly the name a host app would define.
57
279
  def silas_relative_time(time)
@@ -9,6 +9,10 @@ module Silas
9
9
  channel_class.dispatch(
10
10
  thread_key: self.class.thread_key(mail),
11
11
  input: body_text,
12
+ # Which staff member was written to (nil -> the root agent). Every
13
+ # recipient is a candidate — To, Cc, and the forwarding headers Action
14
+ # Mailbox's own routing matches on — first route wins.
15
+ agent: Silas::Channel.route_for("email", mail.recipients),
12
16
  metadata: { "email" => { "from" => mail.from&.first, "subject" => mail.subject } }
13
17
  )
14
18
  rescue Silas::TurnInProgressError
@@ -44,7 +44,7 @@ module Silas
44
44
  end
45
45
  assert_parked!
46
46
  assert_turn_resumable!
47
- update!(status: "pending", approval_state: "approved", approved_by: by)
47
+ claim_verdict!(status: "pending", approval_state: "approved", approved_by: by)
48
48
  Silas.instrument(:approval, action: "approved", tool: tool_name, by: by,
49
49
  invocation_id: id, turn_id: turn_id)
50
50
  resume_turn!
@@ -60,8 +60,8 @@ module Silas
60
60
 
61
61
  assert_parked!
62
62
  assert_turn_resumable!
63
- update!(status: "completed", approval_state: "answered", approved_by: by,
64
- result: { "answer" => text })
63
+ claim_verdict!(status: "completed", approval_state: "answered", approved_by: by,
64
+ result: { "answer" => text })
65
65
  Silas.instrument(:approval, action: "answered", tool: tool_name, by: by,
66
66
  invocation_id: id, turn_id: turn_id)
67
67
  resume_turn!
@@ -74,8 +74,8 @@ module Silas
74
74
  def decline!(reason:, by: nil)
75
75
  assert_parked!
76
76
  assert_turn_resumable!
77
- update!(status: "failed", approval_state: "declined", approved_by: by,
78
- decline_reason: reason, result: { "denied" => reason })
77
+ claim_verdict!(status: "failed", approval_state: "declined", approved_by: by,
78
+ decline_reason: reason, result: { "denied" => reason })
79
79
  Silas.instrument(:approval, action: "declined", tool: tool_name, by: by,
80
80
  invocation_id: id, turn_id: turn_id)
81
81
  resume_turn!
@@ -102,6 +102,27 @@ module Silas
102
102
  raise Error, "invocation #{id} is not awaiting approval (state: #{approval_state.inspect})"
103
103
  end
104
104
 
105
+ # A verdict is a state TRANSITION, not a write. `assert_parked!` reads
106
+ # in-memory state, so without a compare-and-swap two holders of the same
107
+ # rendered approval card — two people, a double-click, a retried POST —
108
+ # both pass it, both write, and both reach `resume_turn!`. Two fresh
109
+ # AgentLoopJobs then run one turn concurrently: two paid model calls, and
110
+ # the second mints tool_call ids the ledger has never seen and therefore
111
+ # cannot dedup. That is precisely the double-execution `warn_unsafe_queue_
112
+ # adapter!` refuses to allow the Async adapter to cause, arriving instead
113
+ # through the approval path — the one path whose entire purpose is to be
114
+ # the safe place a human intervenes. Claim the row, or lose loudly.
115
+ def claim_verdict!(attrs)
116
+ claimed = self.class.where(id: id, approval_state: "required")
117
+ .update_all(attrs.merge(updated_at: Time.current))
118
+ if claimed.zero?
119
+ reload
120
+ raise Error, "invocation #{id} was already settled (state: #{approval_state.inspect}, " \
121
+ "by: #{approved_by.inspect}) — another verdict won the race"
122
+ end
123
+ reload
124
+ end
125
+
105
126
  # A failed turn must never be zombie-resumed by a stale approval card:
106
127
  # force-fail paths expire approvals first, but a card already rendered in
107
128
  # someone's browser can still POST — the verdict must land on a live turn.
@@ -123,7 +144,17 @@ module Silas
123
144
  # point is waiting for a person. Cost/token budgets stay cumulative;
124
145
  # they measure real spend.
125
146
  parked_for = turn.updated_at ? (Time.current - turn.updated_at).to_f : nil
126
- turn.update!(status: "queued", started_at: Time.current)
147
+ # Second CAS, for the case a per-invocation claim cannot cover: two gated
148
+ # invocations on one step, settled concurrently. Both verdicts legitimately
149
+ # win their own row, both then see no remaining gate above, and both would
150
+ # enqueue. Only the verdict that actually moves the turn out of a parked
151
+ # status resumes it.
152
+ claimed = Silas::Turn.where(id: turn.id, status: %w[waiting in_doubt])
153
+ .update_all(status: "queued", started_at: Time.current,
154
+ updated_at: Time.current)
155
+ return if claimed.zero?
156
+
157
+ turn.reload
127
158
  Silas.instrument(:resume, turn_id: turn.id, parked_for: parked_for)
128
159
  AgentLoopJob.perform_later(turn.id)
129
160
  end