insika 0.0.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +361 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +366 -0
- data/docs/AGENTS.md +618 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +117 -0
- data/docs/DEPLOY.md +354 -0
- data/docs/EMBEDDING.md +198 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +232 -0
- data/docs/OBSERVABILITY.md +374 -0
- data/docs/PLUGINS.md +211 -0
- data/docs/REFINEMENT.md +477 -0
- data/docs/RELEASING.md +70 -0
- data/docs/RUNNING-LOCAL.md +153 -0
- data/docs/SANDBOX.md +114 -0
- data/docs/SECURITY.md +375 -0
- data/docs/SKILLS.md +284 -0
- data/docs/TOOLS.md +302 -0
- data/docs/WHY.md +137 -0
- data/docs/WORKFLOWS.md +225 -0
- data/docs/build.md +14 -0
- data/docs/index.md +68 -0
- data/docs/onboarding/start.md +126 -0
- data/docs/operate.md +12 -0
- data/docs/ship.md +10 -0
- data/docs/understand.md +10 -0
- data/lib/insika/agent_file_store.rb +125 -0
- data/lib/insika/agent_profile.rb +255 -0
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/budget_ledger.rb +135 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +153 -0
- data/lib/insika/channel_registry.rb +30 -0
- data/lib/insika/channels/relay.rb +178 -0
- data/lib/insika/channels/web/widget.js +283 -0
- data/lib/insika/channels/web.rb +211 -0
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +303 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +58 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +43 -0
- data/lib/insika/commands/approve_action.rb +46 -0
- data/lib/insika/commands/cancel_task.rb +33 -0
- data/lib/insika/commands/create_agent.rb +54 -0
- data/lib/insika/commands/create_session.rb +67 -0
- data/lib/insika/commands/delete_agent.rb +33 -0
- data/lib/insika/commands/delete_agent_file.rb +50 -0
- data/lib/insika/commands/delete_data_tool.rb +33 -0
- data/lib/insika/commands/delete_llm_provider.rb +36 -0
- data/lib/insika/commands/delete_mcp.rb +30 -0
- data/lib/insika/commands/delete_skill.rb +43 -0
- data/lib/insika/commands/delete_system_file.rb +29 -0
- data/lib/insika/commands/gate_refinement.rb +245 -0
- data/lib/insika/commands/import_mcp_tools.rb +48 -0
- data/lib/insika/commands/import_tools.rb +81 -0
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- data/lib/insika/commands/memory_add_note.rb +32 -0
- data/lib/insika/commands/memory_forget_fact.rb +32 -0
- data/lib/insika/commands/memory_put_fact.rb +35 -0
- data/lib/insika/commands/pause_task.rb +29 -0
- data/lib/insika/commands/resolve_refinement.rb +126 -0
- data/lib/insika/commands/restore_agent_file.rb +36 -0
- data/lib/insika/commands/restore_data_tool.rb +34 -0
- data/lib/insika/commands/restore_system_file.rb +31 -0
- data/lib/insika/commands/resume_task.rb +85 -0
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_refinement.rb +133 -0
- data/lib/insika/commands/send_message.rb +150 -0
- data/lib/insika/commands/set_agent_tools.rb +39 -0
- data/lib/insika/commands/set_skill_agents.rb +112 -0
- data/lib/insika/commands/trigger_workflow.rb +80 -0
- data/lib/insika/commands/update_agent.rb +49 -0
- data/lib/insika/commands/update_settings.rb +33 -0
- data/lib/insika/commands/upsert_llm_provider.rb +34 -0
- data/lib/insika/commands/upsert_mcp.rb +32 -0
- data/lib/insika/commands/write_agent_file.rb +57 -0
- data/lib/insika/commands/write_data_tool.rb +43 -0
- data/lib/insika/commands/write_golden.rb +58 -0
- data/lib/insika/commands/write_skill.rb +60 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +89 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +43 -0
- data/lib/insika/context/priority.rb +30 -0
- data/lib/insika/context/provider.rb +19 -0
- data/lib/insika/context/providers/memory.rb +60 -0
- data/lib/insika/context/providers/prompt.rb +105 -0
- data/lib/insika/context/providers/request.rb +32 -0
- data/lib/insika/context/providers/session.rb +123 -0
- data/lib/insika/context/providers/skill.rb +24 -0
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/context_trace_store.rb +92 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +539 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +382 -0
- data/lib/insika/dsl/server_boot.rb +98 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +364 -0
- data/lib/insika/edge_limiter.rb +268 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +249 -0
- data/lib/insika/errors.rb +201 -0
- data/lib/insika/evals/assertions.rb +247 -0
- data/lib/insika/evals/baseline.rb +69 -0
- data/lib/insika/evals/golden.rb +172 -0
- data/lib/insika/evals/judge.rb +225 -0
- data/lib/insika/evals/pairwise.rb +178 -0
- data/lib/insika/evals/report.rb +115 -0
- data/lib/insika/evals/runner.rb +141 -0
- data/lib/insika/evals/transport.rb +178 -0
- data/lib/insika/event.rb +18 -0
- data/lib/insika/event_stream.rb +132 -0
- data/lib/insika/executor.rb +1995 -0
- data/lib/insika/frontmatter.rb +42 -0
- data/lib/insika/golden_store.rb +145 -0
- data/lib/insika/hooks.rb +48 -0
- data/lib/insika/http_client.rb +63 -0
- data/lib/insika/inbound_log.rb +84 -0
- data/lib/insika/llm_configurator.rb +99 -0
- data/lib/insika/llm_provider_store.rb +83 -0
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +67 -0
- data/lib/insika/mcp_store.rb +115 -0
- data/lib/insika/mcp_tool_ingestor.rb +143 -0
- data/lib/insika/memory_store.rb +93 -0
- data/lib/insika/message_origin.rb +76 -0
- data/lib/insika/middleware.rb +36 -0
- data/lib/insika/model_policy.rb +52 -0
- data/lib/insika/model_resolver.rb +176 -0
- data/lib/insika/model_selection.rb +115 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +102 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +123 -0
- data/lib/insika/pending_action_store.rb +120 -0
- data/lib/insika/plugin/loader.rb +356 -0
- data/lib/insika/plugin.rb +35 -0
- data/lib/insika/policy/engine.rb +83 -0
- data/lib/insika/policy/policy.rb +120 -0
- data/lib/insika/policy_registry.rb +23 -0
- data/lib/insika/profile_source.rb +143 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +168 -0
- data/lib/insika/refinement/candidate.rb +159 -0
- data/lib/insika/refinement/evidence_collector.rb +371 -0
- data/lib/insika/refinement/gate.rb +234 -0
- data/lib/insika/refinement/panel.rb +222 -0
- data/lib/insika/refinement/proposer.rb +262 -0
- data/lib/insika/refinement_store.rb +295 -0
- data/lib/insika/registry.rb +59 -0
- data/lib/insika/reliability.rb +185 -0
- data/lib/insika/safety/config.rb +109 -0
- data/lib/insika/safety/detectors.rb +176 -0
- data/lib/insika/safety/factory.rb +102 -0
- data/lib/insika/safety/input_guardrail.rb +102 -0
- data/lib/insika/safety/moderator.rb +94 -0
- data/lib/insika/safety/output_filter.rb +79 -0
- data/lib/insika/safety/output_validator.rb +101 -0
- data/lib/insika/safety/safe_responses.rb +47 -0
- data/lib/insika/sandbox/boundary.rb +93 -0
- data/lib/insika/sandbox/docker.rb +74 -0
- data/lib/insika/sandbox/local.rb +33 -0
- data/lib/insika/sandbox/runner.rb +80 -0
- data/lib/insika/sandbox.rb +85 -0
- data/lib/insika/schema_guard.rb +147 -0
- data/lib/insika/secret_masking.rb +34 -0
- data/lib/insika/server/a2a/agent_card.rb +27 -0
- data/lib/insika/server/a2a/app.rb +112 -0
- data/lib/insika/server/a2a/client.rb +101 -0
- data/lib/insika/server/a2a/errors.rb +32 -0
- data/lib/insika/server/a2a/http.rb +42 -0
- data/lib/insika/server/a2a/message.rb +27 -0
- data/lib/insika/server/a2a/protocol.rb +45 -0
- data/lib/insika/server/a2a/remotes.rb +25 -0
- data/lib/insika/server/a2a/task_projection.rb +40 -0
- data/lib/insika/server/app.rb +1022 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +118 -0
- data/lib/insika/server/responses.rb +165 -0
- data/lib/insika/server/sse_body.rb +96 -0
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +162 -0
- data/lib/insika/session_store.rb +143 -0
- data/lib/insika/settings_store.rb +154 -0
- data/lib/insika/shutdown.rb +125 -0
- data/lib/insika/skill_catalog.rb +220 -0
- data/lib/insika/skill_store.rb +127 -0
- data/lib/insika/steer_injector.rb +110 -0
- data/lib/insika/store.rb +52 -0
- data/lib/insika/stores/memory.rb +123 -0
- data/lib/insika/stores/sqlite.rb +183 -0
- data/lib/insika/studio/app.rb +1693 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +70 -0
- data/lib/insika/studio/forms.rb +335 -0
- data/lib/insika/studio/nav_icons.rb +31 -0
- data/lib/insika/studio/views/_message.erb +44 -0
- data/lib/insika/studio/views/agent_detail.erb +285 -0
- data/lib/insika/studio/views/agents.erb +63 -0
- data/lib/insika/studio/views/approvals.erb +41 -0
- data/lib/insika/studio/views/chats.erb +34 -0
- data/lib/insika/studio/views/evals.erb +83 -0
- data/lib/insika/studio/views/home.erb +72 -0
- data/lib/insika/studio/views/layout.erb +94 -0
- data/lib/insika/studio/views/login.erb +17 -0
- data/lib/insika/studio/views/mcp.erb +91 -0
- data/lib/insika/studio/views/not_found.erb +5 -0
- data/lib/insika/studio/views/playground.erb +47 -0
- data/lib/insika/studio/views/refinement.erb +234 -0
- data/lib/insika/studio/views/session.erb +137 -0
- data/lib/insika/studio/views/settings.erb +168 -0
- data/lib/insika/studio/views/skills.erb +141 -0
- data/lib/insika/studio/views/system_files.erb +65 -0
- data/lib/insika/studio/views/task.erb +105 -0
- data/lib/insika/studio/views/tasks.erb +33 -0
- data/lib/insika/studio/views/tool_edit.erb +107 -0
- data/lib/insika/studio/views/tools.erb +89 -0
- data/lib/insika/subagent_graph.rb +96 -0
- data/lib/insika/system_file_store.rb +96 -0
- data/lib/insika/task_actor.rb +128 -0
- data/lib/insika/task_store.rb +250 -0
- data/lib/insika/telemetry/pricing.rb +104 -0
- data/lib/insika/telemetry/recorder.rb +228 -0
- data/lib/insika/telemetry.rb +127 -0
- data/lib/insika/testing/store_contract.rb +270 -0
- data/lib/insika/tick.rb +122 -0
- data/lib/insika/token_estimator.rb +16 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +140 -0
- data/lib/insika/tool_catalog.rb +89 -0
- data/lib/insika/tool_definition.rb +518 -0
- data/lib/insika/tool_envelope.rb +140 -0
- data/lib/insika/tool_manifest.rb +218 -0
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_registry.rb +21 -0
- data/lib/insika/tool_store.rb +135 -0
- data/lib/insika/tool_trace_store.rb +92 -0
- data/lib/insika/tools/a2a_remote.rb +48 -0
- data/lib/insika/tools/agent_enum.rb +68 -0
- data/lib/insika/tools/concurrency.rb +54 -0
- data/lib/insika/tools/data_defined_tool.rb +219 -0
- data/lib/insika/tools/load_skill.rb +99 -0
- data/lib/insika/tools/remember.rb +53 -0
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +75 -0
- data/lib/insika/tools/subagents.rb +77 -0
- data/lib/insika/tools/tool_search.rb +94 -0
- data/lib/insika/turn_output.rb +139 -0
- data/lib/insika/turn_state.rb +162 -0
- data/lib/insika/turn_timing.rb +56 -0
- data/lib/insika/usage_ledger.rb +47 -0
- data/lib/insika/version.rb +3 -1
- data/lib/insika/wiring/graph.rb +249 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +220 -4
- metadata +412 -8
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
require "async/queue"
|
|
5
|
+
require "time" # Time#iso8601 for the arrival record on :turn_coalesced
|
|
6
|
+
|
|
7
|
+
module Insika
|
|
8
|
+
# Sessions as Actors: one fiber per session with a FIFO queue
|
|
9
|
+
# of turns, executed ONE AT A TIME. Restores the "one owner at a time"
|
|
10
|
+
# invariant of the transcript that two concurrent `send_message` calls on the
|
|
11
|
+
# same `session_id` would break (read-modify-write on the Session Store). Turns
|
|
12
|
+
# from distinct sessions stay concurrent; one-shot/history (no session_id) do
|
|
13
|
+
# not go through here.
|
|
14
|
+
#
|
|
15
|
+
# Lives in the SUPERVISED scope: the loop is a child of the supervisor, not of
|
|
16
|
+
# the request — it outlives the connection. The turn itself (spawned by the
|
|
17
|
+
# Executor) is also born on the supervisor; the SessionActor only AWAITS it to
|
|
18
|
+
# serialize.
|
|
19
|
+
#
|
|
20
|
+
# it is also where an inbound message for a BUSY session is routed.
|
|
21
|
+
# That decision belongs here and nowhere else — this is already the object that
|
|
22
|
+
# owns "one turn at a time for this session". Putting it in the HTTP handler
|
|
23
|
+
# would duplicate the invariant; putting it in the Executor would mix turn
|
|
24
|
+
# execution with queue policy.
|
|
25
|
+
class SessionActor
|
|
26
|
+
def initialize(session_id:, executor:, parent: Async::Task.current)
|
|
27
|
+
@session_id = session_id
|
|
28
|
+
@executor = executor
|
|
29
|
+
@queue = Async::Queue.new
|
|
30
|
+
@running = false
|
|
31
|
+
# The turn currently sitting at the door: created and :queued, but not yet
|
|
32
|
+
# released to run. `collect` merges into THIS one. nil whenever there is
|
|
33
|
+
# nothing mergeable — which is the common case and the safe default.
|
|
34
|
+
@pending = nil
|
|
35
|
+
@loop = parent.async { |t| t.annotate("session:#{session_id}"); run_loop }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Enqueues a turn (FIFO). Non-blocking: the handler responds with an
|
|
39
|
+
# immediate {task_id:} even if the turn stays :queued behind another. -> task.id.
|
|
40
|
+
#
|
|
41
|
+
# `policy` (a QueuePolicy) opens the debounce window for this turn; nil or a
|
|
42
|
+
# policy without a window behaves exactly as before — dequeued and run at once.
|
|
43
|
+
def enqueue(task, profile:, resume_from: nil, policy: nil)
|
|
44
|
+
@queue.enqueue([task, profile, resume_from, policy])
|
|
45
|
+
task.id
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# merge a fragment into the turn waiting at the door.
|
|
49
|
+
# -> the task id it joined, or nil when there is nothing to merge into (no
|
|
50
|
+
# pending turn, the window has closed, or the turn already started). nil is
|
|
51
|
+
# the caller's signal to create a task of its own.
|
|
52
|
+
#
|
|
53
|
+
# Runs on the REQUEST's fiber, not the loop's; both are on the same reactor
|
|
54
|
+
# and neither yields between the check and the write below, so the "is it
|
|
55
|
+
# still mergeable" test and the append cannot interleave.
|
|
56
|
+
def collect(text)
|
|
57
|
+
pending = @pending
|
|
58
|
+
return nil if pending.nil?
|
|
59
|
+
|
|
60
|
+
@executor.task_store.append_message(pending[:task_id], text)
|
|
61
|
+
pending[:count] += 1
|
|
62
|
+
# A merged fragment leaves NO task of its own (see #hold_at_the_door), so this
|
|
63
|
+
# is the only record that it arrived as a separate message. Kept as arrival
|
|
64
|
+
# times — never the text — and shipped on :turn_coalesced, so "the customer
|
|
65
|
+
# says they sent the order number" is answerable without the store carrying an
|
|
66
|
+
# orphan task per fragment.
|
|
67
|
+
pending[:arrivals] << Time.now.utc.iso8601
|
|
68
|
+
pending[:version] += 1 # tells a sleeping debounce window that more arrived
|
|
69
|
+
pending[:task_id]
|
|
70
|
+
rescue ArgumentError
|
|
71
|
+
# The turn left :queued between the read of @pending and the append (it was
|
|
72
|
+
# released while we were deciding). Not an error: the caller falls back to
|
|
73
|
+
# creating its own task, which is exactly `followup`.
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def running? = @running
|
|
78
|
+
def depth = @queue.size
|
|
79
|
+
|
|
80
|
+
# The turn this session is running RIGHT NOW, or nil when idle or still at the
|
|
81
|
+
# door. `steer` needs the Task itself and not just its id: whether a turn can
|
|
82
|
+
# absorb a message at all depends on what kind of turn it is (a workflow has no
|
|
83
|
+
# chat), and reading that off the object avoids a store round-trip on the
|
|
84
|
+
# request's path.
|
|
85
|
+
attr_reader :current_task
|
|
86
|
+
|
|
87
|
+
# Is there a turn at the door that `collect` could still merge into?
|
|
88
|
+
def collecting? = !@pending.nil?
|
|
89
|
+
|
|
90
|
+
# Is the loop still alive? (the Executor revalidates before reusing from the
|
|
91
|
+
# cache — a dead loop would black-hole queued turns).
|
|
92
|
+
def alive? = !!@loop&.running?
|
|
93
|
+
|
|
94
|
+
# Shuts down the loop (server shutdown / tests — the loop blocks forever on
|
|
95
|
+
# dequeue when idle).
|
|
96
|
+
def stop = @loop&.stop
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def run_loop
|
|
101
|
+
loop do
|
|
102
|
+
task, profile, resume_from, policy = @queue.dequeue # blocks when empty
|
|
103
|
+
task = hold_at_the_door(task, policy)
|
|
104
|
+
@running = true
|
|
105
|
+
@current_task = task
|
|
106
|
+
begin
|
|
107
|
+
@executor.run_serial(task, profile: profile, resume_from: resume_from)
|
|
108
|
+
rescue StandardError
|
|
109
|
+
# run_serial already maps turn errors; this rescue is defense: an
|
|
110
|
+
# unexpected error must NEVER bring down the session loop (Async::Stop <
|
|
111
|
+
# Exception is not captured -> #stop ends the loop normally).
|
|
112
|
+
nil
|
|
113
|
+
ensure
|
|
114
|
+
@running = false
|
|
115
|
+
@current_task = nil
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# the debounce window. Sleeps on the LOOP's fiber, never on the
|
|
121
|
+
# request's, so the POST is acked immediately and the platform does not retry.
|
|
122
|
+
# Returns the task to run (re-read from the store when fragments merged into it,
|
|
123
|
+
# since the in-memory Task is a frozen snapshot of an older message).
|
|
124
|
+
def hold_at_the_door(task, policy)
|
|
125
|
+
return task unless policy&.debounce?
|
|
126
|
+
|
|
127
|
+
@pending = { task_id: task.id, count: 1, version: 0, arrivals: [Time.now.utc.iso8601] }
|
|
128
|
+
begin
|
|
129
|
+
wait_for_quiet(policy)
|
|
130
|
+
merged = @pending[:count]
|
|
131
|
+
arrivals = @pending[:arrivals]
|
|
132
|
+
ensure
|
|
133
|
+
# The window is closed BEFORE the turn runs, under every exit path: a
|
|
134
|
+
# `collect` that slipped in here would append to a task about to be read.
|
|
135
|
+
@pending = nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
return task if merged == 1
|
|
139
|
+
|
|
140
|
+
@executor.emit_coalesced(task, merged: merged, arrivals: arrivals)
|
|
141
|
+
@executor.task_store.find(task.id) || task
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Sleeps in `debounce_ms` slices, restarting whenever a fragment arrives
|
|
145
|
+
# (`version` moved), until either a slice passes in silence or the total
|
|
146
|
+
# deferral reaches `debounce_max_ms` — the ceiling that stops a customer who
|
|
147
|
+
# keeps typing from postponing their own answer forever.
|
|
148
|
+
def wait_for_quiet(policy)
|
|
149
|
+
quiet = policy.debounce_ms / 1000.0
|
|
150
|
+
deadline = monotonic + (policy.debounce_max_ms / 1000.0)
|
|
151
|
+
|
|
152
|
+
loop do
|
|
153
|
+
mark = @pending[:version]
|
|
154
|
+
Async::Task.current.sleep(quiet)
|
|
155
|
+
break if @pending[:version] == mark # a full slice of silence
|
|
156
|
+
break if monotonic >= deadline
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# Domain store for sessions. Persists transcript + vars
|
|
8
|
+
# over an injected Insika::Store, with a fixed schema
|
|
9
|
+
# `session:<id>` in the "sessions" scope.
|
|
10
|
+
#
|
|
11
|
+
# The persisted transcript is the SOURCE OF TRUTH for reconstruction; live
|
|
12
|
+
# events are just delivery state. The message shape
|
|
13
|
+
# (`{"role"=>, "content"=>}`, role ∈ user|assistant|system|tool) is the
|
|
14
|
+
# same one `Runner#seed_history` already consumes — the Executor converts nothing.
|
|
15
|
+
#
|
|
16
|
+
# Normalizes symbol→string on WRITE (the backend only guarantees round-trip of
|
|
17
|
+
# JSON types); READ returns the data as it comes from the backend
|
|
18
|
+
# (string keys), never symmetrizing back to symbols.
|
|
19
|
+
class SessionStore
|
|
20
|
+
include Coercion
|
|
21
|
+
|
|
22
|
+
SCOPE = "sessions"
|
|
23
|
+
KEY_PREFIX = "session:"
|
|
24
|
+
|
|
25
|
+
Session = Data.define(:id, :messages, :vars, :memory_refs,
|
|
26
|
+
:created_at, :updated_at)
|
|
27
|
+
|
|
28
|
+
# store: any Insika::Store (Memory, SQLite, ...) — injected by the
|
|
29
|
+
# composition root (config/wiring.rb). SessionStore does not know the
|
|
30
|
+
# concrete backend.
|
|
31
|
+
def initialize(store:)
|
|
32
|
+
@store = store
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# -> Session; ArgumentError if id already exists (a duplicate session is a
|
|
36
|
+
# domain violation — it never overwrites silently).
|
|
37
|
+
def create(id: SecureRandom.uuid, vars: {})
|
|
38
|
+
key = key_for(id)
|
|
39
|
+
raise ArgumentError, "session already exists: #{id}" unless @store.get(SCOPE, key).nil?
|
|
40
|
+
|
|
41
|
+
now = timestamp
|
|
42
|
+
record = {
|
|
43
|
+
"id" => id.to_s,
|
|
44
|
+
"messages" => [],
|
|
45
|
+
"vars" => deep_stringify(vars),
|
|
46
|
+
"memory_refs" => [],
|
|
47
|
+
"created_at" => now,
|
|
48
|
+
"updated_at" => now
|
|
49
|
+
}
|
|
50
|
+
@store.set(SCOPE, key, record)
|
|
51
|
+
to_session(record)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# -> Session | nil
|
|
55
|
+
def find(id)
|
|
56
|
+
record = @store.get(SCOPE, key_for(id))
|
|
57
|
+
record && to_session(record)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# -> Session (transcript += messages). Read-modify-write on the task's own
|
|
61
|
+
# fiber, without a lock. Each message gets an "at" (ISO8601 UTC) if not
|
|
62
|
+
# provided. NotFoundError if the session does not exist.
|
|
63
|
+
#
|
|
64
|
+
# CONCURRENCY LIMITATION (R2c): the RMW (read record -> += -> set) is
|
|
65
|
+
# atomic ONLY because the SessionActor serializes turns of the same session
|
|
66
|
+
# (one owner at a time). That serialization exists solely in SUPERVISED mode
|
|
67
|
+
# (the actor loop lives on the supervisor). Two concurrent send_message on the
|
|
68
|
+
# same session_id OUTSIDE that path (e.g. calling append_messages directly, or
|
|
69
|
+
# a non-supervised deployment) would interleave read/set and LOSE messages —
|
|
70
|
+
# there is no compare-and-swap here. Route same-session writes through the
|
|
71
|
+
# SessionActor; see session_actor.rb.
|
|
72
|
+
def append_messages(id, messages)
|
|
73
|
+
record = fetch!(id)
|
|
74
|
+
incoming = (messages.is_a?(Hash) ? [messages] : Array(messages))
|
|
75
|
+
.map { |msg| stamp(deep_stringify(msg)) }
|
|
76
|
+
record["messages"] += incoming
|
|
77
|
+
record["updated_at"] = timestamp
|
|
78
|
+
@store.set(SCOPE, key_for(id), record)
|
|
79
|
+
to_session(record)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# -> Session (SHALLOW merge: an existing nested key is replaced wholesale,
|
|
83
|
+
# not merged). NotFoundError if absent.
|
|
84
|
+
def update_vars(id, vars)
|
|
85
|
+
record = fetch!(id)
|
|
86
|
+
record["vars"] = record["vars"].merge(deep_stringify(vars))
|
|
87
|
+
record["updated_at"] = timestamp
|
|
88
|
+
@store.set(SCOPE, key_for(id), record)
|
|
89
|
+
to_session(record)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# -> bool (delegates to the backend: false for a nonexistent id)
|
|
93
|
+
def delete(id)
|
|
94
|
+
@store.delete(SCOPE, key_for(id))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# -> enumerates ids without the "session:" prefix. Without a block,
|
|
98
|
+
# returns an Enumerator.
|
|
99
|
+
def each_id
|
|
100
|
+
return enum_for(:each_id) unless block_given?
|
|
101
|
+
|
|
102
|
+
@store.list(SCOPE, KEY_PREFIX).each do |key|
|
|
103
|
+
yield key.delete_prefix(KEY_PREFIX)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def key_for(id)
|
|
110
|
+
"#{KEY_PREFIX}#{id}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Loads the raw record; NotFoundError if absent (nonexistent session ->
|
|
114
|
+
# HTTP 404). Backend errors (StoreError) propagate without re-wrapping.
|
|
115
|
+
def fetch!(id)
|
|
116
|
+
record = @store.get(SCOPE, key_for(id))
|
|
117
|
+
raise Insika::NotFoundError, "session not found: #{id}" if record.nil?
|
|
118
|
+
|
|
119
|
+
record
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def to_session(record)
|
|
123
|
+
Session.new(
|
|
124
|
+
id: record["id"],
|
|
125
|
+
messages: record["messages"],
|
|
126
|
+
vars: record["vars"],
|
|
127
|
+
memory_refs: record["memory_refs"],
|
|
128
|
+
created_at: record["created_at"],
|
|
129
|
+
updated_at: record["updated_at"]
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Stamps "at" (ISO8601 UTC) on the message when absent; preserves whatever is provided.
|
|
134
|
+
def stamp(message)
|
|
135
|
+
message["at"] ||= timestamp
|
|
136
|
+
message
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def timestamp
|
|
140
|
+
Time.now.utc.iso8601
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# GENERAL deploy settings: timeouts, streaming and
|
|
5
|
+
# compaction. A single record in the ConfigStore (scope "settings", key "general").
|
|
6
|
+
# A read always returns the DEFAULTS overlaid by whatever was authored — so
|
|
7
|
+
# a brand-new deploy (empty store) already responds with coherent config, and the Studio
|
|
8
|
+
# only persists the delta. Shallow merge at the top, deep in `compaction` (sub-hash).
|
|
9
|
+
#
|
|
10
|
+
# Not to be confused with the transport `CONFIG` (bind/port/token, ENV+freeze at
|
|
11
|
+
# boot): this is editable RUNTIME config, durable in the same backend.
|
|
12
|
+
class SettingsStore
|
|
13
|
+
SCOPE = "settings"
|
|
14
|
+
KEY = "general"
|
|
15
|
+
|
|
16
|
+
# STRICT config, settings layer (— "no silent config compat: every
|
|
17
|
+
# schema migration explicit"). The settings record carries a `schema_version`;
|
|
18
|
+
# every shape change is a numbered migration here, applied ONLY by the explicit
|
|
19
|
+
# `migrate!` (Studio settings saves never silently reinterpret old-shaped data).
|
|
20
|
+
# v1 is the baseline: a pre-versioning record (no `schema_version`) reads as 0 and
|
|
21
|
+
# `insika doctor --fix` stamps it to 1. Add a real change as MIGRATIONS[2] = proc
|
|
22
|
+
# and bump SCHEMA_VERSION.
|
|
23
|
+
SCHEMA_VERSION = 1
|
|
24
|
+
MIGRATIONS = {}.freeze # target_version(Integer) => ->(record_hash){ migrated_hash }
|
|
25
|
+
|
|
26
|
+
DEFAULTS = {
|
|
27
|
+
"schema_version" => SCHEMA_VERSION,
|
|
28
|
+
"streaming" => true,
|
|
29
|
+
"request_timeout" => 120,
|
|
30
|
+
"max_retries" => 2,
|
|
31
|
+
"turn_timeout" => 120,
|
|
32
|
+
"tool_timeout" => 30,
|
|
33
|
+
"compaction" => { "enabled" => false, "keep_last" => 20 },
|
|
34
|
+
# LLM config v2. Platform-wide model layer, resolved by the
|
|
35
|
+
# ModelResolver under an agent that pins no model of its own:
|
|
36
|
+
# default_model/default_provider -> the platform default (Chat > Agent > HERE)
|
|
37
|
+
# fallback_models -> ordered chain ["provider/model" | "model", ...] tried
|
|
38
|
+
# when the primary is NOT a user pin (source semantics)
|
|
39
|
+
# utility_model -> slot for cheap internal tasks (titles, distillation,
|
|
40
|
+
# compaction); reserved for later wiring.
|
|
41
|
+
"default_model" => nil,
|
|
42
|
+
"default_provider" => nil,
|
|
43
|
+
"fallback_models" => [],
|
|
44
|
+
"utility_model" => nil,
|
|
45
|
+
# Reasoning control (4-layer: Chat > Agent > Model > Global). `thinking`
|
|
46
|
+
# is the GLOBAL default (off/on/low/medium/high; nil = provider default);
|
|
47
|
+
# `model_params` is the PER-MODEL layer, a map "<provider/model>"|"<model>" ->
|
|
48
|
+
# { "thinking" => ... }. Both resolved by the ModelResolver.
|
|
49
|
+
"thinking" => nil,
|
|
50
|
+
"model_params" => {},
|
|
51
|
+
# Evals (panel by). The GRADERS are platform config, so
|
|
52
|
+
# the operator picks them in the Studio instead of remembering a CLI flag:
|
|
53
|
+
# judges -> [{ "model" =>, "provider" => }, …]. [] = deterministic
|
|
54
|
+
# asserts only (rubric'd cases read as judge_pending).
|
|
55
|
+
# aggregate -> median | mean | min — how the panel's scores become the one
|
|
56
|
+
# the report and the baseline read.
|
|
57
|
+
# min_agreement -> fraction of judges that must pass for the case to pass.
|
|
58
|
+
# quorum -> samples per judge (variance), on top of the panel.
|
|
59
|
+
# tolerance -> max judge-score drop before it counts as a regression.
|
|
60
|
+
# Additive key: reads overlay DEFAULTS, so no numbered migration is due (only a
|
|
61
|
+
# later SHAPE change to this key would earn one).
|
|
62
|
+
"evals" => {
|
|
63
|
+
"judges" => [],
|
|
64
|
+
"aggregate" => "median",
|
|
65
|
+
"min_agreement" => 0.5,
|
|
66
|
+
"quorum" => 1,
|
|
67
|
+
"tolerance" => 0.05
|
|
68
|
+
},
|
|
69
|
+
# Edge limits — the platform layer of the EdgeLimiter.
|
|
70
|
+
# nil/0 = off (opt-in). chat_rate_limit = turn attempts per chat per
|
|
71
|
+
# chat_rate_window (s); agent_token_ceiling = total tokens per agent per
|
|
72
|
+
# agent_token_window (s). limit_response overrides the safe reply.
|
|
73
|
+
# Per-agent overrides live in profile.limits (same keys, sans windows).
|
|
74
|
+
"edge" => {
|
|
75
|
+
"chat_rate_limit" => nil,
|
|
76
|
+
"chat_rate_window" => 60,
|
|
77
|
+
"agent_token_ceiling" => nil,
|
|
78
|
+
"agent_token_window" => 86_400,
|
|
79
|
+
"limit_response" => nil
|
|
80
|
+
}
|
|
81
|
+
}.freeze
|
|
82
|
+
|
|
83
|
+
def initialize(config_store:)
|
|
84
|
+
@cs = config_store
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# -> Hash (defaults overlaid by the authored values). String keys (Store contract).
|
|
88
|
+
def get
|
|
89
|
+
deep_merge(DEFAULTS, stored)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Merges the patch over the current one and persists. -> Hash (resulting settings).
|
|
93
|
+
# Unknown keys are preserved (the Studio decides the screen's schema).
|
|
94
|
+
def update(patch)
|
|
95
|
+
merged = deep_merge(get, stringify(patch || {}))
|
|
96
|
+
@cs.put(SCOPE, KEY, merged)
|
|
97
|
+
merged
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# RAW persisted schema version (bypasses the DEFAULTS overlay, which would always
|
|
101
|
+
# report the current one). nil = no settings persisted yet (fresh deploy — nothing
|
|
102
|
+
# to migrate); an Integer otherwise, 0 for a pre-versioning record.
|
|
103
|
+
def stored_schema_version
|
|
104
|
+
raw = stored
|
|
105
|
+
return nil if raw.empty?
|
|
106
|
+
|
|
107
|
+
Integer(raw["schema_version"] || 0)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Which numbered migrations still need to run. [] when fresh or already current.
|
|
111
|
+
def pending_migrations
|
|
112
|
+
from = stored_schema_version
|
|
113
|
+
return [] if from.nil? || from >= SCHEMA_VERSION
|
|
114
|
+
|
|
115
|
+
((from + 1)..SCHEMA_VERSION).to_a
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Applies the pending migrations EXPLICITLY (in order) and stamps the version. No-op
|
|
119
|
+
# when fresh or already current. -> resulting schema_version (Integer).
|
|
120
|
+
def migrate!
|
|
121
|
+
data = stored
|
|
122
|
+
return SCHEMA_VERSION if data.empty?
|
|
123
|
+
|
|
124
|
+
pending_migrations.each do |version|
|
|
125
|
+
migration = MIGRATIONS[version]
|
|
126
|
+
data = stringify(migration.call(data)) if migration
|
|
127
|
+
end
|
|
128
|
+
data["schema_version"] = SCHEMA_VERSION
|
|
129
|
+
@cs.put(SCOPE, KEY, data)
|
|
130
|
+
SCHEMA_VERSION
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def stored
|
|
136
|
+
@cs.get(SCOPE, KEY) || {}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def deep_merge(base, override)
|
|
140
|
+
base.merge(override) do |_key, a, b|
|
|
141
|
+
a.is_a?(Hash) && b.is_a?(Hash) ? deep_merge(a, b) : b
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def stringify(obj)
|
|
146
|
+
case obj
|
|
147
|
+
when Hash then obj.each_with_object({}) { |(k, v), acc| acc[k.to_s] = stringify(v) }
|
|
148
|
+
when Array then obj.map { |v| stringify(v) }
|
|
149
|
+
when Symbol then obj.to_s
|
|
150
|
+
else obj
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# shutdown is a drain, not a kill (docs/DEPLOY.md, process model
|
|
5
|
+
# The serving arms install this around the Executor. On the first
|
|
6
|
+
# SIGTERM/SIGINT the process stops accepting new turns (`Executor#begin_drain!`
|
|
7
|
+
# — a turn arriving mid-drain is left `:queued` for the next boot's recovery)
|
|
8
|
+
# and waits up to `timeout` seconds for the in-flight ones; only then does the
|
|
9
|
+
# ordinary stop proceed. A second signal skips the wait — the operator insisting
|
|
10
|
+
# means now. Whatever the deadline abandons dies `:running` with the process and
|
|
11
|
+
# the next boot generation's task sweep replays it from its checkpoint
|
|
12
|
+
# (side-effect skip on resume is what makes that replay safe).
|
|
13
|
+
#
|
|
14
|
+
# Mechanics, because trap context is narrow: the handler writes ONE byte into a
|
|
15
|
+
# self-pipe and returns. A plain watcher THREAD — not a fiber: at install time
|
|
16
|
+
# the serving reactor may not exist yet, and the drain must not depend on it —
|
|
17
|
+
# blocks on that pipe, runs the drain, and then delivers the stop the trap
|
|
18
|
+
# withheld by raising Interrupt in the serving thread. That is exactly the
|
|
19
|
+
# exception async-container's own trap would have raised, so everything
|
|
20
|
+
# downstream (Falcon worker teardown, reactor close, Litestream's final sync)
|
|
21
|
+
# is unchanged; this class only buys the turns time before it.
|
|
22
|
+
class Shutdown
|
|
23
|
+
DEFAULT_TIMEOUT = 20 # seconds — the number DEPLOY.md records and entrypoint.sh builds on
|
|
24
|
+
POLL = 0.05
|
|
25
|
+
|
|
26
|
+
# The one-call form the serving arms use: resolves the deadline, sets the
|
|
27
|
+
# traps, parks the watcher. The CALLING thread is captured as the stop target
|
|
28
|
+
# — install from the thread that runs the server.
|
|
29
|
+
#
|
|
30
|
+
# `executors:` drains N graphs on one signal. Signals are a
|
|
31
|
+
# PROCESS concern, and `Signal.trap` keeps only the last handler — so a second
|
|
32
|
+
# `install` per graph would silently leave the earlier graphs dying mid-turn.
|
|
33
|
+
# The host installs ONCE, naming every graph it embedded. `executor:` is the
|
|
34
|
+
# single-graph sugar every serving arm still uses.
|
|
35
|
+
def self.install(executor: nil, executors: nil, timeout: nil, logger: $stdout, signals: %w[INT TERM])
|
|
36
|
+
new(executors: executors || executor, timeout: timeout || default_timeout,
|
|
37
|
+
logger: logger).install!(signals)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# INSIKA_DRAIN_TIMEOUT (validated by EnvSchema); absent/garbage -> the default.
|
|
41
|
+
def self.default_timeout
|
|
42
|
+
Integer(EnvSchema.read("INSIKA_DRAIN_TIMEOUT").to_s)
|
|
43
|
+
rescue ArgumentError
|
|
44
|
+
DEFAULT_TIMEOUT
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# `interrupt` is the injectable stop delivery (specs); the default raises
|
|
48
|
+
# Interrupt in `target`, the thread that installed the traps.
|
|
49
|
+
def initialize(executor: nil, executors: nil, timeout:, logger: $stdout,
|
|
50
|
+
target: Thread.current, interrupt: nil)
|
|
51
|
+
@executors = Array(executors || executor)
|
|
52
|
+
@timeout = timeout
|
|
53
|
+
@logger = logger
|
|
54
|
+
@target = target
|
|
55
|
+
@interrupt = interrupt || -> { @target.raise(Interrupt) }
|
|
56
|
+
@reader, @writer = IO.pipe
|
|
57
|
+
@signaled = false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
attr_reader :timeout
|
|
61
|
+
|
|
62
|
+
def install!(signals = %w[INT TERM])
|
|
63
|
+
signals.each { |sig| Signal.trap(sig) { signal_received } }
|
|
64
|
+
@thread = Thread.new { watch }
|
|
65
|
+
@thread.name = "insika-shutdown"
|
|
66
|
+
self
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Runs in TRAP context: a flag flip and a pipe write, nothing else — except
|
|
70
|
+
# when the drain is already underway, where a repeated signal means "stop
|
|
71
|
+
# waiting" and the Interrupt is delivered immediately.
|
|
72
|
+
def signal_received
|
|
73
|
+
return @interrupt.call if @signaled
|
|
74
|
+
|
|
75
|
+
@signaled = true
|
|
76
|
+
begin
|
|
77
|
+
@writer.write_nonblock("!")
|
|
78
|
+
rescue IOError, SystemCallError
|
|
79
|
+
nil # pipe gone (process already unwinding): nothing left to schedule
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The watcher's whole life: parked on the pipe until the first signal, then
|
|
84
|
+
# drain and hand the stop back to the normal path.
|
|
85
|
+
def watch
|
|
86
|
+
@reader.read(1)
|
|
87
|
+
drain
|
|
88
|
+
@interrupt.call
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Closes the intake and waits for the in-flight turns, bounded by the
|
|
92
|
+
# deadline. With N executors the intake of EVERY one closes first, before any
|
|
93
|
+
# waiting: draining them in sequence would let graph B keep taking turns while
|
|
94
|
+
# graph A spends the deadline. -> { drained: bool, abandoned: [task ids] }
|
|
95
|
+
def drain
|
|
96
|
+
@executors.each(&:begin_drain!)
|
|
97
|
+
log("shutdown: draining — #{in_flight.size} turn(s) in flight, deadline #{@timeout}s")
|
|
98
|
+
deadline = monotonic + @timeout
|
|
99
|
+
sleep(POLL) until in_flight.empty? || monotonic >= deadline
|
|
100
|
+
|
|
101
|
+
abandoned = in_flight
|
|
102
|
+
if abandoned.empty?
|
|
103
|
+
log("shutdown: drained clean")
|
|
104
|
+
else
|
|
105
|
+
log("shutdown: deadline reached — abandoning #{abandoned.size} turn(s); " \
|
|
106
|
+
"recovery replays them at the next boot")
|
|
107
|
+
end
|
|
108
|
+
{ drained: abandoned.empty?, abandoned: abandoned }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
# The turns still running across every graph this shutdown owns.
|
|
114
|
+
def in_flight = @executors.flat_map(&:in_flight)
|
|
115
|
+
|
|
116
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
117
|
+
|
|
118
|
+
# Observability only: a logger failure must never alter the drain.
|
|
119
|
+
def log(message)
|
|
120
|
+
@logger&.puts(message)
|
|
121
|
+
rescue StandardError
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|