xeno 0.0.1 → 0.0.2
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 +45 -2
- data/README.md +3 -1
- data/Rakefile +2 -0
- data/app/controllers/xeno/api_controller.rb +15 -18
- data/app/controllers/xeno/application_controller.rb +2 -0
- data/app/controllers/xeno/dev_controller.rb +5 -4
- data/app/controllers/xeno/dev_ui_controller.rb +5 -4
- data/app/controllers/xeno/health_controller.rb +3 -1
- data/app/controllers/xeno/sessions_controller.rb +22 -24
- data/app/controllers/xeno/slack_controller.rb +9 -10
- data/app/controllers/xeno/streams_controller.rb +18 -22
- data/app/helpers/xeno/application_helper.rb +2 -0
- data/app/jobs/xeno/application_job.rb +2 -0
- data/app/jobs/xeno/reaper_job.rb +5 -4
- data/app/jobs/xeno/schedule_job.rb +10 -13
- data/app/jobs/xeno/slack_event_job.rb +7 -7
- data/app/jobs/xeno/turn_job.rb +5 -4
- data/app/mailers/xeno/application_mailer.rb +2 -0
- data/app/models/xeno/action.rb +7 -12
- data/app/models/xeno/application_record.rb +2 -0
- data/app/models/xeno/chat.rb +9 -11
- data/app/models/xeno/dedup.rb +7 -7
- data/app/models/xeno/event.rb +16 -20
- data/app/models/xeno/message.rb +2 -0
- data/app/models/xeno/pending_message.rb +4 -2
- data/app/models/xeno/session.rb +52 -67
- data/app/models/xeno/turn.rb +29 -34
- data/config/routes.rb +2 -0
- data/db/migrate/{20260806000001_move_transcript_support_tables_to_ruby_llm.rb → 20260808000001_create_ruby_llm_tables.rb} +25 -54
- data/db/migrate/{20260804000002_create_xeno_orchestration_tables.rb → 20260808000002_create_xeno_tables.rb} +44 -14
- data/docs/configuration.md +56 -0
- data/docs/runtime.md +4 -4
- data/exe/xeno +1 -1
- data/lib/generators/xeno/install/install_generator.rb +3 -2
- data/lib/generators/xeno/install/templates/agent.rb +1 -2
- data/lib/generators/xeno/install/templates/initializer.rb +6 -9
- data/lib/generators/xeno/tool/tool_generator.rb +4 -2
- data/lib/tasks/xeno_tasks.rake +2 -0
- data/lib/xeno/agent_config.rb +9 -8
- data/lib/xeno/agent_definition.rb +25 -28
- data/lib/xeno/approval_context.rb +2 -0
- data/lib/xeno/arguments.rb +8 -9
- data/lib/xeno/ask_question.rb +5 -5
- data/lib/xeno/channels/slack.rb +25 -29
- data/lib/xeno/channels.rb +10 -9
- data/lib/xeno/compaction.rb +20 -33
- data/lib/xeno/configuration.rb +35 -44
- data/lib/xeno/engine.rb +8 -7
- data/lib/xeno/errors.rb +11 -10
- data/lib/xeno/hooks.rb +7 -10
- data/lib/xeno/info.rb +3 -2
- data/lib/xeno/inputs.rb +26 -19
- data/lib/xeno/reaper.rb +13 -17
- data/lib/xeno/schedules.rb +8 -9
- data/lib/xeno/session_state.rb +8 -9
- data/lib/xeno/standalone/local_secret.rb +7 -6
- data/lib/xeno/standalone/model_refresh.rb +6 -6
- data/lib/xeno/standalone/puma.rb +9 -9
- data/lib/xeno/standalone.rb +22 -21
- data/lib/xeno/tool.rb +28 -25
- data/lib/xeno/turn_runner.rb +74 -96
- data/lib/xeno/version.rb +3 -1
- data/lib/xeno.rb +16 -14
- metadata +4 -9
- data/db/migrate/20260804000001_create_xeno_llm_tables.rb +0 -70
- data/db/migrate/20260805000001_add_resumes_to_xeno_turns.rb +0 -8
- data/db/migrate/20260805000002_add_transcript_deferred_to_xeno_turns.rb +0 -8
- data/db/migrate/20260805000003_create_xeno_dedups.rb +0 -14
- data/db/migrate/20260805000004_add_kind_to_xeno_turns.rb +0 -9
- data/db/migrate/20260805000005_add_state_to_xeno_sessions.rb +0 -8
data/lib/xeno/configuration.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
4
|
# Gem-level runtime knobs, set from an initializer:
|
|
3
5
|
#
|
|
@@ -6,77 +8,66 @@ module Xeno
|
|
|
6
8
|
# end
|
|
7
9
|
#
|
|
8
10
|
class Configuration
|
|
9
|
-
# Per-turn model-call budget
|
|
11
|
+
# Per-turn model-call budget: a turn must bound its model calls.
|
|
10
12
|
attr_accessor :max_steps
|
|
11
13
|
|
|
12
|
-
# A turn whose claim ledger reaches this many attempts is poison — it
|
|
13
|
-
#
|
|
14
|
+
# A turn whose claim ledger reaches this many attempts is poison — it fails instead of retrying
|
|
15
|
+
# forever.
|
|
14
16
|
attr_accessor :max_turn_attempts
|
|
15
17
|
|
|
16
|
-
# A running turn whose heartbeat is older than this is presumed dead and
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
# session after a hard crash.
|
|
18
|
+
# A running turn whose heartbeat is older than this is presumed dead and reclaimable. Checkpoint
|
|
19
|
+
# replay makes takeover safe; a too-short window risks overlap with a live-but-slow owner, a
|
|
20
|
+
# too-long one stalls the session after a hard crash.
|
|
20
21
|
attr_accessor :turn_stale_after
|
|
21
22
|
|
|
22
|
-
# How often the background heartbeat beats while the runner is inside a
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
# beats several times per staleness window.
|
|
23
|
+
# How often the background heartbeat beats while the runner is inside a model call or a tool
|
|
24
|
+
# body (the calls that can outlast turn_stale_after). nil (default) = a quarter of
|
|
25
|
+
# turn_stale_after, so a live owner always beats several times per staleness window.
|
|
26
26
|
attr_writer :heartbeat_interval
|
|
27
27
|
|
|
28
|
-
# Per-session token budgets, enforced
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
# budget.exceeded event; the session recovers via reset. nil =
|
|
33
|
-
# unlimited. Per-agent override: `limits input_tokens:, output_tokens:`
|
|
34
|
-
# in agent.rb (false disables an axis).
|
|
28
|
+
# Per-session token budgets, enforced before each model call from the persisted usage counts,
|
|
29
|
+
# independently per axis. The call that crosses a cap finishes; the next call fails the turn
|
|
30
|
+
# with a budget.exceeded event, and the session recovers via reset. nil = unlimited. Per-agent
|
|
31
|
+
# override: `limits` in agent.rb.
|
|
35
32
|
attr_accessor :max_input_tokens_per_session, :max_output_tokens_per_session
|
|
36
33
|
|
|
37
|
-
# Compaction triggers when the last model call's context usage crosses
|
|
38
|
-
#
|
|
39
|
-
# disables automatic compaction (manual stays available).
|
|
34
|
+
# Compaction triggers when the last model call's context usage crosses this fraction of the
|
|
35
|
+
# model's context window. nil/false disables automatic compaction (manual stays available).
|
|
40
36
|
attr_accessor :compaction_threshold
|
|
41
37
|
|
|
42
|
-
# Fallback context window (tokens) for models the registry doesn't know
|
|
43
|
-
#
|
|
44
|
-
# unknown-window models.
|
|
38
|
+
# Fallback context window (tokens) for models the registry doesn't know (assume_model_exists).
|
|
39
|
+
# nil = automatic compaction never triggers for unknown-window models.
|
|
45
40
|
attr_accessor :compaction_context_window
|
|
46
41
|
|
|
47
|
-
# How many of the most recent user-message-anchored turns survive a
|
|
48
|
-
#
|
|
49
|
-
# summarized and replaced.
|
|
42
|
+
# How many of the most recent user-message-anchored turns survive a compaction verbatim (the
|
|
43
|
+
# "recent tail"). Everything earlier is summarized and replaced.
|
|
50
44
|
attr_accessor :compaction_tail_turns
|
|
51
45
|
|
|
52
|
-
# Optional lambda answering "is the worker shutting down?"
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
# queue adapter (`queue_adapter.stopping?` — Solid Queue flips it on
|
|
57
|
-
# worker shutdown; adapters that don't implement it just return false).
|
|
46
|
+
# Optional lambda answering "is the worker shutting down?", probed between steps. When true the
|
|
47
|
+
# turn releases its claim and re-enqueues itself, so a deploy-style stop resumes on the next
|
|
48
|
+
# worker instead of waiting out stale-heartbeat reclaim. nil = ask the queue adapter's
|
|
49
|
+
# `stopping?` (Solid Queue implements it; adapters that don't return false).
|
|
58
50
|
attr_accessor :stopping_check
|
|
59
51
|
|
|
60
|
-
# Fail-closed HTTP auth lambda. Receives the request; a falsy return is
|
|
61
|
-
#
|
|
52
|
+
# Fail-closed HTTP auth lambda. Receives the request; a falsy return is a 401; a truthy return
|
|
53
|
+
# becomes the request principal.
|
|
62
54
|
attr_accessor :authenticate
|
|
63
55
|
|
|
64
56
|
# How often the SSE stream polls for new events, in seconds.
|
|
65
57
|
attr_accessor :stream_poll_interval
|
|
66
58
|
|
|
67
|
-
# How many events one stream poll reads at most. Bounds the catch-up
|
|
68
|
-
#
|
|
69
|
-
#
|
|
59
|
+
# How many events one stream poll reads at most. Bounds the catch-up read on long sessions (a
|
|
60
|
+
# reconnect from index 0 pages through history in batches instead of loading every row in one
|
|
61
|
+
# query).
|
|
70
62
|
attr_accessor :stream_catch_up_batch
|
|
71
63
|
|
|
72
|
-
# Optional hard cap on how long one SSE connection is served (seconds).
|
|
73
|
-
#
|
|
64
|
+
# Optional hard cap on how long one SSE connection is served (seconds). nil = until the session
|
|
65
|
+
# reaches a terminal status.
|
|
74
66
|
attr_accessor :stream_max_duration
|
|
75
67
|
|
|
76
|
-
# Optional lambda answering "is the server shutting down?" — when true,
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
# open). nil = detect Puma's graceful stop automatically.
|
|
68
|
+
# Optional lambda answering "is the server shutting down?" — when true, open SSE streams close
|
|
69
|
+
# themselves so a graceful stop doesn't wait out the in-flight-request window (Ctrl-C stays
|
|
70
|
+
# near-instant with tabs open). nil = detect Puma's graceful stop automatically.
|
|
80
71
|
attr_accessor :stream_shutdown_check
|
|
81
72
|
|
|
82
73
|
def initialize
|
data/lib/xeno/engine.rb
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
4
|
class Engine < ::Rails::Engine
|
|
3
5
|
isolate_namespace Xeno
|
|
4
6
|
|
|
5
|
-
# agent/ is a Zeitwerk root namespaced under Xeno with per-slot
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# plain shared Ruby (required manually by the author).
|
|
7
|
+
# agent/ is a Zeitwerk root namespaced under Xeno with per-slot submodules:
|
|
8
|
+
# agent/tools/get_weather.rb defines Xeno::Tools::GetWeather. Non-constant files are xeno's to
|
|
9
|
+
# load: agent.rb is a config DSL, schedules/ holds markdown, channels/ is a DSL slot, lib/ is
|
|
10
|
+
# plain shared Ruby required by the author.
|
|
10
11
|
initializer "xeno.agent_autoload", before: :setup_main_autoloader do |app|
|
|
11
12
|
agent_root = Xeno.agent_root
|
|
12
13
|
if agent_root&.directory?
|
|
@@ -20,8 +21,8 @@ module Xeno
|
|
|
20
21
|
end
|
|
21
22
|
end
|
|
22
23
|
|
|
23
|
-
# The cached definition holds tool classes; drop it whenever the app
|
|
24
|
-
#
|
|
24
|
+
# The cached definition holds tool classes; drop it whenever the app reloads so dev picks up
|
|
25
|
+
# edits under agent/.
|
|
25
26
|
config.to_prepare do
|
|
26
27
|
Xeno.reset_definition!
|
|
27
28
|
end
|
data/lib/xeno/errors.rb
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
4
|
class Error < StandardError; end
|
|
3
5
|
|
|
4
|
-
# Raised when a fenced write discovers its claim was reaped — the runner
|
|
5
|
-
#
|
|
6
|
+
# Raised when a fenced write discovers its claim was reaped — the runner is a zombie and must
|
|
7
|
+
# abort without touching anything else.
|
|
6
8
|
class Fenced < Error; end
|
|
7
9
|
|
|
8
|
-
# Raised (internally) to park the turn: pending actions need human input.
|
|
9
|
-
#
|
|
10
|
+
# Raised (internally) to park the turn: pending actions need human input. Never RubyLLM's cancel!
|
|
11
|
+
# — its cleanup destroys in-flight rows.
|
|
10
12
|
class Parked < Error
|
|
11
13
|
attr_reader :actions
|
|
12
14
|
|
|
@@ -19,8 +21,8 @@ module Xeno
|
|
|
19
21
|
# The per-turn runaway guard tripped.
|
|
20
22
|
class MaxStepsExceeded < Error; end
|
|
21
23
|
|
|
22
|
-
# A session token budget is spent: raised BEFORE the model call that
|
|
23
|
-
#
|
|
24
|
+
# A session token budget is spent: raised BEFORE the model call that would overspend.
|
|
25
|
+
# Deterministic turn failure; reset recovers the session.
|
|
24
26
|
class BudgetExceeded < Error
|
|
25
27
|
attr_reader :axis, :used, :limit
|
|
26
28
|
|
|
@@ -32,9 +34,8 @@ module Xeno
|
|
|
32
34
|
end
|
|
33
35
|
end
|
|
34
36
|
|
|
35
|
-
# Raised
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
# pattern). Not a failure — it never counts against the poison ladder.
|
|
37
|
+
# Raised between runner steps when the worker is shutting down gracefully: the turn releases its
|
|
38
|
+
# claim and re-enqueues itself so the next worker resumes it promptly. Not a failure — it never
|
|
39
|
+
# counts against the poison ladder.
|
|
39
40
|
class Interrupted < Error; end
|
|
40
41
|
end
|
data/lib/xeno/hooks.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
4
|
# Observe-only event handlers from agent/hooks/*.rb:
|
|
3
5
|
#
|
|
@@ -7,16 +9,11 @@ module Xeno
|
|
|
7
9
|
#
|
|
8
10
|
# Xeno.hook "*" do |event| ... end # every event
|
|
9
11
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
# or inject model context. A raising handler is logged and skipped — a
|
|
16
|
-
# hook can never break the runtime or fail the turn.
|
|
17
|
-
# - AT-LEAST-ONCE: replayed steps re-emit new events with new indexes.
|
|
18
|
-
# Key once-per-step side effects on (turn_id, step) from the data; key
|
|
19
|
-
# stored content on (session_id, index).
|
|
12
|
+
# Handlers fire after the event row commits, typed handlers before the wildcard, with the
|
|
13
|
+
# Xeno::Event record as the argument. Return values are ignored and a raising handler is logged
|
|
14
|
+
# and skipped — a hook can never veto, inject context, or fail the turn. Delivery is
|
|
15
|
+
# at-least-once: replayed steps re-emit events, so key side effects on (turn_id, step) and stored
|
|
16
|
+
# content on (session_id, index).
|
|
20
17
|
module Hooks
|
|
21
18
|
module_function
|
|
22
19
|
|
data/lib/xeno/info.rb
CHANGED
data/lib/xeno/inputs.rb
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
|
|
6
|
-
# back in.
|
|
7
|
-
module Inputs
|
|
4
|
+
# Resolves parked work: approvals, denials, and question answers. Each resolution updates its
|
|
5
|
+
# action; when nothing on the turn still awaits input, the turn flips back to pending and the
|
|
6
|
+
# resume job enqueues. A parked session holds no process — this is the only way back in.
|
|
7
|
+
module Inputs # :nodoc:
|
|
8
8
|
module_function
|
|
9
9
|
|
|
10
10
|
def approve!(action, principal: nil)
|
|
11
11
|
resolve(action) do
|
|
12
12
|
action.update!(status: "approved", resolved_at: Time.current, resolved_by: principal)
|
|
13
|
+
mirror_decision(action, "approved")
|
|
13
14
|
end
|
|
14
15
|
end
|
|
15
16
|
|
|
@@ -22,6 +23,7 @@ module Xeno
|
|
|
22
23
|
resolved_at: Time.current,
|
|
23
24
|
resolved_by: principal
|
|
24
25
|
)
|
|
26
|
+
mirror_decision(action, "denied")
|
|
25
27
|
end
|
|
26
28
|
end
|
|
27
29
|
|
|
@@ -38,14 +40,20 @@ module Xeno
|
|
|
38
40
|
end
|
|
39
41
|
end
|
|
40
42
|
|
|
41
|
-
#
|
|
42
|
-
# the
|
|
43
|
-
#
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
43
|
+
# The action row stays authoritative (kind, payload, who, when); the decision is mirrored onto
|
|
44
|
+
# the standard tool-call column so any RubyLLM-aware tooling reads it natively. Questions have
|
|
45
|
+
# no place there and are not mirrored.
|
|
46
|
+
def mirror_decision(action, decision)
|
|
47
|
+
return unless action.kind == "tool"
|
|
48
|
+
|
|
49
|
+
record = RubyLLM::ActiveRecord::ToolCall.find_by(tool_call_id: action.tool_call_id)
|
|
50
|
+
record&.update!(approval: decision)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Guards the state, applies the resolution, and resumes when the turn has nothing left to wait
|
|
54
|
+
# for. Resolution, status flip, and resume enqueue commit in one transaction, so on a DB-backed
|
|
55
|
+
# queue a crash cannot separate "approved" from "job exists". Backends that defer or lose the
|
|
56
|
+
# enqueue fall back to the reaper.
|
|
49
57
|
def resolve(action)
|
|
50
58
|
unless action.status == "pending_approval"
|
|
51
59
|
raise Xeno::Error, "action #{action.id} is not awaiting input (status: #{action.status})"
|
|
@@ -58,14 +66,13 @@ module Xeno
|
|
|
58
66
|
action
|
|
59
67
|
end
|
|
60
68
|
|
|
61
|
-
# Resuming with unanswered inputs would generate against partial tool
|
|
62
|
-
#
|
|
63
|
-
# so don't bother waking it until everything is resolved.
|
|
69
|
+
# Resuming with unanswered inputs would generate against partial tool results — an invalid
|
|
70
|
+
# provider state the runner would re-park on. Wake it only when everything is resolved.
|
|
64
71
|
def resume_turn(turn)
|
|
65
72
|
return if turn.actions.where(status: "pending_approval").exists?
|
|
66
73
|
|
|
67
|
-
# Resumes are human-driven and unbounded — counted apart from the
|
|
68
|
-
#
|
|
74
|
+
# Resumes are human-driven and unbounded — counted apart from the failure `attempts` so
|
|
75
|
+
# approvals can never poison the turn.
|
|
69
76
|
resumed = Turn.where(id: turn.id, status: "waiting")
|
|
70
77
|
.update_all("status = 'pending', resumes = resumes + 1")
|
|
71
78
|
return unless resumed == 1
|
data/lib/xeno/reaper.rb
CHANGED
|
@@ -1,26 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
# running but has no live owner and re-enqueues it. Duplicate-safe by
|
|
5
|
-
# construction — the claim CAS means a redundant TurnJob exits quietly,
|
|
6
|
-
# and a poisoned turn fails properly at claim time.
|
|
4
|
+
# Finds turns that should be running but have no live owner and re-enqueues them. Duplicate-safe:
|
|
5
|
+
# the claim CAS makes a redundant TurnJob exit quietly, and a poisoned turn fails at claim time.
|
|
7
6
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
|
|
11
|
-
# periodic work: `Xeno::ReaperJob.perform_later` or `bin/rails xeno:reap`.
|
|
12
|
-
module Reaper
|
|
7
|
+
# ReaperJob rides Solid Queue's recurring machinery. Other queue backends trigger it however they
|
|
8
|
+
# run periodic work: Xeno::ReaperJob.perform_later or `bin/rails xeno:reap`.
|
|
9
|
+
module Reaper # :nodoc:
|
|
13
10
|
module_function
|
|
14
11
|
|
|
15
12
|
# One sweep. Rescues, in order:
|
|
16
|
-
# 1. running turns with a stale heartbeat — the owner died
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
# ever came back (lost/discarded job).
|
|
13
|
+
# 1. running turns with a stale heartbeat — the owner died or the
|
|
14
|
+
# queue exhausted its retries; claim! reclaims these directly.
|
|
15
|
+
# 2. stale pending turns — staged or released for retry, but the job
|
|
16
|
+
# never came back.
|
|
21
17
|
# 3. waiting turns with nothing left to wait for — every action
|
|
22
|
-
# resolved but the resume enqueue never happened
|
|
23
|
-
#
|
|
18
|
+
# resolved but the resume enqueue never happened.
|
|
19
|
+
# Genuinely parked turns are never touched.
|
|
24
20
|
def sweep!(stale_before: Xeno.config.turn_stale_after.ago)
|
|
25
21
|
rescued = []
|
|
26
22
|
|
data/lib/xeno/schedules.rb
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require "yaml"
|
|
2
4
|
|
|
3
5
|
module Xeno
|
|
4
|
-
# Compiles agent/schedules/*.md into Solid Queue recurring entries
|
|
5
|
-
# compile-to-the-queue's-native-scheduler, not a dispatcher. No polling
|
|
6
|
+
# Compiles agent/schedules/*.md into Solid Queue recurring entries. There is no polling
|
|
6
7
|
# dispatcher: the queue's own recurring machinery fires ScheduleJob.
|
|
7
8
|
#
|
|
8
|
-
# Only keys under the managed prefix are touched
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
|
|
13
|
-
# owner), so it goes into every section including development.
|
|
14
|
-
module Schedules
|
|
9
|
+
# Only keys under the managed prefix are touched, so hand-written entries survive a sync. User
|
|
10
|
+
# schedules are written for every environment except development, where the dispatch endpoint
|
|
11
|
+
# triggers them by name. The reaper entry is runtime infrastructure and goes into every
|
|
12
|
+
# environment.
|
|
13
|
+
module Schedules # :nodoc:
|
|
15
14
|
MANAGED_PREFIX = "xeno_".freeze
|
|
16
15
|
REAPER_KEY = "#{MANAGED_PREFIX}runtime_reaper".freeze
|
|
17
16
|
|
data/lib/xeno/session_state.rb
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
|
-
# The per-session KV store: working state for
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
# what the last worker wrote); writes take the session row lock, so
|
|
7
|
-
# read-modify-write merges never lose entries. Cleared by `reset`; it is
|
|
8
|
-
# NOT long-term memory — durable knowledge belongs in your own models.
|
|
4
|
+
# The per-session KV store: working state for one conversation, not long-term memory. Values
|
|
5
|
+
# round-trip through JSON (symbols become strings; unserializable values are rejected up front).
|
|
6
|
+
# Reads always hit the database, and writes take the session row lock so read-modify-write merges
|
|
7
|
+
# never lose entries. Cleared by `reset`.
|
|
9
8
|
class SessionState
|
|
10
9
|
def initialize(session)
|
|
11
10
|
@session = session
|
|
@@ -20,8 +19,8 @@ module Xeno
|
|
|
20
19
|
current
|
|
21
20
|
end
|
|
22
21
|
|
|
23
|
-
# Merges the entries in one locked write. Keys stringify; values must
|
|
24
|
-
#
|
|
22
|
+
# Merges the entries in one locked write. Keys stringify; values must be JSON-serializable
|
|
23
|
+
# (Xeno::Error otherwise).
|
|
25
24
|
def update(entries)
|
|
26
25
|
normalized = normalize(entries)
|
|
27
26
|
@session.with_lock do
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require "securerandom"
|
|
2
4
|
require "fileutils"
|
|
3
5
|
|
|
@@ -5,13 +7,12 @@ module Xeno
|
|
|
5
7
|
module Standalone
|
|
6
8
|
module_function
|
|
7
9
|
|
|
8
|
-
# A persisted local secret so sessions survive restarts without
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# looser files are tightened on read.
|
|
10
|
+
# A persisted local secret so standalone sessions survive restarts without anyone managing
|
|
11
|
+
# SECRET_KEY_BASE. Owner-only (0600) — it signs cookies/sessions; looser pre-existing files are
|
|
12
|
+
# tightened on read.
|
|
12
13
|
#
|
|
13
|
-
# Lives apart from xeno/standalone so tests can exercise it without
|
|
14
|
-
#
|
|
14
|
+
# Lives apart from xeno/standalone so tests can exercise it without defining the
|
|
15
|
+
# Rails::Application (which self-registers on inheritance).
|
|
15
16
|
def local_secret(root)
|
|
16
17
|
path = root.join("storage", ".local_secret")
|
|
17
18
|
FileUtils.mkdir_p(path.dirname)
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Xeno
|
|
2
4
|
module Standalone
|
|
3
5
|
module_function
|
|
4
6
|
|
|
5
|
-
# First boot only (the registry table is empty):
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# keys. Never fatal, never repeated once the table has rows; skippable
|
|
10
|
-
# with XENO_SKIP_MODEL_REFRESH=1 (deterministic scripts, offline boots).
|
|
7
|
+
# First boot only (the registry table is empty): pulls the live model catalog so current model
|
|
8
|
+
# ids resolve without a manual refresh. The definition loads first because agent.rb's
|
|
9
|
+
# RubyLLM.configure sets the provider keys. Never fatal, never repeated once the table has rows;
|
|
10
|
+
# XENO_SKIP_MODEL_REFRESH=1 skips it.
|
|
11
11
|
def refresh_models!
|
|
12
12
|
return if ENV["XENO_SKIP_MODEL_REFRESH"]
|
|
13
13
|
|
data/lib/xeno/standalone/puma.rb
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Gem-owned puma config for standalone mode (`xeno server`): the whole stack — web + Solid Queue
|
|
4
|
+
# supervisor — inside one Puma process.
|
|
3
5
|
threads_count = ENV.fetch("RAILS_MAX_THREADS", 5).to_i
|
|
4
6
|
threads threads_count, threads_count
|
|
5
7
|
|
|
6
|
-
# Loopback by default: development runs with the dev auth bypass, and an
|
|
7
|
-
#
|
|
8
|
-
# HOST=0.0.0.0 for containers/proxies that need it.
|
|
8
|
+
# Loopback by default: development runs with the dev auth bypass, and an unauthenticated dev UI must
|
|
9
|
+
# not be exposed to the LAN. Opt out with HOST=0.0.0.0 for containers/proxies that need it.
|
|
9
10
|
bind "tcp://#{ENV.fetch("HOST", "127.0.0.1")}:#{ENV.fetch("PORT", 3000)}"
|
|
10
11
|
|
|
11
12
|
plugin :solid_queue if ENV.fetch("SOLID_QUEUE_IN_PUMA", "1") != "0"
|
|
12
13
|
|
|
13
|
-
# Ctrl-C must win. Live SSE streams
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
# Hard-stop leftover streams after 5s; xeno's state is rows, nothing is lost.
|
|
14
|
+
# Ctrl-C must win. Live SSE streams are in-flight requests and Puma waits for in-flight requests
|
|
15
|
+
# forever by default — an open browser tab made shutdown hang. Hard-stop leftover streams after 5s;
|
|
16
|
+
# state is rows, nothing is lost.
|
|
17
17
|
force_shutdown_after ENV.fetch("XENO_FORCE_SHUTDOWN_AFTER", 5).to_i
|
data/lib/xeno/standalone.rb
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
#
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# = Standalone mode
|
|
4
|
+
#
|
|
5
|
+
# An agent shouldn't need a whole Rails app of its own. `xeno new my-agent` scaffolds a directory
|
|
6
|
+
# with a handful of visible files — agent/, a Gemfile, config.ru, .gitignore — and the entire
|
|
7
|
+
# Rails::Application lives here, inside the gem:
|
|
4
8
|
#
|
|
5
9
|
# # config.ru
|
|
6
10
|
# require "xeno/standalone"
|
|
7
11
|
# run Xeno.rails_app
|
|
8
12
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
13
|
+
# `xeno server` boots it as one process with zero external services: SQLite for storage, Solid
|
|
14
|
+
# Queue inside Puma for jobs, migrations applied at boot. When the agent outgrows this, mount the
|
|
15
|
+
# engine in a full Rails app and bring the agent/ directory along unchanged.
|
|
11
16
|
require "rails"
|
|
12
17
|
require "active_model/railtie"
|
|
13
18
|
require "active_record/railtie"
|
|
@@ -15,8 +20,8 @@ require "active_job/railtie"
|
|
|
15
20
|
require "action_controller/railtie"
|
|
16
21
|
require "action_view/railtie"
|
|
17
22
|
|
|
18
|
-
# A config.ru-only app has no Bundler.require(*Rails.groups); pull in what
|
|
19
|
-
#
|
|
23
|
+
# A config.ru-only app has no Bundler.require(*Rails.groups); pull in what the scaffolded Gemfile
|
|
24
|
+
# provides.
|
|
20
25
|
require "solid_queue"
|
|
21
26
|
require "sqlite3"
|
|
22
27
|
|
|
@@ -51,9 +56,8 @@ module Xeno
|
|
|
51
56
|
"adapter" => "sqlite3",
|
|
52
57
|
"database" => ENV.fetch("XENO_DATABASE", "storage/xeno_#{Rails.env}.sqlite3"),
|
|
53
58
|
"pool" => ENV.fetch("RAILS_MAX_THREADS", 10).to_i,
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
# locks ("default: no wait") and every collision raises
|
|
59
|
+
# Web and Solid Queue's forked processes share this one file; without a busy timeout the
|
|
60
|
+
# sqlite3 adapter does not wait for locks and every collision raises
|
|
57
61
|
# SQLite3::BusyException. Same 5s default rails new generates.
|
|
58
62
|
"timeout" => ENV.fetch("XENO_DATABASE_TIMEOUT", 5000).to_i
|
|
59
63
|
}
|
|
@@ -64,12 +68,11 @@ module Xeno
|
|
|
64
68
|
config.secret_key_base = ENV["SECRET_KEY_BASE"] || Xeno::Standalone.local_secret(APP_ROOT)
|
|
65
69
|
end
|
|
66
70
|
|
|
67
|
-
module Standalone
|
|
71
|
+
module Standalone # :nodoc:
|
|
68
72
|
module_function
|
|
69
73
|
|
|
70
|
-
# Boot: initialize the app, then bring the database up to date — the
|
|
71
|
-
#
|
|
72
|
-
# the solid_queue gem's install template).
|
|
74
|
+
# Boot: initialize the app, then bring the database up to date — the engine's migrations plus
|
|
75
|
+
# Solid Queue's tables (loaded straight from the solid_queue gem's install template).
|
|
73
76
|
def boot!
|
|
74
77
|
ENV["RAILS_ENV"] ||= "development"
|
|
75
78
|
FileUtils.mkdir_p(StandaloneApplication::APP_ROOT.join("storage"))
|
|
@@ -89,11 +92,9 @@ module Xeno
|
|
|
89
92
|
Rails.application
|
|
90
93
|
end
|
|
91
94
|
|
|
92
|
-
# Solid Queue's recurring machinery
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
# visible files; the env var — read by Solid Queue's supervisor, which
|
|
96
|
-
# starts after config.ru has loaded — points the scheduler at it.
|
|
95
|
+
# Solid Queue's recurring machinery fires the reaper (always) and the compiled schedules
|
|
96
|
+
# (outside development) in standalone mode. The file lives under storage/ so the scaffold stays
|
|
97
|
+
# four visible files; the env var points Solid Queue's supervisor at it.
|
|
97
98
|
def write_recurring_config!
|
|
98
99
|
section = { Xeno::Schedules::REAPER_KEY => Xeno::Schedules.reaper_entry }
|
|
99
100
|
unless Rails.env.development?
|
|
@@ -123,8 +124,8 @@ module Xeno
|
|
|
123
124
|
File.join(spec.gem_dir, "lib", "generators", "solid_queue", "install", "templates", "db", "queue_schema.rb")
|
|
124
125
|
end
|
|
125
126
|
|
|
126
|
-
# The gem-owned puma config used by `xeno server` — Solid Queue runs
|
|
127
|
-
#
|
|
127
|
+
# The gem-owned puma config used by `xeno server` — Solid Queue runs inside Puma
|
|
128
|
+
# (SOLID_QUEUE_IN_PUMA), one process total.
|
|
128
129
|
def puma_config_path
|
|
129
130
|
File.expand_path("standalone/puma.rb", __dir__)
|
|
130
131
|
end
|