silas 0.1.7 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -0
  3. data/README.md +37 -14
  4. data/app/controllers/silas/inbox/sessions_controller.rb +24 -0
  5. data/app/controllers/silas/inbox/turns_controller.rb +32 -0
  6. data/app/jobs/silas/agent_loop_job.rb +46 -43
  7. data/app/jobs/silas/dead_job_rescuer_job.rb +25 -4
  8. data/app/models/silas/tool_invocation.rb +13 -1
  9. data/app/models/silas/turn.rb +1 -0
  10. data/app/views/layouts/silas/inbox.html.erb +14 -0
  11. data/app/views/silas/inbox/invocations/_invocation.html.erb +18 -2
  12. data/app/views/silas/inbox/sessions/index.html.erb +20 -2
  13. data/app/views/silas/inbox/sessions/show.html.erb +11 -0
  14. data/app/views/silas/inbox/steps/_step.html.erb +6 -1
  15. data/app/views/silas/inbox/turns/_header.html.erb +7 -0
  16. data/config/routes.rb +6 -1
  17. data/db/migrate/20260724000001_drop_agent_sdk_columns_from_silas_turns.rb +9 -0
  18. data/lib/generators/silas/install/install_generator.rb +33 -14
  19. data/lib/generators/silas/install/templates/bin_ci +2 -2
  20. data/lib/generators/silas/install/templates/initializer.rb +29 -5
  21. data/lib/generators/silas/install/templates/ruby_llm.rb +4 -0
  22. data/lib/silas/chat.rb +45 -13
  23. data/lib/silas/configuration.rb +68 -24
  24. data/lib/silas/delta_buffer.rb +50 -0
  25. data/lib/silas/engine.rb +6 -0
  26. data/lib/silas/engines/base.rb +6 -8
  27. data/lib/silas/engines/ruby_llm.rb +15 -4
  28. data/lib/silas/errors.rb +3 -3
  29. data/lib/silas/eval/scripted_engine.rb +0 -2
  30. data/lib/silas/inbox/delta_broadcaster.rb +38 -0
  31. data/lib/silas/ledger.rb +26 -10
  32. data/lib/silas/mcp/handler.rb +6 -5
  33. data/lib/silas/mcp/server.rb +9 -9
  34. data/lib/silas/step_runner.rb +21 -6
  35. data/lib/silas/tool.rb +5 -0
  36. data/lib/silas/version.rb +1 -1
  37. data/lib/silas.rb +9 -9
  38. metadata +5 -6
  39. data/lib/silas/agent_sdk/cli.rb +0 -59
  40. data/lib/silas/agent_sdk/stream_parser.rb +0 -86
  41. data/lib/silas/agent_sdk/version_guard.rb +0 -26
  42. data/lib/silas/engines/agent_sdk.rb +0 -75
  43. data/lib/silas/subprocess_runner.rb +0 -41
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ad5ce26e72a73ea83df134c187f8c8dedcfcc35a65f20493f3095abfbfaa7ed8
4
- data.tar.gz: ab1989850b2b8c881da660824dcad3f71159cfffcd8648901ec2a06385d8c294
3
+ metadata.gz: 1f804f46eccb34c27515be306e2697345f3be7e0ad3479aeafb05f3ca9e5e54b
4
+ data.tar.gz: 63a99caaeebb60133423d0d73669eff7074232262743764ec0d9d7d38f7b2012
5
5
  SHA512:
6
- metadata.gz: fb5da2cdb854533bf68a761582063e8c5b22c2e40fc02cab05240d4ea199554da317bb0509398c81923a312c7b43773834d354ffb6391be549a6254329e3b995
7
- data.tar.gz: 57e53d9a3c5b438b941cd599ba805c2c210e303d6362599d757b58b907ea10b9401cbf918cd909d1c53590d8fee4cc9e0fef8a8fb5ccb422c26db5e0abaaf43a
6
+ metadata.gz: bb45b5ca774d80cdd2c584851114204eb123dd714a94264a851699ea24955fa8eefba4b1897e1924a91a484f64c9746bc625e007652fdc8a71e096425e7a9882
7
+ data.tar.gz: 707d7857de1f2fba1e4e9327e9c32f71e7282c4dd2cfe739cb6aea98904cc805634f0ce70fd4353c5d580ea9f521d79e9581621d8f9b321216e9e00c058ba3b8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,98 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 (unreleased)
4
+
5
+ - **Token streaming, end to end.** The engine seam's `&on_event` block — dead
6
+ code since 0.1.0 — is live: the `:ruby_llm` engine streams the model
7
+ response (`chat.complete` with a block; the assembled message is identical
8
+ to the sync path, so durability semantics are untouched), `StepRunner`
9
+ coalesces text deltas into ~10Hz `"silas.delta"` notifications
10
+ (`Silas::DeltaBuffer`) carrying the accumulated text, and two subscribers
11
+ render them: the inbox trace (synchronous Turbo `broadcast_update_to` into a
12
+ stable per-step target — crash-restream overwrites, never duplicates) and
13
+ the `silas:chat` REPL (tokens print as they arrive). Deltas are decoration
14
+ over the authoritative rows: never persisted, never fed to the model, and a
15
+ replayed step emits none. `around_model_call` hooks keep their existing
16
+ contract and can no longer swallow the stream.
17
+ - **Onboarding fixes.** The generated `bin/ci` can now actually fail on app
18
+ tests (it silently swallowed them with `|| true`); the generated initializer
19
+ shows every option the next-steps mention (`inbox_auth`, `sandbox`,
20
+ `memory_approval`, `model_prices`, `eval_dir`, `approval_ttl`) and defaults
21
+ to `claude-sonnet-5` instead of handing a first run the most expensive model
22
+ with no budget set; the rescuer's `recurring.yml` entry is now **idempotent
23
+ and environment-aware** (injected under every deployable env block —
24
+ staging included — never blind-appended into whatever block ends the file,
25
+ never duplicated on a re-run); a missing provider API key is caught **at
26
+ boot** with the exact fix (warns in development, raises `BootGuardError` in
27
+ production); and the unsafe Async-adapter warning **raises in production**,
28
+ where running agents on it silently voids the durability contract.
29
+ - **Model-call resilience: transient provider errors retry from the
30
+ checkpoint; nothing ever strands in `running`.** Previously a single
31
+ 429/529/timeout failed the loop job permanently and invisibly — the turn sat
32
+ in `running` forever with no retry and no signal. Now:
33
+ `resume_errors_after_advancing = false` on the loop job (Active Job
34
+ Continuations otherwise swallow errors raised after a checkpoint and
35
+ self-resume unboundedly, bypassing `retry_on` — verified against activejob
36
+ 8.1); transient classes (`RateLimitError`, `OverloadedError`,
37
+ `ServiceUnavailableError`, `ServerError`, Faraday timeouts) retry with
38
+ polynomial backoff + jitter and **resume from the last completed step**;
39
+ exhaustion and permanent rejections (`UnauthorizedError`,
40
+ `PaymentRequiredError`, …) expire pending approvals and fail the turn
41
+ loudly. The rescuer now also sweeps **stranded turns** — a loop job that
42
+ died with an error outside the retry list fails its turn
43
+ (`reason: "job_failed"`) instead of leaving it running forever. Stale
44
+ approval cards can no longer zombie-resume a failed turn (`approve!` /
45
+ `decline!` refuse; `resume_turn!` guards).
46
+ - **The inbox now shows the audit trail it exists to provide.** Tool
47
+ `arguments` render for every settled invocation (not just parked ones), a
48
+ failed tool shows its recorded `error` instead of a bare red pill, and
49
+ `approved_by` / `decline_reason` render on settled approvals — who held the
50
+ lever, and why it moved. Active turns gain a **Cancel** button (running
51
+ turns flag for a step-boundary cancel, parked turns cancel immediately),
52
+ and the "N awaiting approval" badge is now a link filtering the session
53
+ list to what needs you (`?pending=1`).
54
+ - **Web chat in the inbox.** The session page gains a composer (`POST
55
+ .../sessions/:id/turns`) and the index a start-a-session form (with a named
56
+ agent picker) — the browser is now a first-class conversational surface, not
57
+ just approve/decline. Writes ride `authenticate_write!` exactly like
58
+ approvals; a turn-in-progress renders as an inline alert; web-chat sessions
59
+ stay `channel: nil` ("direct"), so no outbound delivery jobs are enqueued.
60
+
61
+ - **`approval :once` is now scoped to (tool, arguments), not tool name alone.**
62
+ Name-only matching was a footgun: approving a £5 refund silently
63
+ auto-approved a £5,000 refund later in the same session. Identical repeat
64
+ calls still skip re-approval; different arguments park again. Graded gates
65
+ belong in an approval lambda.
66
+ - **The ledger's checkpoint guard moved to `IsolatedExecutionState`** (from
67
+ `Thread.current[]`, which is fiber-local) — it now follows the app's
68
+ configured isolation level exactly like agent scopes, surviving into
69
+ internally-created fibers where the old flag silently vanished. Nested
70
+ ledger transactions now save/restore the guard instead of clearing it (the
71
+ old `ensure` opened a checkpoint-guard hole for the rest of the outer
72
+ transaction).
73
+ - **Removed the `:agent_sdk` engine** (the `claude -p` subprocess integration).
74
+ Its differentiating rationale — running on a Claude subscription plan instead
75
+ of API credits — was structurally unreachable: `--bare` was hardcoded and the
76
+ engine raised without `ANTHROPIC_API_KEY` regardless of `config.auth`, so the
77
+ OAuth path could never execute a turn. What remained was a second engine with
78
+ weaker guarantees on every axis (exactly-once only *within* a run,
79
+ `approval :never` tools only, fail-closed on any mid-subprocess kill) that
80
+ made the durability contract conditional. One production path now:
81
+ `:ruby_llm`.
82
+ - `config.engine = :agent_sdk` raises a clear `BootGuardError` at configure
83
+ time. `config.auth` and the `agent_sdk_*` options are warning no-ops for
84
+ this release (hard removal in 0.3) — an existing initializer won't crash.
85
+ - The in-process MCP server (`Silas::Mcp::Server`/`Handler`) **survives the
86
+ cut** — it is the seam for a planned "mount your agent's tools as an MCP
87
+ server" feature — with its own integration spec. Its bind host moved from
88
+ `config.agent_sdk_mcp_host` to `config.mcp_server_host`.
89
+ - New migration drops `silas_turns.cli_session_id` and
90
+ `silas_turns.mcp_token` (the latter was write-only; tokens are minted and
91
+ compared in memory). Run `bin/rails silas:install:migrations db:migrate`.
92
+ - `Silas::Engines::Base.loop_ownership` is gone — every engine executes one
93
+ model call per step under the framework-owned loop. Custom engines that
94
+ merely inherited it are unaffected.
95
+
3
96
  ## 0.1.7
4
97
 
5
98
  - **Memory — graph-shaped, not a graph database.** New `silas_memories` table:
data/README.md CHANGED
@@ -88,6 +88,13 @@ hundreds of times per release (results in `chaos_host/results/`):
88
88
  - **Approvals park at zero compute** — the job exits; approving enqueues a fresh
89
89
  one that replays completed work from rows, never re-calling the model or
90
90
  re-running tools. Parks expire (default 7 days) rather than ghosting forever.
91
+ - **Transient model errors retry from the checkpoint.** A rate limit,
92
+ overload, or timeout backs off and retries the job — and the continuation
93
+ resumes from the last completed step, never re-running completed work.
94
+ Exhausted retries and permanent rejections (bad key, bad request) expire
95
+ pending approvals and fail the turn loudly. **A turn can never sit in
96
+ `running` forever**: the rescuer also fails turns stranded by a loop job
97
+ that died outside the retry list.
91
98
  - **The rescuer is part of the contract.** Solid Queue marks a dead worker's
92
99
  jobs failed and nothing retries them; the installer wires
93
100
  `Silas::DeadJobRescuerJob` as a recurring task (every 30s). Recovery time ≈
@@ -96,19 +103,19 @@ hundreds of times per release (results in `chaos_host/results/`):
96
103
  deploy that changes tools/skills mid-turn fails the turn loudly
97
104
  (`NondeterminismError`) instead of resuming into a different agent.
98
105
 
99
- ## Engines
106
+ ## Engine
100
107
 
101
- Inference is one pluggable seam (`config.engine`):
108
+ Inference is one pluggable seam (`config.engine`): `:ruby_llm` — API-key auth
109
+ via [RubyLLM](https://rubyllm.com), any provider it supports — is the default
110
+ and the production path. Compose resilience via `config.around_model_call`, or
111
+ swap in any object responding to `#execute_step` (the eval harness and the
112
+ chaos tests do exactly that).
102
113
 
103
- - `:ruby_llm` API-key auth via [RubyLLM](https://rubyllm.com); any provider it
104
- supports. Canonical, production mode. Compose resilience via
105
- `config.around_model_call`.
106
- - `:agent_sdk` a `claude -p` subprocess runs the whole turn, calling back into
107
- your tools over an in-worker HTTP MCP endpoint whose `tools/call` goes through
108
- the same Ledger. Always `--bare` (API-key auth only; the boot guard raises if
109
- OAuth is configured with `ANTHROPIC_API_KEY` present, and if the key is missing
110
- in api_key mode). v1 is honestly weaker than `:ruby_llm`: exactly-once *within*
111
- a run, `approval :never` tools only, and fail-closed on a mid-subprocess kill.
114
+ > The experimental `:agent_sdk` engine (a `claude -p` subprocess) was removed
115
+ > in 0.2: its subscription-auth rationale was structurally unreachable, and it
116
+ > carried weaker guarantees than `:ruby_llm` on every axis. Its in-process MCP
117
+ > server survives and returns as a first-class *mount your tools as MCP*
118
+ > feature.
112
119
 
113
120
  ## Sandbox: run untrusted code with hermetic
114
121
 
@@ -190,12 +197,28 @@ An agent is reached by more than a method call:
190
197
  approvals render as Slack buttons / signed email links that call the same
191
198
  `approve!`/`decline!`. Outbound delivery is idempotent and off the durable loop.
192
199
 
200
+ ## Streaming
201
+
202
+ Turns stream. The `:ruby_llm` engine emits text deltas as the model responds:
203
+ `bin/rails silas:chat` prints tokens as they arrive, and the inbox trace
204
+ renders them live over Turbo (coalesced to ~10Hz). Deltas are decoration over
205
+ the durable rows — never persisted, never fed back to the model, and a
206
+ replayed step renders from its row with no deltas at all, so streaming adds
207
+ zero risk to the durability contract. Custom sinks subscribe to the
208
+ `"silas.delta"` notification (`{ session_id:, turn_id:, step_id:, step_index:,
209
+ text: }`, where `text` is the accumulated string so far — filter by ids;
210
+ notifications are process-global).
211
+
193
212
  ## The inbox
194
213
 
195
214
  Mount the engine (the generator does this) and a live inbox appears at
196
- `/silas/inbox`: a session list, a live step-trace that streams over Turbo
197
- Streams as the agent runs, approval cards whose Approve/Decline buttons call the
198
- exact same `approve!`/`decline!` as Slack and email, and per-session token/cost
215
+ `/silas/inbox`: a session list, **web chat** (start a session or reply from
216
+ the browser same durable loop, no separate surface), a live step-trace that
217
+ streams tokens over Turbo Streams as the agent runs, approval cards whose
218
+ Approve/Decline buttons call the exact same `approve!`/`decline!` as Slack and
219
+ email, a full **audit trail** (every tool call's arguments and its result or
220
+ recorded error; who approved; who declined and why), **cancel** on active
221
+ turns (honored at the next step boundary), and per-session token/cost
199
222
  accounting. It's **deny-by-default** — invisible until you wire auth:
200
223
 
201
224
  ```ruby
@@ -1,9 +1,20 @@
1
1
  module Silas
2
2
  module Inbox
3
3
  class SessionsController < BaseController
4
+ before_action :authenticate_write!, only: :create
5
+
4
6
  def index
5
7
  @sessions = Silas::Session.order(created_at: :desc).limit(100)
6
8
  @sessions = @sessions.where(agent_name: params[:agent]) if params[:agent].present?
9
+ if params[:pending].present? # the "N awaiting approval" badge drills into this
10
+ # Subquery, not joins+distinct: DISTINCT over silas_sessions.* trips
11
+ # on the json metadata column (PG json has no equality operator).
12
+ @sessions = @sessions.where(
13
+ id: Silas::Turn.joins(:tool_invocations)
14
+ .where(silas_tool_invocations: { approval_state: "required" })
15
+ .select(:session_id)
16
+ )
17
+ end
7
18
  @agent_names = Silas::Session.distinct.pluck(:agent_name).sort
8
19
  @pending_total = Silas::ToolInvocation.where(approval_state: "required").count
9
20
  end
@@ -13,6 +24,19 @@ module Silas
13
24
  @turns = @session.turns.includes(steps: :tool_invocations)
14
25
  @cost = Silas::Inbox::Cost.for_session(@session)
15
26
  end
27
+
28
+ # Start a session from the browser. channel stays nil ("direct") — web
29
+ # chat is read live on the session page, not delivered outbound.
30
+ def create
31
+ input = params[:input].to_s.strip
32
+ return redirect_to inbox_sessions_path, alert: "Type a message first." if input.empty?
33
+
34
+ handle = params[:agent].present? ? Silas.agent(params[:agent]) : Silas.agent
35
+ started = handle.start(input: input)
36
+ redirect_to inbox_session_path(started)
37
+ rescue Silas::Error => e
38
+ redirect_to inbox_sessions_path, alert: e.message
39
+ end
16
40
  end
17
41
  end
18
42
  end
@@ -0,0 +1,32 @@
1
+ module Silas
2
+ module Inbox
3
+ # The web composer: append a turn to an existing session. Same write-auth
4
+ # gate as approve/decline. Deliberately thin — the turn runs on the durable
5
+ # loop and the model's after_commit broadcasts render it live, so this
6
+ # controller only enqueues and redirects.
7
+ class TurnsController < BaseController
8
+ before_action :authenticate_write!
9
+
10
+ def create
11
+ agent_session = Silas::Session.find(params[:session_id])
12
+ input = params[:input].to_s.strip
13
+ return redirect_to inbox_session_path(agent_session), alert: "Type a message first." if input.empty?
14
+
15
+ agent_session.continue(input: input)
16
+ redirect_to inbox_session_path(agent_session)
17
+ rescue Silas::TurnInProgressError
18
+ redirect_to inbox_session_path(agent_session), alert: "A turn is already running — wait for it to settle."
19
+ end
20
+
21
+ # Cancel from the trace. Running turns are flagged and honored at the
22
+ # next step boundary (the same safe point as budgets); parked/queued
23
+ # turns cancel immediately.
24
+ def cancel
25
+ turn = Silas::Turn.find(params[:id])
26
+ outcome = turn.cancel!(reason: "canceled from inbox by #{current_actor}")
27
+ notice = outcome == :cancel_requested ? "Cancel requested — honored at the next step boundary." : "Turn canceled."
28
+ redirect_to inbox_session_path(turn.session_id), notice: notice
29
+ end
30
+ end
31
+ end
32
+ end
@@ -12,10 +12,51 @@ module Silas
12
12
  class AgentLoopJob < ActiveJob::Base
13
13
  include ActiveJob::Continuable
14
14
 
15
+ # Errors must reach retry_on. By default Continuable swallows any
16
+ # StandardError raised after a checkpoint and silently self-resumes —
17
+ # unbounded invisible retries that bypass attempts/wait/jitter entirely
18
+ # (verified against activejob 8.1: the around_perform rescue runs before
19
+ # rescue_with_handler). Checkpoints still survive retry_on's re-enqueues —
20
+ # continuation state rides the job payload — so a retried execution skips
21
+ # completed steps. Isolation interrupts are rescued separately and are
22
+ # unaffected by this flag. Do NOT reach for max_resumptions as an error
23
+ # bound: with isolate_steps on, every isolated step consumes one
24
+ # resumption by design.
25
+ self.resume_errors_after_advancing = false
26
+
15
27
  self.resume_options = { wait: 0 } # spike: default 5s wait makes turns crawl
16
28
 
17
29
  queue_as { Silas.config.queue_name }
18
30
 
31
+ # Transient provider trouble: back off and retry; the continuation resumes
32
+ # from the last completed step. Exhaustion fails the turn LOUDLY — a turn
33
+ # must never strand in "running". (Never retry_on StandardError: it would
34
+ # catch Continuation::Error subclasses and retry a structurally broken job
35
+ # forever.)
36
+ retry_on ::RubyLLM::RateLimitError, ::RubyLLM::OverloadedError,
37
+ ::RubyLLM::ServiceUnavailableError, ::RubyLLM::ServerError,
38
+ ::Faraday::TimeoutError, ::Faraday::ConnectionFailed,
39
+ wait: :polynomially_longer, attempts: 5, jitter: 0.15 do |job, error|
40
+ fail_turn(job, error)
41
+ end
42
+
43
+ # Permanent provider rejections: retrying cannot help. Fail the turn now.
44
+ discard_on ::RubyLLM::UnauthorizedError, ::RubyLLM::PaymentRequiredError,
45
+ ::RubyLLM::ForbiddenError, ::RubyLLM::BadRequestError do |job, error|
46
+ fail_turn(job, error)
47
+ end
48
+
49
+ # The force-fail path: expire approvals FIRST so no stale card can
50
+ # zombie-resume the failed turn, then finish loudly.
51
+ def self.fail_turn(job, error)
52
+ turn = Turn.find_by(id: job.arguments.first)
53
+ return unless turn&.active?
54
+
55
+ turn.expire_pending_approvals!("turn failed: model error")
56
+ turn.finish!(:failed, reason: "model_error")
57
+ Rails.logger&.error("[silas] turn #{turn.id} failed on #{error.class}: #{error.message}")
58
+ end
59
+
19
60
  def perform(turn_id)
20
61
  turn = Turn.find(turn_id)
21
62
  return if turn.completed? || %w[failed canceled].include?(turn.status)
@@ -26,26 +67,17 @@ module Silas
26
67
  # staff member never wakes up holding the root agent's tools.
27
68
  scope = Silas.scope_for_session(turn.session)
28
69
  if scope
29
- Silas.with_agent_scope(scope) { drive(turn) }
70
+ Silas.with_agent_scope(scope) { run_turn(turn) }
30
71
  else
31
- drive(turn)
72
+ run_turn(turn)
32
73
  end
33
74
  end
34
75
 
35
76
  private
36
77
 
37
- def drive(turn)
38
- if Silas.resolved_engine.class.loop_ownership == :engine
39
- perform_engine_owned(turn)
40
- else
41
- perform_framework_owned(turn)
42
- end
43
- end
44
-
45
-
46
- # :ruby_llm — the framework drives the loop, one model call per step, tools
47
- # executed through the Ledger. The determinism constraints live here.
48
- def perform_framework_owned(turn)
78
+ # The framework drives the loop: one model call per step, tools executed
79
+ # through the Ledger. The determinism constraints live here.
80
+ def run_turn(turn)
49
81
  step :prepare, isolated: isolate? do
50
82
  Ledger.assert_no_checkpoint!
51
83
  turn.update!(status: "running", job_id: job_id, started_at: turn.started_at || Time.current)
@@ -95,35 +127,6 @@ module Silas
95
127
  end
96
128
  end
97
129
 
98
- # :agent_sdk — Claude Code owns the loop; one isolated :run step wraps the
99
- # whole subprocess (one Continuation checkpoint per invocation). Same
100
- # durable shell, same queue/rescuer/single-active-turn invariants.
101
- def perform_engine_owned(turn)
102
- step :prepare, isolated: isolate? do
103
- Ledger.assert_no_checkpoint!
104
- turn.update!(status: "running", job_id: job_id, started_at: turn.started_at || Time.current)
105
- Instructions.snapshot!(turn)
106
- Step.find_or_create_by!(turn: turn, index: 0) # anchor step exists before the MCP thread needs it
107
- end
108
-
109
- # Cancellation for engine-owned turns is honored only BEFORE the
110
- # subprocess starts — a running claude -p is not aborted mid-flight (v1).
111
- if turn.reload.cancel_requested_at
112
- turn.finish!(:canceled, reason: "canceled")
113
- return
114
- end
115
-
116
- outcome = nil
117
- step :run, isolated: isolate? do
118
- Ledger.assert_no_checkpoint!
119
- outcome = SubprocessRunner.call(turn)
120
- end
121
-
122
- step :finalize do
123
- turn.finish!(:completed) if outcome == :terminal
124
- end
125
- end
126
-
127
130
  def isolate? = Silas.config.isolate_steps
128
131
  end
129
132
  end
@@ -20,12 +20,33 @@ module Silas
20
20
 
21
21
  rescued = 0
22
22
  SolidQueue::FailedExecution.includes(:job).find_each do |failed|
23
- next unless DEAD_PROCESS_ERRORS.include?(failed.error&.dig("exception_class"))
24
-
25
- failed.retry
26
- rescued += 1
23
+ if DEAD_PROCESS_ERRORS.include?(failed.error&.dig("exception_class"))
24
+ failed.retry
25
+ rescued += 1
26
+ elsif failed.job&.class_name == "Silas::AgentLoopJob"
27
+ fail_stranded_turn(failed)
28
+ end
27
29
  end
28
30
  rescued
29
31
  end
32
+
33
+ private
34
+
35
+ # A loop job that failed with a NON-dead-process error (something outside
36
+ # AgentLoopJob's retry list — a NoMethodError in a tool, an AR blip) will
37
+ # never be retried by anyone. Without this sweep its turn sits in
38
+ # "running" forever: not failed, not parked, invisible as broken. The
39
+ # failed execution stays in Solid Queue for forensics; the TURN is failed
40
+ # loudly with its approvals expired.
41
+ def fail_stranded_turn(failed)
42
+ turn = Turn.find_by(id: failed.job.arguments&.dig("arguments", 0))
43
+ return unless turn&.active?
44
+
45
+ exception = failed.error&.dig("exception_class")
46
+ turn.expire_pending_approvals!("turn failed: #{exception}")
47
+ turn.finish!(:failed, reason: "job_failed")
48
+ Rails.logger&.error("[silas] turn #{turn.id} failed: its loop job died with " \
49
+ "#{exception} — #{failed.error&.dig('message')}")
50
+ end
30
51
  end
31
52
  end
@@ -35,6 +35,7 @@ module Silas
35
35
  # in-doubt invocation, approval means "it did not run — re-execute".
36
36
  def approve!(by: nil)
37
37
  assert_parked!
38
+ assert_turn_resumable!
38
39
  update!(status: "pending", approval_state: "approved", approved_by: by)
39
40
  resume_turn!
40
41
  end
@@ -45,6 +46,7 @@ module Silas
45
46
  # abandon" — the operator-supplied reason becomes the recorded outcome.
46
47
  def decline!(reason:, by: nil)
47
48
  assert_parked!
49
+ assert_turn_resumable!
48
50
  update!(status: "failed", approval_state: "declined", approved_by: by,
49
51
  decline_reason: reason, result: { "denied" => reason })
50
52
  resume_turn!
@@ -68,8 +70,18 @@ module Silas
68
70
  raise Error, "invocation #{id} is not awaiting approval (state: #{approval_state.inspect})"
69
71
  end
70
72
 
73
+ # A failed turn must never be zombie-resumed by a stale approval card:
74
+ # force-fail paths expire approvals first, but a card already rendered in
75
+ # someone's browser can still POST — the verdict must land on a live turn.
76
+ def assert_turn_resumable!
77
+ return unless turn.reload.failed?
78
+
79
+ raise Error, "turn #{turn.id} already failed (#{turn.failure_reason}) — " \
80
+ "this approval can no longer resume it"
81
+ end
82
+
71
83
  def resume_turn!
72
- return if turn.reload.canceled? # a canceled turn never zombie-resumes
84
+ return if turn.reload.canceled? || turn.failed? # settled turns never zombie-resume
73
85
  return if turn.tool_invocations.where(approval_state: "required").exists?
74
86
 
75
87
  turn.update!(status: "queued")
@@ -17,6 +17,7 @@ module Silas
17
17
 
18
18
  ACTIVE_STATUSES.each { |s| define_method(:"#{s}?") { status == s } }
19
19
  def completed? = status == "completed"
20
+ def failed? = status == "failed"
20
21
  def active? = ACTIVE_STATUSES.include?(status)
21
22
  def parked? = status == "waiting" || status == "in_doubt"
22
23
 
@@ -58,6 +58,7 @@
58
58
  .step::before { content: ""; position: absolute; left: -5px; top: 6px; width: 8px; height: 8px;
59
59
  border-radius: 50%; background: var(--accent); }
60
60
  .step-text { margin: 2px 0; }
61
+ .step-live { white-space: pre-wrap; }
61
62
  .tool { background: var(--grey-bg); border-radius: 10px; padding: 8px 10px; margin: 6px 0; font-size: 13px; }
62
63
  .tool code { font-family: var(--mono); }
63
64
  pre { font-family: var(--mono); font-size: 12px; background: var(--grey-bg); border-radius: 8px;
@@ -73,7 +74,19 @@
73
74
  padding: 8px; margin: 8px 0; font: inherit; background: var(--panel); color: var(--ink); resize: vertical; }
74
75
  form.inline { display: inline; }
75
76
  .cost { font-family: var(--mono); font-size: 12px; color: var(--muted); }
77
+ .composer textarea { width: 100%; border: 1px solid var(--line); border-radius: 10px;
78
+ padding: 10px 12px; font: inherit; background: var(--panel); color: var(--ink); resize: vertical; }
79
+ .composer textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
80
+ .composer-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 8px; }
81
+ .btn.send { background: var(--accent); color: #fff; }
82
+ select.composer-agent { border: 1px solid var(--line); border-radius: 8px; padding: 6px 8px;
83
+ font: inherit; background: var(--panel); color: var(--ink); margin-bottom: 8px; }
76
84
  .flash { background: var(--red-bg); color: var(--red); padding: 10px 12px; border-radius: 10px; margin-bottom: 12px; }
85
+ .flash-notice { background: var(--green-bg); color: var(--green); }
86
+ .btn-cancel { border: 1px solid var(--red); background: transparent; color: var(--red);
87
+ border-radius: 8px; padding: 2px 10px; font-size: 12px; font-weight: 600; cursor: pointer; margin-left: auto; }
88
+ pre.error { background: var(--red-bg); color: var(--red); }
89
+ pre.args { opacity: 0.85; }
77
90
  .empty { text-align: center; color: var(--muted); padding: 48px 0; }
78
91
  .agent-filter { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 14px; }
79
92
  .agent-filter .chip { font-size: 12px; padding: 3px 10px; border: 1px solid #d9dce1;
@@ -89,6 +102,7 @@
89
102
  <% if content_for?(:header_extra) %><%= yield :header_extra %><% end %>
90
103
  </header>
91
104
  <% if flash[:alert] %><div class="flash"><%= flash[:alert] %></div><% end %>
105
+ <% if flash[:notice] %><div class="flash flash-notice"><%= flash[:notice] %></div><% end %>
92
106
  <%= yield %>
93
107
  </div>
94
108
  </body>
@@ -2,10 +2,26 @@
2
2
  <div>
3
3
  <code><%= invocation.tool_name %></code>
4
4
  <%= status_pill(invocation.awaiting_approval? ? "required" : invocation.status) %>
5
+ <%# The audit line — who held the lever, and why it moved. %>
6
+ <% if invocation.approval_state == "approved" && invocation.approved_by.present? %>
7
+ <span class="muted">approved by <%= invocation.approved_by %></span>
8
+ <% elsif invocation.approval_state == "declined" %>
9
+ <span class="muted">declined<%= " by #{invocation.approved_by}" if invocation.approved_by.present? %><%= " — “#{invocation.decline_reason}”" if invocation.decline_reason.present? %></span>
10
+ <% elsif invocation.approval_state == "expired" %>
11
+ <span class="muted">approval expired unanswered</span>
12
+ <% end %>
5
13
  </div>
6
14
  <% if invocation.awaiting_approval? %>
7
15
  <%= render "silas/inbox/invocations/approval_card", invocation: invocation %>
8
- <% elsif invocation.result.present? %>
9
- <pre><%= pretty_args(invocation.result) %></pre>
16
+ <% else %>
17
+ <%# What the agent passed — the question an audit surface must answer. %>
18
+ <% if invocation.arguments.present? %>
19
+ <pre class="args"><%= pretty_args(invocation.arguments) %></pre>
20
+ <% end %>
21
+ <% if invocation.error.present? %>
22
+ <pre class="error"><%= invocation.error %></pre>
23
+ <% elsif invocation.result.present? %>
24
+ <pre><%= pretty_args(invocation.result) %></pre>
25
+ <% end %>
10
26
  <% end %>
11
27
  </div>
@@ -1,5 +1,7 @@
1
1
  <% content_for :header_extra do %>
2
- <% if @pending_total.positive? %><span class="badge"><%= @pending_total %> awaiting approval</span><% end %>
2
+ <% if @pending_total.positive? %>
3
+ <%= link_to "#{@pending_total} awaiting approval", inbox_sessions_path(pending: 1), class: "badge" %>
4
+ <% end %>
3
5
  <% end %>
4
6
 
5
7
  <% if @agent_names && @agent_names.size > 1 %>
@@ -12,8 +14,24 @@
12
14
  </div>
13
15
  <% end %>
14
16
 
17
+ <div class="card composer">
18
+ <%= form_with url: inbox_sessions_path do |f| %>
19
+ <% roster = Silas.named_agent_scopes.keys.sort %>
20
+ <% if roster.any? %>
21
+ <%= f.select :agent, [ [ "agent (root)", "" ] ] + roster.map { |a| [ a, a ] },
22
+ {}, class: "composer-agent" %>
23
+ <% end %>
24
+ <%= f.text_area :input, rows: 2, required: true, class: "composer-input",
25
+ placeholder: "Start a new session…" %>
26
+ <div class="composer-row">
27
+ <span></span>
28
+ <%= f.submit "Start session", class: "btn send" %>
29
+ </div>
30
+ <% end %>
31
+ </div>
32
+
15
33
  <% if @sessions.empty? %>
16
- <div class="empty">No agent sessions yet.</div>
34
+ <div class="empty">No agent sessions yet — start one above.</div>
17
35
  <% else %>
18
36
  <% @sessions.each do |session| %>
19
37
  <%= link_to inbox_session_path(session), class: "card session-row" do %>
@@ -18,3 +18,14 @@
18
18
  <div id="silas-turns">
19
19
  <%= render partial: "silas/inbox/turns/turn", collection: @turns, as: :turn %>
20
20
  </div>
21
+
22
+ <div class="card composer">
23
+ <%= form_with url: inbox_session_turns_path(@session) do |f| %>
24
+ <%= f.text_area :input, rows: 2, required: true, class: "composer-input",
25
+ placeholder: "Message #{@session.agent_name}…" %>
26
+ <div class="composer-row">
27
+ <span class="muted"><%= "a turn is running — sending will queue behind it" if @session.active_turn %></span>
28
+ <%= f.submit "Send", class: "btn send" %>
29
+ </div>
30
+ <% end %>
31
+ </div>
@@ -1,6 +1,11 @@
1
1
  <div class="step" id="<%= dom_id(step) %>">
2
+ <%# The text container is unconditional: silas-step-<id>-text is the live
3
+ delta target. While the step runs, deltas replace its innerHTML; the
4
+ completed row render (this partial, re-broadcast) supersedes them. %>
2
5
  <% if (text = step_text(step)) %>
3
- <div class="step-text"><%= simple_format(text) %></div>
6
+ <div class="step-text" id="silas-step-<%= step.id %>-text"><%= simple_format(text) %></div>
7
+ <% else %>
8
+ <div class="step-text step-live" id="silas-step-<%= step.id %>-text"></div>
4
9
  <% end %>
5
10
  <div id="silas-step-<%= step.id %>-tools">
6
11
  <%= render partial: "silas/inbox/invocations/invocation", collection: step.tool_invocations, as: :invocation %>
@@ -1,6 +1,13 @@
1
1
  <div class="turn-head">
2
2
  <span class="turn-input"><%= truncate(turn.input, length: 90) %></span>
3
3
  <%= status_pill(turn.status) %>
4
+ <% if turn.active? && !turn.cancel_requested_at %>
5
+ <%= form_with url: cancel_inbox_turn_path(turn), method: :post, class: "inline" do %>
6
+ <button class="btn-cancel" title="Honored at the next step boundary">Cancel</button>
7
+ <% end %>
8
+ <% elsif turn.cancel_requested_at && turn.active? %>
9
+ <span class="muted">cancel requested…</span>
10
+ <% end %>
4
11
  </div>
5
12
  <% if turn.failure_reason.present? %>
6
13
  <div class="muted">failed: <%= turn.failure_reason %></div>
data/config/routes.rb CHANGED
@@ -8,7 +8,12 @@ Silas::Engine.routes.draw do
8
8
 
9
9
  namespace :inbox do
10
10
  root to: "sessions#index"
11
- resources :sessions, only: %i[index show]
11
+ resources :sessions, only: %i[index show create] do
12
+ resources :turns, only: :create
13
+ end
14
+ resources :turns, only: [] do
15
+ member { post :cancel }
16
+ end
12
17
  resources :invocations, only: [] do
13
18
  member do
14
19
  post :approve
@@ -0,0 +1,9 @@
1
+ class DropAgentSdkColumnsFromSilasTurns < ActiveRecord::Migration[8.1]
2
+ # The :agent_sdk engine was removed in 0.2. cli_session_id was its fail-closed
3
+ # resume marker; mcp_token was written by Mcp::Server but read by nothing (the
4
+ # token is minted and compared in memory).
5
+ def change
6
+ remove_column :silas_turns, :cli_session_id, :string
7
+ remove_column :silas_turns, :mcp_token, :string
8
+ end
9
+ end