pinecall-runtime 0.1.6__py3-none-any.whl
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.
- pinecall/__init__.py +0 -0
- pinecall/channels/__init__.py +0 -0
- pinecall/channels/_chooser.py +45 -0
- pinecall/channels/offers.py +219 -0
- pinecall/channels/rooms.py +176 -0
- pinecall/channels/routes.py +275 -0
- pinecall/channels/telephony/__init__.py +0 -0
- pinecall/channels/telephony/_twilio.py +349 -0
- pinecall/channels/telephony/carrier.py +229 -0
- pinecall/channels/telephony/carrier_catalog.py +92 -0
- pinecall/channels/telephony/carriers.csv +41 -0
- pinecall/channels/telephony/dialing.py +419 -0
- pinecall/channels/telephony/firewall.py +58 -0
- pinecall/channels/telephony/hand_over.py +78 -0
- pinecall/channels/telephony/number_path.py +176 -0
- pinecall/channels/telephony/numbers.py +530 -0
- pinecall/channels/telephony/origination.py +77 -0
- pinecall/channels/telephony/sip.py +448 -0
- pinecall/channels/whatsapp.py +413 -0
- pinecall/cli/__init__.py +0 -0
- pinecall/cli/_archive.py +85 -0
- pinecall/cli/_drift.py +38 -0
- pinecall/cli/_facts.py +67 -0
- pinecall/cli/_load.py +433 -0
- pinecall/cli/_operator.py +626 -0
- pinecall/cli/_sessions.py +164 -0
- pinecall/cli/_traceback.py +95 -0
- pinecall/cli/_usage.py +34 -0
- pinecall/cli/main.py +521 -0
- pinecall/domain/__init__.py +0 -0
- pinecall/domain/agent.py +446 -0
- pinecall/domain/call.py +152 -0
- pinecall/domain/country_codes.py +32 -0
- pinecall/domain/errors.py +151 -0
- pinecall/domain/names.py +115 -0
- pinecall/domain/org.py +120 -0
- pinecall/domain/person.py +208 -0
- pinecall/domain/scope.py +21 -0
- pinecall/evals/__init__.py +0 -0
- pinecall/evals/_evidence.py +158 -0
- pinecall/evals/calibration.py +112 -0
- pinecall/evals/callers.py +160 -0
- pinecall/evals/case.py +327 -0
- pinecall/evals/checks.py +437 -0
- pinecall/evals/compliance.py +197 -0
- pinecall/evals/dataset.py +451 -0
- pinecall/evals/goldens.py +331 -0
- pinecall/evals/judges.py +525 -0
- pinecall/evals/runs.py +252 -0
- pinecall/evals/spoken.py +356 -0
- pinecall/fleet/__init__.py +0 -0
- pinecall/fleet/client.py +569 -0
- pinecall/fleet/demand.py +51 -0
- pinecall/fleet/heartbeat.py +194 -0
- pinecall/fleet/measures.py +157 -0
- pinecall/fleet/roster.py +232 -0
- pinecall/fleet/worlds.py +44 -0
- pinecall/gateway/__init__.py +0 -0
- pinecall/gateway/_call_setup.py +53 -0
- pinecall/gateway/_deps.py +556 -0
- pinecall/gateway/_gateway.py +75 -0
- pinecall/gateway/_served.py +396 -0
- pinecall/gateway/_sockets.py +444 -0
- pinecall/gateway/_streams.py +83 -0
- pinecall/gateway/_text_calls.py +182 -0
- pinecall/gateway/api/__init__.py +0 -0
- pinecall/gateway/api/accounts.py +324 -0
- pinecall/gateway/api/agents.py +150 -0
- pinecall/gateway/api/apps.py +478 -0
- pinecall/gateway/api/box.py +658 -0
- pinecall/gateway/api/calibration.py +58 -0
- pinecall/gateway/api/callbacks.py +105 -0
- pinecall/gateway/api/calls.py +655 -0
- pinecall/gateway/api/chat.py +223 -0
- pinecall/gateway/api/dataset.py +163 -0
- pinecall/gateway/api/desk.py +113 -0
- pinecall/gateway/api/entries.py +77 -0
- pinecall/gateway/api/evals.py +647 -0
- pinecall/gateway/api/fleet.py +73 -0
- pinecall/gateway/api/hosting.py +241 -0
- pinecall/gateway/api/judges.py +97 -0
- pinecall/gateway/api/keys.py +110 -0
- pinecall/gateway/api/line.py +142 -0
- pinecall/gateway/api/members.py +197 -0
- pinecall/gateway/api/metrics.py +226 -0
- pinecall/gateway/api/numbers.py +476 -0
- pinecall/gateway/api/ops.py +428 -0
- pinecall/gateway/api/org.py +283 -0
- pinecall/gateway/api/personas.py +157 -0
- pinecall/gateway/api/pipeline.py +206 -0
- pinecall/gateway/api/prompts.py +50 -0
- pinecall/gateway/api/providers.py +240 -0
- pinecall/gateway/api/recordings.py +89 -0
- pinecall/gateway/api/relay.py +169 -0
- pinecall/gateway/api/retrieval.py +403 -0
- pinecall/gateway/api/runner.py +109 -0
- pinecall/gateway/api/sessions.py +102 -0
- pinecall/gateway/api/settings.py +463 -0
- pinecall/gateway/api/signup.py +164 -0
- pinecall/gateway/api/sso_login.py +168 -0
- pinecall/gateway/api/threads.py +187 -0
- pinecall/gateway/api/usage.py +303 -0
- pinecall/gateway/api/visitors.py +222 -0
- pinecall/gateway/api/whatsapp.py +48 -0
- pinecall/gateway/api/widget.py +49 -0
- pinecall/gateway/app.py +504 -0
- pinecall/gateway/calls/__init__.py +0 -0
- pinecall/gateway/calls/binding.py +63 -0
- pinecall/gateway/calls/commands.py +18 -0
- pinecall/gateway/calls/inbox.py +100 -0
- pinecall/gateway/calls/known.py +23 -0
- pinecall/gateway/calls/owners.py +87 -0
- pinecall/gateway/calls/pump.py +33 -0
- pinecall/gateway/calls/serving.py +118 -0
- pinecall/gateway/calls/threads.py +367 -0
- pinecall/gateway/dispatching/__init__.py +0 -0
- pinecall/gateway/dispatching/arrivals.py +58 -0
- pinecall/gateway/dispatching/sweep.py +43 -0
- pinecall/gateway/ending/__init__.py +0 -0
- pinecall/gateway/ending/reaper.py +160 -0
- pinecall/gateway/ending/seal.py +312 -0
- pinecall/gateway/ending/stranded.py +117 -0
- pinecall/log/__init__.py +0 -0
- pinecall/log/_histogram.py +74 -0
- pinecall/log/_relay.py +259 -0
- pinecall/log/_writer.py +454 -0
- pinecall/log/days.py +154 -0
- pinecall/log/drift.py +550 -0
- pinecall/log/facts.py +358 -0
- pinecall/log/inbox.py +169 -0
- pinecall/log/lists.py +155 -0
- pinecall/log/logs.py +581 -0
- pinecall/log/openings.py +71 -0
- pinecall/log/private.py +135 -0
- pinecall/log/queries.py +229 -0
- pinecall/log/readers.py +207 -0
- pinecall/log/reduce.py +659 -0
- pinecall/log/refold.py +217 -0
- pinecall/log/store.py +370 -0
- pinecall/log/usage.py +242 -0
- pinecall/postgres/__init__.py +0 -0
- pinecall/postgres/migrate.py +202 -0
- pinecall/postgres/migrations/0001_schema.sql +591 -0
- pinecall/postgres/migrations/0002_worlds.sql +15 -0
- pinecall/postgres/migrations/0003_lent.sql +4 -0
- pinecall/postgres/migrations/0004_carriers.sql +23 -0
- pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
- pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
- pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
- pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
- pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
- pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
- pinecall/postgres/migrations/0011_erasure.sql +32 -0
- pinecall/postgres/migrations/0012_org_policy.sql +10 -0
- pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
- pinecall/postgres/migrations/0014_consents.sql +24 -0
- pinecall/postgres/migrations/0015_disclosure.sql +7 -0
- pinecall/postgres/migrations/0016_call_records.sql +21 -0
- pinecall/postgres/migrations/0017_reads.sql +17 -0
- pinecall/postgres/migrations/0018_hosting.sql +41 -0
- pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
- pinecall/postgres/migrations/0020_written_once.sql +7 -0
- pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
- pinecall/postgres/migrations/0022_sealed.sql +39 -0
- pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
- pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
- pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
- pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
- pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
- pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
- pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
- pinecall/postgres/migrations/0060_drift.sql +62 -0
- pinecall/postgres/migrations/0061_canaries.sql +24 -0
- pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
- pinecall/postgres/migrations/0063_prompts.sql +16 -0
- pinecall/postgres/migrations/0075_call_private.sql +14 -0
- pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
- pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
- pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
- pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
- pinecall/postgres/migrations/0080_call_openings.sql +20 -0
- pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
- pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
- pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
- pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
- pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
- pinecall/postgres/migrations/0086_route_origin.sql +17 -0
- pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
- pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
- pinecall/postgres/migrations/0089_offers.sql +21 -0
- pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
- pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
- pinecall/postgres/migrations/0092_key_parent.sql +6 -0
- pinecall/postgres/migrations/0093_code_tries.sql +10 -0
- pinecall/postgres/migrations/0094_route_approval.sql +12 -0
- pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
- pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
- pinecall/postgres/migrations/0097_case_status.sql +19 -0
- pinecall/postgres/pool.py +190 -0
- pinecall/process/__init__.py +0 -0
- pinecall/process/_objects.py +97 -0
- pinecall/process/box_settings.py +23 -0
- pinecall/process/connections.py +124 -0
- pinecall/process/metrics.py +127 -0
- pinecall/process/recordings.py +323 -0
- pinecall/process/resolver.py +41 -0
- pinecall/process/sealed_audio.py +173 -0
- pinecall/process/settings.py +534 -0
- pinecall/process/shared.py +158 -0
- pinecall/process/signal.py +416 -0
- pinecall/providers/__init__.py +0 -0
- pinecall/providers/build.py +570 -0
- pinecall/providers/catalog.py +266 -0
- pinecall/providers/credentials.py +230 -0
- pinecall/providers/declared.py +69 -0
- pinecall/providers/prices.py +240 -0
- pinecall/providers/tried.py +104 -0
- pinecall/providers/voices.py +174 -0
- pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
- pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
- pinecall/public/console/favicon.png +0 -0
- pinecall/public/console/index.html +20 -0
- pinecall/public/console/logo-mark.png +0 -0
- pinecall/public/console/pinecall-logo-dark.png +0 -0
- pinecall/public/console/pinecall-logo.png +0 -0
- pinecall/public/console/pinecall-mark.png +0 -0
- pinecall/public/console/sw.js +37 -0
- pinecall/public/widget/pinecall-widget.js +853 -0
- pinecall/py.typed +0 -0
- pinecall/retrieval/__init__.py +0 -0
- pinecall/retrieval/_search.py +186 -0
- pinecall/retrieval/_sections.py +70 -0
- pinecall/retrieval/embed.py +340 -0
- pinecall/retrieval/extraction.py +403 -0
- pinecall/retrieval/knowledge.py +659 -0
- pinecall/retrieval/lookups.py +192 -0
- pinecall/retrieval/memory.py +603 -0
- pinecall/runner/__init__.py +0 -0
- pinecall/runner/_kube.py +459 -0
- pinecall/runner/_plan.py +157 -0
- pinecall/runner/main.py +306 -0
- pinecall/session/__init__.py +0 -0
- pinecall/session/_agent.py +141 -0
- pinecall/session/_hearing.py +101 -0
- pinecall/session/_livekit.py +195 -0
- pinecall/session/_prompt.py +147 -0
- pinecall/session/_recording.py +237 -0
- pinecall/session/call.py +367 -0
- pinecall/session/clock.py +40 -0
- pinecall/session/hold.py +239 -0
- pinecall/session/room.py +626 -0
- pinecall/session/session.py +690 -0
- pinecall/session/text.py +144 -0
- pinecall/session/tools.py +483 -0
- pinecall/session/voice.py +118 -0
- pinecall/session/widget.py +201 -0
- pinecall/tenancy/__init__.py +0 -0
- pinecall/tenancy/admission.py +204 -0
- pinecall/tenancy/agents.py +164 -0
- pinecall/tenancy/canary.py +126 -0
- pinecall/tenancy/carrier_networks.py +156 -0
- pinecall/tenancy/carriers.py +265 -0
- pinecall/tenancy/codes.py +196 -0
- pinecall/tenancy/consents.py +167 -0
- pinecall/tenancy/dial_policy.py +405 -0
- pinecall/tenancy/disclosure.py +41 -0
- pinecall/tenancy/erasure.py +204 -0
- pinecall/tenancy/export.py +167 -0
- pinecall/tenancy/hosted_running.py +166 -0
- pinecall/tenancy/hosting.py +475 -0
- pinecall/tenancy/judges.py +126 -0
- pinecall/tenancy/keys.py +467 -0
- pinecall/tenancy/knocks.py +40 -0
- pinecall/tenancy/letters.py +378 -0
- pinecall/tenancy/mail.py +458 -0
- pinecall/tenancy/org_secrets.py +123 -0
- pinecall/tenancy/orgs.py +78 -0
- pinecall/tenancy/people.py +543 -0
- pinecall/tenancy/personas.py +145 -0
- pinecall/tenancy/policy.py +65 -0
- pinecall/tenancy/prompts.py +48 -0
- pinecall/tenancy/reads.py +73 -0
- pinecall/tenancy/recording_keys.py +41 -0
- pinecall/tenancy/remembered.py +148 -0
- pinecall/tenancy/retention.py +86 -0
- pinecall/tenancy/scopes.py +406 -0
- pinecall/tenancy/signin.py +622 -0
- pinecall/tenancy/spend.py +69 -0
- pinecall/tenancy/sso.py +431 -0
- pinecall/tenancy/sso_domains.py +132 -0
- pinecall/tenancy/throttle.py +88 -0
- pinecall/tenancy/tokens.py +400 -0
- pinecall/tenancy/traceback.py +70 -0
- pinecall/tenancy/usage.py +136 -0
- pinecall/tenancy/vault.py +204 -0
- pinecall/tenancy/words.py +144 -0
- pinecall/wire/__init__.py +0 -0
- pinecall/wire/commands.py +350 -0
- pinecall/wire/events.py +655 -0
- pinecall/wire/frames.py +65 -0
- pinecall/wire/metrics.py +287 -0
- pinecall/wire/parts.py +320 -0
- pinecall/wire/rest/__init__.py +0 -0
- pinecall/wire/rest/accounts.py +512 -0
- pinecall/wire/rest/agents.py +172 -0
- pinecall/wire/rest/calls.py +397 -0
- pinecall/wire/rest/evals.py +453 -0
- pinecall/wire/rest/fleet.py +69 -0
- pinecall/wire/rest/hosting.py +174 -0
- pinecall/wire/rest/numbers.py +280 -0
- pinecall/wire/rest/ops.py +342 -0
- pinecall/wire/rest/providers.py +83 -0
- pinecall/wire/rest/retrieval.py +301 -0
- pinecall/wire/rest/settings.py +184 -0
- pinecall/wire/rest/usage.py +245 -0
- pinecall/wire/room.py +61 -0
- pinecall/wire/scores.py +49 -0
- pinecall/wire/state.py +243 -0
- pinecall/worker/__init__.py +0 -0
- pinecall/worker/_job.py +655 -0
- pinecall/worker/_recorder.py +82 -0
- pinecall/worker/_traces.py +48 -0
- pinecall/worker/main.py +307 -0
- pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
- pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
- pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
- pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
- pinecall_runtime-0.1.6.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Beside a worker's calls: the load livekit reads, and the heartbeat the gateway hears."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import socket
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import fields
|
|
9
|
+
|
|
10
|
+
from livekit.agents import AgentServer
|
|
11
|
+
from livekit.agents.worker import ServerOptions
|
|
12
|
+
|
|
13
|
+
from pinecall.domain.errors import DeclarationRefused, GatewayRefused
|
|
14
|
+
from pinecall.fleet.client import GatewayClient
|
|
15
|
+
from pinecall.fleet.measures import LastMinute
|
|
16
|
+
from pinecall.fleet.roster import HEARTBEAT_S, REFUSED_AT, agent_name
|
|
17
|
+
from pinecall.process.settings import Settings
|
|
18
|
+
from pinecall.wire.rest.fleet import HeartbeatRequest
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
NO_MEASURE = "livekit's load_fnc option no longer carries a measure of its own"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# A worker that starts again under the same name (a pod's container, after its node was reset)
|
|
27
|
+
# registers anew while LiveKit may hold the last process's dead socket for 15-20 minutes and offer
|
|
28
|
+
# it calls. This process's start, to the second, keeps the two apart; the roster keys by the
|
|
29
|
+
# worker's name, so the gateway offers only the newest registration.
|
|
30
|
+
STARTED = f"{time.time():.0f}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
A_SLOT_AT_LEAST = "a worker holds at least one call: PINECALL_MAX_JOBS={max_jobs}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# The id livekit's AgentServer holds until LiveKit answers its registration with the real one.
|
|
37
|
+
UNREGISTERED = "unregistered"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# The exit a cordoned worker leaves with; its unit's RestartPreventExitStatus= keeps it down.
|
|
41
|
+
CORDONED_EXIT = 3
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# What LiveKit reads, on LiveKit's scale: its server offers a job under REFUSED_AT and its
|
|
45
|
+
# framework declines at it, so a worker that counts its calls is at REFUSED_AT with every slot
|
|
46
|
+
# taken. The server reads the load the worker last reported (every 2.5 s): a burst faster than
|
|
47
|
+
# that, on a worker's last slots, is declined by the framework and offered to no one else.
|
|
48
|
+
class Load:
|
|
49
|
+
"""The load LiveKit reads: its line times the slots taken, or the machine's CPU share."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self, max_jobs: int | None, measure: Callable[[AgentServer], float] | None = None
|
|
53
|
+
) -> None:
|
|
54
|
+
"""A worker nobody has dispatched to yet."""
|
|
55
|
+
if max_jobs is not None and max_jobs < 1:
|
|
56
|
+
raise DeclarationRefused(A_SLOT_AT_LEAST.format(max_jobs=max_jobs))
|
|
57
|
+
self.max_jobs = max_jobs
|
|
58
|
+
self.measure = measure or livekits_measure()
|
|
59
|
+
self.refused = False
|
|
60
|
+
|
|
61
|
+
def __call__(self, server: AgentServer) -> float:
|
|
62
|
+
"""The load now, as livekit reads it."""
|
|
63
|
+
if self.max_jobs is None:
|
|
64
|
+
return self.announced(self.measure(server))
|
|
65
|
+
return self.at(len(server.active_jobs))
|
|
66
|
+
|
|
67
|
+
def at(self, active: int) -> float:
|
|
68
|
+
"""The load of a worker holding this many calls, when its slots were measured."""
|
|
69
|
+
return self.announced(REFUSED_AT * active / (self.max_jobs or 1))
|
|
70
|
+
|
|
71
|
+
def announced(self, load: float) -> float:
|
|
72
|
+
"""The load, a crossing of LiveKit's line said once each way."""
|
|
73
|
+
refused = load >= REFUSED_AT
|
|
74
|
+
if refused != self.refused:
|
|
75
|
+
self.refused = refused
|
|
76
|
+
if refused:
|
|
77
|
+
logger.warning("load %.2f: livekit routes no job here until it falls", load)
|
|
78
|
+
else:
|
|
79
|
+
logger.info("load %.2f: livekit routes jobs here again", load)
|
|
80
|
+
return load
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Heartbeats:
|
|
84
|
+
"""The worker's report to its gateway every five seconds, and a cordon's way out."""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self, server: AgentServer, gateway: GatewayClient, settings: Settings, minute: LastMinute
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Not beating yet; `leave` is set when the worker must go."""
|
|
90
|
+
self.server = server
|
|
91
|
+
self.gateway = gateway
|
|
92
|
+
self.fleet = settings.fleet
|
|
93
|
+
self.max_jobs = settings.max_jobs
|
|
94
|
+
self.name = worker_name_of(settings)
|
|
95
|
+
self.agent_name = agent_name_of(settings)
|
|
96
|
+
self.minute = minute
|
|
97
|
+
self.cordoned = False
|
|
98
|
+
self.leave = asyncio.Event()
|
|
99
|
+
# Set once LiveKit registered the worker and the gateway answered a heartbeat uncordoned.
|
|
100
|
+
self.ready = asyncio.Event()
|
|
101
|
+
|
|
102
|
+
def beat(self) -> HeartbeatRequest:
|
|
103
|
+
"""What this worker holds now, and what its calls did in the last minute."""
|
|
104
|
+
active = len(self.server.active_jobs)
|
|
105
|
+
last = self.minute.of(asyncio.get_running_loop().time())
|
|
106
|
+
# The name only once LiveKit registered it: before, a call offered to it waits for nobody.
|
|
107
|
+
registered = self.server.id != UNREGISTERED
|
|
108
|
+
return HeartbeatRequest(
|
|
109
|
+
fleet=self.fleet,
|
|
110
|
+
worker=self.name,
|
|
111
|
+
agent_name=self.agent_name if registered else None,
|
|
112
|
+
active=active,
|
|
113
|
+
max_jobs=self.max_jobs,
|
|
114
|
+
load=self.gateways_load(active),
|
|
115
|
+
draining=self.server.draining,
|
|
116
|
+
ended=last.ended,
|
|
117
|
+
failed=last.failed,
|
|
118
|
+
errors=last.errors,
|
|
119
|
+
turns=last.turns,
|
|
120
|
+
first_audio_p95_s=last.first_audio_p95_s,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# The gateway's scale: calls over slots, full at 1.0 (roster._refused_at); a worker on CPU
|
|
124
|
+
# reports the share LiveKit reads, which is the same number on both scales.
|
|
125
|
+
def gateways_load(self, active: int) -> float:
|
|
126
|
+
"""The load the gateway reads for this worker."""
|
|
127
|
+
if self.max_jobs is not None:
|
|
128
|
+
return active / self.max_jobs
|
|
129
|
+
load_of = self.server.load_fnc
|
|
130
|
+
return load_of(self.server) if isinstance(load_of, Load) else 0.0
|
|
131
|
+
|
|
132
|
+
# A gateway away is said and waited out: the worker keeps its calls meanwhile.
|
|
133
|
+
async def run(self) -> None:
|
|
134
|
+
"""Beat until cordoned; then ask the worker to leave."""
|
|
135
|
+
while True:
|
|
136
|
+
try:
|
|
137
|
+
existing = await self.gateway.heartbeat(self.beat())
|
|
138
|
+
except GatewayRefused as refused:
|
|
139
|
+
logger.warning("heartbeat: %s", refused)
|
|
140
|
+
else:
|
|
141
|
+
if existing.cordoned:
|
|
142
|
+
logger.warning("cordoned by the gateway: draining, then leaving")
|
|
143
|
+
self.cordoned = True
|
|
144
|
+
self.leave.set()
|
|
145
|
+
return
|
|
146
|
+
if self.server.id != UNREGISTERED:
|
|
147
|
+
self.ready.set()
|
|
148
|
+
await asyncio.sleep(HEARTBEAT_S)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# systemd's Type=notify: `systemctl restart` returns only once this is said, so a release starts
|
|
152
|
+
# the next instance only after this one can take calls. Outside systemd there is nobody to tell.
|
|
153
|
+
async def announced_ready(beats: Heartbeats, notify: str | None) -> bool:
|
|
154
|
+
"""Tell systemd the worker is ready once LiveKit registered it and the gateway heard it."""
|
|
155
|
+
await beats.ready.wait()
|
|
156
|
+
if not notify:
|
|
157
|
+
return False
|
|
158
|
+
# An address that starts with @ is in the abstract namespace, whose first byte is a zero.
|
|
159
|
+
address = "\0" + notify[1:] if notify.startswith("@") else notify
|
|
160
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as systemd:
|
|
161
|
+
systemd.connect(address)
|
|
162
|
+
systemd.sendall(b"READY=1")
|
|
163
|
+
logger.info("registered with LiveKit and heard by the gateway: ready")
|
|
164
|
+
return True
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# The job's process names its call's worker as the heartbeat does: the same settings, the same host.
|
|
168
|
+
def worker_name_of(settings: Settings) -> str:
|
|
169
|
+
"""What the worker is called in its heartbeats and its calls: its setting, or the short host."""
|
|
170
|
+
return settings.worker_name or socket.gethostname().split(".")[0]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# Its own name, not the fleet's: LiveKit offers a job only to the workers registered under the
|
|
174
|
+
# name a dispatch carries, so the gateway reaches this process and no other (docs/scaling.md).
|
|
175
|
+
def agent_name_of(settings: Settings) -> str:
|
|
176
|
+
"""The name the worker registers under with LiveKit: its fleet's, its own, its start."""
|
|
177
|
+
return f"{agent_name(settings.fleet, worker_name_of(settings))}.{STARTED}"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# livekit's own CPU average, read off its options' default rather than its private class.
|
|
181
|
+
def livekits_measure() -> Callable[[AgentServer], float]:
|
|
182
|
+
"""The load function livekit runs when given none: the machine's CPU over a few seconds."""
|
|
183
|
+
(declared,) = (option for option in fields(ServerOptions) if option.name == "load_fnc")
|
|
184
|
+
measure = declared.default
|
|
185
|
+
if not callable(measure):
|
|
186
|
+
raise DeclarationRefused(NO_MEASURE)
|
|
187
|
+
|
|
188
|
+
def measured(server: AgentServer) -> float:
|
|
189
|
+
load = measure(server)
|
|
190
|
+
if not isinstance(load, int | float):
|
|
191
|
+
raise DeclarationRefused(NO_MEASURE)
|
|
192
|
+
return float(load)
|
|
193
|
+
|
|
194
|
+
return measured
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""A worker's last minute: what its calls' job processes tell it, and what its heartbeat carries."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import math
|
|
5
|
+
import socket
|
|
6
|
+
import tempfile
|
|
7
|
+
from collections import deque
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, override
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, ValidationError
|
|
14
|
+
|
|
15
|
+
from pinecall.process.settings import Settings
|
|
16
|
+
from pinecall.wire.rest.calls import BatchedEntry
|
|
17
|
+
|
|
18
|
+
# What the heartbeat carries is the last minute's, as the roster judges it.
|
|
19
|
+
WINDOW_S = 60.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# A busy minute is thousands of turns: past this many, the oldest are forgotten early.
|
|
23
|
+
MOST_KEPT = 10_000
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# A p95 is read off this many turns at least; fewer, and the heartbeat carries none.
|
|
27
|
+
TURNS_FOR_A_P95 = 5
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# How a call that failed ended; one datagram per thing told, and a datagram lost is uncounted.
|
|
31
|
+
AN_ERROR = "error"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class JobReport(BaseModel):
|
|
35
|
+
"""One thing a job tells its worker: a turn's first audio, an error, or how its call ended."""
|
|
36
|
+
|
|
37
|
+
model_config = ConfigDict(frozen=True, extra="ignore")
|
|
38
|
+
|
|
39
|
+
first_audio_s: float | None = None
|
|
40
|
+
error: bool = False
|
|
41
|
+
ended: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class Minute:
|
|
46
|
+
"""A worker's calls over the last minute: ended, failed, error entries, and first audio."""
|
|
47
|
+
|
|
48
|
+
ended: int
|
|
49
|
+
# Of the calls that ended, those that ended in an error.
|
|
50
|
+
failed: int
|
|
51
|
+
errors: int
|
|
52
|
+
turns: int
|
|
53
|
+
first_audio_p95_s: float | None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class LastMinute:
|
|
57
|
+
"""What the worker's job processes told it, kept for a minute, in the worker's main process."""
|
|
58
|
+
|
|
59
|
+
def __init__(self) -> None:
|
|
60
|
+
"""Nothing heard yet."""
|
|
61
|
+
self.first_audio: deque[tuple[float, float]] = deque(maxlen=MOST_KEPT)
|
|
62
|
+
self.errors: deque[float] = deque(maxlen=MOST_KEPT)
|
|
63
|
+
self.ended: deque[tuple[float, bool]] = deque(maxlen=MOST_KEPT)
|
|
64
|
+
|
|
65
|
+
def heard(self, message: bytes, now: float) -> None:
|
|
66
|
+
"""One datagram from a job; one that does not read is dropped."""
|
|
67
|
+
try:
|
|
68
|
+
report = JobReport.model_validate_json(message)
|
|
69
|
+
except ValidationError:
|
|
70
|
+
return
|
|
71
|
+
if report.first_audio_s is not None:
|
|
72
|
+
self.first_audio.append((now, report.first_audio_s))
|
|
73
|
+
if report.error:
|
|
74
|
+
self.errors.append(now)
|
|
75
|
+
if report.ended is not None:
|
|
76
|
+
self.ended.append((now, report.ended == AN_ERROR))
|
|
77
|
+
|
|
78
|
+
def of(self, now: float) -> Minute:
|
|
79
|
+
"""The minute up to now."""
|
|
80
|
+
since = now - WINDOW_S
|
|
81
|
+
while self.first_audio and self.first_audio[0][0] < since:
|
|
82
|
+
self.first_audio.popleft()
|
|
83
|
+
while self.errors and self.errors[0] < since:
|
|
84
|
+
self.errors.popleft()
|
|
85
|
+
while self.ended and self.ended[0][0] < since:
|
|
86
|
+
self.ended.popleft()
|
|
87
|
+
waits = sorted(value for _, value in self.first_audio)
|
|
88
|
+
return Minute(
|
|
89
|
+
ended=len(self.ended),
|
|
90
|
+
failed=sum(1 for _, failed in self.ended if failed),
|
|
91
|
+
errors=len(self.errors),
|
|
92
|
+
turns=len(waits),
|
|
93
|
+
first_audio_p95_s=_p95(waits) if len(waits) >= TURNS_FOR_A_P95 else None,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class _Heard(asyncio.DatagramProtocol):
|
|
98
|
+
def __init__(self, minute: LastMinute) -> None:
|
|
99
|
+
self.minute = minute
|
|
100
|
+
|
|
101
|
+
@override
|
|
102
|
+
def datagram_received(self, data: bytes, addr: tuple[str | Any, int]) -> None:
|
|
103
|
+
del addr
|
|
104
|
+
self.minute.heard(data, asyncio.get_running_loop().time())
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# Private to the unit on the box (PrivateTmp=yes); the health port tells two workers of a machine
|
|
108
|
+
# apart, as it must already, since each binds its own.
|
|
109
|
+
def measures_path(settings: Settings) -> Path:
|
|
110
|
+
"""Where a worker hears its jobs: a datagram socket its main process binds."""
|
|
111
|
+
name = f"pinecall-{settings.fleet}-{settings.worker_http_port}.measures"
|
|
112
|
+
return Path(tempfile.gettempdir()) / name
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# Bound before livekit starts its processes; a leftover of a worker that died is taken over.
|
|
116
|
+
async def listening(path: Path, minute: LastMinute) -> asyncio.DatagramTransport:
|
|
117
|
+
"""Bind the socket the worker's jobs tell it through."""
|
|
118
|
+
await asyncio.to_thread(path.unlink, missing_ok=True)
|
|
119
|
+
transport, _ = await asyncio.get_running_loop().create_datagram_endpoint(
|
|
120
|
+
lambda: _Heard(minute), local_addr=str(path), family=socket.AF_UNIX
|
|
121
|
+
)
|
|
122
|
+
return transport
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# Called on every batch a job sends: most carry none of the three, and send nothing.
|
|
126
|
+
def reported(path: Path, entries: Sequence[BatchedEntry]) -> None:
|
|
127
|
+
"""Tell the worker what a batch of its call's entries says: first audio, errors, an end."""
|
|
128
|
+
messages = [message for entry in entries if (message := _message_of(entry)) is not None]
|
|
129
|
+
if not messages:
|
|
130
|
+
return
|
|
131
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as sender:
|
|
132
|
+
sender.settimeout(0)
|
|
133
|
+
for message in messages:
|
|
134
|
+
try:
|
|
135
|
+
sender.sendto(message, str(path))
|
|
136
|
+
except OSError:
|
|
137
|
+
# A worker not listening (a test, or its main process gone) loses the measure only.
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# Nearest rank, of values already sorted.
|
|
142
|
+
def _p95(values: Sequence[float]) -> float:
|
|
143
|
+
return values[max(math.ceil(0.95 * len(values)) - 1, 0)]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _message_of(entry: BatchedEntry) -> bytes | None:
|
|
147
|
+
if entry.type == "turn.agent":
|
|
148
|
+
measured = entry.data.get("metrics")
|
|
149
|
+
waited = measured.get("e2e_latency") if isinstance(measured, dict) else None
|
|
150
|
+
if isinstance(waited, int | float):
|
|
151
|
+
return JobReport(first_audio_s=waited).model_dump_json().encode()
|
|
152
|
+
if entry.type == AN_ERROR:
|
|
153
|
+
return JobReport(error=True).model_dump_json().encode()
|
|
154
|
+
if entry.type == "call.ended":
|
|
155
|
+
reason = entry.data.get("reason")
|
|
156
|
+
return JobReport(ended=reason if isinstance(reason, str) else "").model_dump_json().encode()
|
|
157
|
+
return None
|
pinecall/fleet/roster.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""The roster of workers, one per fleet: who is up, what each holds, and whether a fleet is full."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Iterable, Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import TypeAdapter
|
|
9
|
+
|
|
10
|
+
from pinecall.process.shared import Assigned, Shared, newest
|
|
11
|
+
from pinecall.process.signal import LocalSignal, Signal
|
|
12
|
+
from pinecall.wire.rest.fleet import FleetTotals, HeartbeatRequest, HeartbeatResponse, WorkerStatus
|
|
13
|
+
|
|
14
|
+
type WorkerState = Literal["gone", "cordoned", "draining", "accepting", "failing", "full"]
|
|
15
|
+
|
|
16
|
+
# LiveKit's line, the same on both of its sides: livekit-server's agent.DefaultTargetLoad
|
|
17
|
+
# (pkg/agent/config.go; a job is offered to a worker whose reported load is under it) and
|
|
18
|
+
# livekit-agents' load_threshold (the worker declines at or over it). A worker gated on CPU
|
|
19
|
+
# reports its share of the machine, which is that scale. A worker that counts its calls reports
|
|
20
|
+
# 0.7 times its calls over its slots to LiveKit (fleet/heartbeat.Load), so LiveKit's full is its
|
|
21
|
+
# last slot, and calls over slots to the gateway, full at 1.0: on 2026-10-02 it reported calls over
|
|
22
|
+
# slots to both, and LiveKit stopped at 0.7 of them while the gateway still counted seats free.
|
|
23
|
+
REFUSED_AT = 0.7
|
|
24
|
+
EVERY_SLOT = 1.0
|
|
25
|
+
OVERFLOW = "overflow"
|
|
26
|
+
HEARTBEAT_S = 5.0
|
|
27
|
+
# A worker silent this long is no longer capacity.
|
|
28
|
+
STALE_AFTER_S = 30.0
|
|
29
|
+
# A silent worker stays listed, as gone, this long.
|
|
30
|
+
FORGOTTEN_AFTER_S = 3600.0
|
|
31
|
+
# A worker up but bad: at least half the calls that ended in its last minute ended in an error,
|
|
32
|
+
# judged on four calls at least, or its callers waited over five seconds for first audio at the
|
|
33
|
+
# p95, over twenty turns at least. Set so they change nothing a healthy worker does.
|
|
34
|
+
FAILED_SHARE = 0.5
|
|
35
|
+
ENDED_TO_JUDGE = 4
|
|
36
|
+
SLOW_FIRST_AUDIO_S = 5.0
|
|
37
|
+
TURNS_TO_JUDGE = 20
|
|
38
|
+
|
|
39
|
+
# Every gateway says the heartbeats it heard and the cordons it set, once a heartbeat; each answers
|
|
40
|
+
# a heartbeat's `full` from totals at most this old, not by walking the fleet every time.
|
|
41
|
+
ROSTER_CHANNEL = "roster"
|
|
42
|
+
TOTALS_KEPT_S = 1.0
|
|
43
|
+
CORDONED = "cordoned"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Heard:
|
|
48
|
+
"""One gateway's share: the workers that beat on it lately, and the cordons it set."""
|
|
49
|
+
|
|
50
|
+
seats: tuple[WorkerStatus, ...] = ()
|
|
51
|
+
cordons: tuple[Assigned, ...] = ()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# In memory, on every gateway: each keeps the heartbeats that reached it and hears the others', so
|
|
55
|
+
# a worker beating on any gateway is counted on all of them. After a restart the next round of
|
|
56
|
+
# heartbeats, five seconds, rebuilds it; a cordon set on one gateway stands on all, the newest
|
|
57
|
+
# setting of a worker's cordon winning.
|
|
58
|
+
class Roster:
|
|
59
|
+
"""The workers of every fleet, by fleet and name, as their heartbeats say on any gateway."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, signal: Signal | None = None) -> None:
|
|
62
|
+
"""Nobody heard from yet."""
|
|
63
|
+
# Every gateway's workers, the newest heartbeat of each: what every read answers from.
|
|
64
|
+
self.seats: dict[tuple[str, str], WorkerStatus] = {}
|
|
65
|
+
self.heard_here: dict[tuple[str, str], WorkerStatus] = {}
|
|
66
|
+
self.cordons: dict[tuple[str, str], Assigned] = {}
|
|
67
|
+
self.cordoned: dict[tuple[str, str], str] = {}
|
|
68
|
+
self.shared = Shared(signal or LocalSignal(), ROSTER_CHANNEL, _HEARD, Heard(), self._merged)
|
|
69
|
+
self.shared.every_s = HEARTBEAT_S
|
|
70
|
+
self.shared.gathered = self._gathered
|
|
71
|
+
self._totals: dict[str, tuple[float, FleetTotals]] = {}
|
|
72
|
+
|
|
73
|
+
async def start(self) -> None:
|
|
74
|
+
"""Hear the other gateways' workers, and say this one's every heartbeat."""
|
|
75
|
+
await self.shared.start()
|
|
76
|
+
|
|
77
|
+
async def close(self) -> None:
|
|
78
|
+
"""Stop hearing and saying."""
|
|
79
|
+
await self.shared.close()
|
|
80
|
+
|
|
81
|
+
def report(self, beat: HeartbeatRequest, now: float) -> HeartbeatResponse:
|
|
82
|
+
"""Keep the heartbeat and answer the worker's standing; a cordon outlives heartbeats."""
|
|
83
|
+
cordoned = (beat.fleet, beat.worker) in self.cordoned
|
|
84
|
+
seat = WorkerStatus(
|
|
85
|
+
fleet=beat.fleet,
|
|
86
|
+
worker=beat.worker,
|
|
87
|
+
agent_name=beat.agent_name,
|
|
88
|
+
active=beat.active,
|
|
89
|
+
max_jobs=beat.max_jobs,
|
|
90
|
+
load=beat.load,
|
|
91
|
+
draining=beat.draining,
|
|
92
|
+
cordoned=cordoned,
|
|
93
|
+
seen_at=now,
|
|
94
|
+
ended=beat.ended,
|
|
95
|
+
failed=beat.failed,
|
|
96
|
+
errors=beat.errors,
|
|
97
|
+
turns=beat.turns,
|
|
98
|
+
first_audio_p95_s=beat.first_audio_p95_s,
|
|
99
|
+
)
|
|
100
|
+
self.heard_here[(beat.fleet, beat.worker)] = seat
|
|
101
|
+
self.seats[(beat.fleet, beat.worker)] = seat
|
|
102
|
+
return HeartbeatResponse(cordoned=cordoned, full=self._kept_totals(beat.fleet, now).full)
|
|
103
|
+
|
|
104
|
+
def cordon(self, fleet: str, worker: str, *, on: bool = True) -> bool:
|
|
105
|
+
"""Cordon a worker, or take the cordon back; False for a name nobody has."""
|
|
106
|
+
if (fleet, worker) not in self.seats:
|
|
107
|
+
return False
|
|
108
|
+
holder = CORDONED if on else None
|
|
109
|
+
self.cordons[(fleet, worker)] = Assigned(fleet, worker, holder, time.time())
|
|
110
|
+
self._merged()
|
|
111
|
+
self.shared.put(self._gathered())
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
def of(self, fleet: str, now: float) -> list[WorkerStatus]:
|
|
115
|
+
"""A fleet's workers by last heartbeat, forgetting the ones silent for an hour."""
|
|
116
|
+
for key, seat in list(self.seats.items()):
|
|
117
|
+
if now - seat.seen_at > FORGOTTEN_AFTER_S:
|
|
118
|
+
del self.seats[key]
|
|
119
|
+
return sorted(
|
|
120
|
+
(seat for seat in self.seats.values() if seat.fleet == fleet),
|
|
121
|
+
key=lambda seat: seat.seen_at,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def _kept_totals(self, fleet: str, now: float) -> FleetTotals:
|
|
125
|
+
kept = self._totals.get(fleet)
|
|
126
|
+
if kept is not None and 0 <= now - kept[0] < TOTALS_KEPT_S:
|
|
127
|
+
return kept[1]
|
|
128
|
+
totals = self.totals(fleet, now)
|
|
129
|
+
self._totals[fleet] = (now, totals)
|
|
130
|
+
return totals
|
|
131
|
+
|
|
132
|
+
def _gathered(self) -> Heard:
|
|
133
|
+
return Heard(seats=tuple(self.heard_here.values()), cordons=tuple(self.cordons.values()))
|
|
134
|
+
|
|
135
|
+
# Each worker's newest heartbeat, wherever it beat; each cordon's newest setting.
|
|
136
|
+
def _merged(self) -> None:
|
|
137
|
+
theirs = [heard.share for heard in self.shared.theirs.values()]
|
|
138
|
+
settings = (setting for share in theirs for setting in share.cordons)
|
|
139
|
+
self.cordoned = newest(self.cordons, settings)
|
|
140
|
+
merged = dict(self.heard_here)
|
|
141
|
+
for share in theirs:
|
|
142
|
+
for seat in share.seats:
|
|
143
|
+
known = merged.get((seat.fleet, seat.worker))
|
|
144
|
+
if known is None or known.seen_at < seat.seen_at:
|
|
145
|
+
merged[(seat.fleet, seat.worker)] = seat
|
|
146
|
+
self.seats = {
|
|
147
|
+
key: seat.model_copy(update={"cordoned": key in self.cordoned})
|
|
148
|
+
for key, seat in merged.items()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
# A fleet nobody heard from is not full: a gateway with no worker yet refuses nobody.
|
|
152
|
+
def totals(self, fleet: str, now: float) -> FleetTotals:
|
|
153
|
+
"""A fleet's workers heard from lately, summed."""
|
|
154
|
+
up = [
|
|
155
|
+
seat for seat in self.seats.values() if seat.fleet == fleet and heard_lately(seat, now)
|
|
156
|
+
]
|
|
157
|
+
accepting = len(accepting_of(up, now))
|
|
158
|
+
return FleetTotals(
|
|
159
|
+
fleet=fleet,
|
|
160
|
+
workers=len(up),
|
|
161
|
+
active=sum(seat.active for seat in up),
|
|
162
|
+
seats=sum(seat.max_jobs or 0 for seat in up),
|
|
163
|
+
free=sum(max(0, (seat.max_jobs or 0) - seat.active) for seat in up if seat.max_jobs),
|
|
164
|
+
accepting=accepting,
|
|
165
|
+
full=bool(up) and accepting == 0,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
_HEARD: TypeAdapter[Heard] = TypeAdapter(Heard)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def heard_lately(seat: WorkerStatus, now: float) -> bool:
|
|
173
|
+
"""Whether the worker's last heartbeat is recent enough to count."""
|
|
174
|
+
return now - seat.seen_at <= STALE_AFTER_S
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def failing(seat: WorkerStatus) -> bool:
|
|
178
|
+
"""Whether the worker's last minute is past the line: its calls fail, or its callers wait."""
|
|
179
|
+
ended, failed = seat.ended or 0, seat.failed or 0
|
|
180
|
+
if ended >= ENDED_TO_JUDGE and failed / ended >= FAILED_SHARE:
|
|
181
|
+
return True
|
|
182
|
+
slow = seat.first_audio_p95_s
|
|
183
|
+
return (seat.turns or 0) >= TURNS_TO_JUDGE and slow is not None and slow > SLOW_FIRST_AUDIO_S
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# A failing worker is not counted while another of its fleet accepts and is not failing: the line
|
|
187
|
+
# never empties a fleet, so a box of one worker per world keeps counting its one.
|
|
188
|
+
def accepting_of(seats: Iterable[WorkerStatus], now: float) -> list[WorkerStatus]:
|
|
189
|
+
"""The fleet's workers counted as accepting: up and under the line, and not failing."""
|
|
190
|
+
accepting = [seat for seat in seats if _accepting_now(seat, now)]
|
|
191
|
+
sound = [seat for seat in accepting if not failing(seat)]
|
|
192
|
+
return sound or accepting
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def worker_state(seat: WorkerStatus, fleet: Sequence[WorkerStatus], now: float) -> WorkerState:
|
|
196
|
+
"""How the roster counts the worker among its fleet's: gone, cordoned, draining, and so on."""
|
|
197
|
+
if not heard_lately(seat, now):
|
|
198
|
+
return "gone"
|
|
199
|
+
if seat.cordoned:
|
|
200
|
+
return "cordoned"
|
|
201
|
+
if seat.draining:
|
|
202
|
+
return "draining"
|
|
203
|
+
if seat in accepting_of(fleet, now):
|
|
204
|
+
return "accepting"
|
|
205
|
+
return "failing" if _accepting_now(seat, now) else "full"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# A worker registers with LiveKit under <fleet>/<worker>, the fleet's overflow under
|
|
209
|
+
# <fleet>/overflow: a dispatch reaches exactly the workers registered under its name.
|
|
210
|
+
def agent_name(fleet: str, worker: str) -> str:
|
|
211
|
+
"""The name a worker of a fleet is registered under with LiveKit."""
|
|
212
|
+
return f"{fleet}/{worker}"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def overflow_name(fleet: str) -> str:
|
|
216
|
+
"""The name the fleet's overflow is registered under with LiveKit."""
|
|
217
|
+
return agent_name(fleet, OVERFLOW)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _refused_at(max_jobs: int | None) -> float:
|
|
221
|
+
"""The load a heartbeat says full at: every slot of a worker that counts, 0.7 of a CPU."""
|
|
222
|
+
return REFUSED_AT if max_jobs is None else EVERY_SLOT
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _accepting_now(seat: WorkerStatus, now: float) -> bool:
|
|
226
|
+
"""Whether livekit would dispatch to it: up, not cordoned, not draining, under its line."""
|
|
227
|
+
return (
|
|
228
|
+
heard_lately(seat, now)
|
|
229
|
+
and not seat.cordoned
|
|
230
|
+
and not seat.draining
|
|
231
|
+
and seat.load < _refused_at(seat.max_jobs)
|
|
232
|
+
)
|
pinecall/fleet/worlds.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""The fleet each world's calls are dispatched to, as the operator named them."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
4
|
+
|
|
5
|
+
from pinecall.domain.names import PRODUCTION, SANDBOX, Env
|
|
6
|
+
from pinecall.postgres.pool import Pool
|
|
7
|
+
from pinecall.process import box_settings
|
|
8
|
+
from pinecall.process.settings import A_FLEET_NAME
|
|
9
|
+
|
|
10
|
+
FLEETS = "fleets"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Fleets(BaseModel):
|
|
14
|
+
"""The fleet of workers each world's calls are dispatched to."""
|
|
15
|
+
|
|
16
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
17
|
+
|
|
18
|
+
production: str = Field(default="pinecall", pattern=A_FLEET_NAME)
|
|
19
|
+
sandbox: str = Field(default="pinecall-sandbox", pattern=A_FLEET_NAME)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def fleets(pool: Pool) -> Fleets:
|
|
23
|
+
"""The fleet each world's calls go to."""
|
|
24
|
+
async with pool.connection() as connection:
|
|
25
|
+
value = await box_settings.read(connection, FLEETS)
|
|
26
|
+
return Fleets() if value is None else Fleets.model_validate(value)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def set_fleets(pool: Pool, named: Fleets) -> None:
|
|
30
|
+
"""Write the fleet of each world, whole."""
|
|
31
|
+
async with pool.connection() as connection:
|
|
32
|
+
await box_settings.write(connection, FLEETS, named.model_dump(mode="json"))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def world_of(named: Fleets, fleet: str) -> Env | None:
|
|
36
|
+
"""The world whose calls go to the fleet; None when no world's do."""
|
|
37
|
+
if fleet == named.production:
|
|
38
|
+
return PRODUCTION
|
|
39
|
+
return SANDBOX if fleet == named.sandbox else None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def fleet_of(named: Fleets, env: Env) -> str:
|
|
43
|
+
"""The fleet a call of the world is dispatched to."""
|
|
44
|
+
return named.production if env == PRODUCTION else named.sandbox
|
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""What a call of an agent is set up with: its config tuned by the scope, the keys, a refusal."""
|
|
2
|
+
|
|
3
|
+
from cryptography.fernet import MultiFernet
|
|
4
|
+
|
|
5
|
+
from pinecall.domain.agent import AgentConfig, Versions
|
|
6
|
+
from pinecall.domain.errors import QuotaExhausted
|
|
7
|
+
from pinecall.domain.scope import Scope
|
|
8
|
+
from pinecall.log.logs import Logs
|
|
9
|
+
from pinecall.postgres.pool import Pool
|
|
10
|
+
from pinecall.providers.catalog import Providers
|
|
11
|
+
from pinecall.providers.credentials import Keyring
|
|
12
|
+
from pinecall.providers.declared import apply_tuning
|
|
13
|
+
from pinecall.tenancy import admission, scopes, vault
|
|
14
|
+
from pinecall.tenancy.scopes import Picked
|
|
15
|
+
from pinecall.wire.events import (
|
|
16
|
+
CreditsExhausted,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# A call's own id picks its version where the scope stands on a canary; no call is the rest.
|
|
21
|
+
async def tuned(
|
|
22
|
+
pool: Pool,
|
|
23
|
+
declared: AgentConfig,
|
|
24
|
+
scope: Scope,
|
|
25
|
+
configured: Providers,
|
|
26
|
+
picked: Picked | None = None,
|
|
27
|
+
) -> tuple[AgentConfig, Versions]:
|
|
28
|
+
"""The declaration under the scope's settings, and the versions it was built from."""
|
|
29
|
+
existing = await scopes.current(pool, scope, declared.slug, picked)
|
|
30
|
+
config = apply_tuning(declared, existing.tuning, existing.lexicon, defaults=configured.defaults)
|
|
31
|
+
return config, existing.versions
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Read per call, so a rotated key runs from the next call on.
|
|
35
|
+
async def keys_of(pool: Pool, sealed: MultiFernet, scope: Scope) -> Keyring:
|
|
36
|
+
"""What the org's calls in the world may run on."""
|
|
37
|
+
quotas = await admission.quotas_of(pool, scope.org, scope.env)
|
|
38
|
+
return Keyring(
|
|
39
|
+
own=await vault.credentials_of(pool, sealed, scope.org),
|
|
40
|
+
box=await vault.box_credentials(pool, sealed),
|
|
41
|
+
lends=quotas.lends,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# The refusal is written on the agent's log as the numbers the quota ran out at.
|
|
46
|
+
async def exhausted(logs: Logs, org: str, agent: str, refused: QuotaExhausted) -> None:
|
|
47
|
+
"""credits.exhausted on the agent's log, for a refusal that names its quota."""
|
|
48
|
+
if refused.quota is None:
|
|
49
|
+
return
|
|
50
|
+
text = CreditsExhausted.model_validate(
|
|
51
|
+
{"org": org, "quota": refused.quota, "used": refused.used, "limit": refused.limit}
|
|
52
|
+
)
|
|
53
|
+
await logs.agent(agent).append("credits.exhausted", text.written())
|