agents_control 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5b56040135b5403eefcaaa60ef384fcba2f5cb9bf28add127443bfbe741df2ee
|
|
4
|
+
data.tar.gz: c19ff5fd9a1dfd0cb46373b2ec58e66b095108072ca18efbb3342f9a3c34f121
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 73cbb9c93c0a7b506ed52e51811c9ed2462276c59a3e10f37747054fc05be8b2b2fed83aec1110a151b4ca65470673b7ec65791d5e2d400cd3199cd448306e75
|
|
7
|
+
data.tar.gz: 6825f34afc030b952b5bc6690170241844888b71e5270fa057597f236a0e2869da1c1caff6cfb4563faee28d84a403d9775fa434d780d672057bc1a1e4dc8ea1
|
|
@@ -242,10 +242,16 @@ module AgentsControl
|
|
|
242
242
|
raise NetworkError, e.message
|
|
243
243
|
end
|
|
244
244
|
|
|
245
|
+
# SystemCallError as a family rather than a list of Errno
|
|
246
|
+
# constants. The list was a bet on knowing every errno a network
|
|
247
|
+
# stack can raise, and it lost: Errno::EADDRNOTAVAIL — "can't
|
|
248
|
+
# assign requested address", what a VPN reconnecting produces —
|
|
249
|
+
# wasn't on it, so it escaped as itself instead of arriving as
|
|
250
|
+
# Unavailable, and killed the polling thread outright. The
|
|
251
|
+
# daemon stayed up with its port open and simply stopped hearing
|
|
252
|
+
# Telegram, for days, with nothing in the log after the trace.
|
|
245
253
|
NETWORK_ERRORS = [
|
|
246
|
-
|
|
247
|
-
Errno::ENETUNREACH, Errno::ETIMEDOUT, Errno::EPIPE,
|
|
248
|
-
SocketError, Timeout::Error, OpenSSL::SSL::SSLError, IOError
|
|
254
|
+
SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError, IOError
|
|
249
255
|
].freeze
|
|
250
256
|
end
|
|
251
257
|
end
|
|
@@ -16,6 +16,19 @@ module AgentsControl
|
|
|
16
16
|
# closed laptop doesn't hammer reconnects all night.
|
|
17
17
|
BACKOFF = [1, 2, 5, 10, 30, 60].freeze
|
|
18
18
|
|
|
19
|
+
# A successful poll leaves a timestamp behind, so something
|
|
20
|
+
# outside this process can tell a bot that is listening from one
|
|
21
|
+
# that stopped hours ago. `polling?` answers that in-process, and
|
|
22
|
+
# doctor runs as a separate command — the daemon's port reports
|
|
23
|
+
# only that it is up, which a deaf daemon is too.
|
|
24
|
+
HEARTBEAT_KEY = "telegram:last_poll"
|
|
25
|
+
|
|
26
|
+
# A healthy poll returns at least every telegram.poll_timeout
|
|
27
|
+
# (30s), and immediately when something arrives. Writing on each
|
|
28
|
+
# one would rewrite the store several times a second under
|
|
29
|
+
# traffic, for a reading nobody needs that finely.
|
|
30
|
+
HEARTBEAT_EVERY = 15
|
|
31
|
+
|
|
19
32
|
def initialize(api:, router:, store:, config:, logger: $stdout)
|
|
20
33
|
@api = api
|
|
21
34
|
@router = router
|
|
@@ -59,6 +72,12 @@ module AgentsControl
|
|
|
59
72
|
|
|
60
73
|
def stop = @stopped&.push(nil)
|
|
61
74
|
|
|
75
|
+
# Whether the Telegram side is actually alive. The daemon holds
|
|
76
|
+
# its port and answers hooks whether or not this thread is
|
|
77
|
+
# running, so without asking there is no way to tell a bot that
|
|
78
|
+
# is listening from one that died quietly hours ago.
|
|
79
|
+
def polling? = @poller&.alive? || false
|
|
80
|
+
|
|
62
81
|
def running? = @running
|
|
63
82
|
|
|
64
83
|
private
|
|
@@ -69,6 +88,7 @@ module AgentsControl
|
|
|
69
88
|
while @running
|
|
70
89
|
begin
|
|
71
90
|
poll_once
|
|
91
|
+
heartbeat
|
|
72
92
|
failures = 0
|
|
73
93
|
rescue Api::Conflict => e
|
|
74
94
|
# Another process is reading updates — looping further is
|
|
@@ -86,10 +106,41 @@ module AgentsControl
|
|
|
86
106
|
failures += 1
|
|
87
107
|
log("API error: #{e.message}")
|
|
88
108
|
pause(backoff(failures))
|
|
109
|
+
rescue StandardError => e
|
|
110
|
+
# A backstop, not a place to add expected errors above it.
|
|
111
|
+
# Whatever it is, the bot going deaf until someone notices
|
|
112
|
+
# is worse than retrying: the daemon keeps its port and its
|
|
113
|
+
# hooks either way, so a dead poller looks exactly like a
|
|
114
|
+
# healthy one from outside.
|
|
115
|
+
failures += 1
|
|
116
|
+
log("unexpected #{e.class}: #{redacted(e)}, retrying in #{backoff(failures)}s")
|
|
117
|
+
pause(backoff(failures))
|
|
89
118
|
end
|
|
90
119
|
end
|
|
91
120
|
end
|
|
92
121
|
|
|
122
|
+
# The message may carry the token, so it is only printed once
|
|
123
|
+
# redaction has actually succeeded — and redaction failing must
|
|
124
|
+
# not take the handler down with it, which is how the backstop
|
|
125
|
+
# above managed to kill the very thread it exists to keep alive.
|
|
126
|
+
def redacted(error)
|
|
127
|
+
@api.redact(error.message)
|
|
128
|
+
rescue StandardError
|
|
129
|
+
"(message withheld — could not redact)"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Best-effort by design: a store that can't be written is not a
|
|
133
|
+
# reason to stop reading Telegram, which is the whole job.
|
|
134
|
+
def heartbeat
|
|
135
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
136
|
+
return if @last_heartbeat && now - @last_heartbeat < HEARTBEAT_EVERY
|
|
137
|
+
|
|
138
|
+
@last_heartbeat = now
|
|
139
|
+
@store.put(Time.now.to_i, ttl: 86_400, key: HEARTBEAT_KEY)
|
|
140
|
+
rescue StandardError => e
|
|
141
|
+
log("couldn't record the poll heartbeat: #{e.class}")
|
|
142
|
+
end
|
|
143
|
+
|
|
93
144
|
def poll_once
|
|
94
145
|
updates = @api.get_updates(offset: offset, timeout: @config.get("telegram.poll_timeout", 30))
|
|
95
146
|
|
|
@@ -18,16 +18,17 @@ module AgentsControl
|
|
|
18
18
|
# control the app.
|
|
19
19
|
NOT_AUTHORISED = "-1743"
|
|
20
20
|
|
|
21
|
-
def initialize(config: nil, secrets: nil, executor: Executor.new, api: nil)
|
|
21
|
+
def initialize(config: nil, secrets: nil, executor: Executor.new, api: nil, store: nil)
|
|
22
22
|
@config = config || Config.load
|
|
23
23
|
@secrets = secrets || Secrets.new
|
|
24
24
|
@executor = executor
|
|
25
25
|
@api = api
|
|
26
|
+
@store = store
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
CHECKS = %i[
|
|
29
30
|
ruby_check terminal_check automation_check agent_binary_check
|
|
30
|
-
token_check chats_check passphrase_check bot_check daemon_check hooks_check
|
|
31
|
+
token_check chats_check passphrase_check bot_check daemon_check polling_check hooks_check
|
|
31
32
|
anchors_check wake_check
|
|
32
33
|
].freeze
|
|
33
34
|
|
|
@@ -185,6 +186,36 @@ module AgentsControl
|
|
|
185
186
|
end
|
|
186
187
|
end
|
|
187
188
|
|
|
189
|
+
# The daemon holds its port and answers hooks whether or not it is
|
|
190
|
+
# still reading Telegram, so "running" and "listening" are separate
|
|
191
|
+
# questions and only the first was ever asked. A poll loop that died
|
|
192
|
+
# on a network error looked exactly like a healthy one — for days,
|
|
193
|
+
# in the case that prompted this.
|
|
194
|
+
# Derived from the configured long-poll timeout rather than fixed:
|
|
195
|
+
# a healthy poll returns at least that often, so a hand-raised
|
|
196
|
+
# telegram.poll_timeout would otherwise make a perfectly good daemon
|
|
197
|
+
# read as deaf. Two windows plus a minute leaves room for a retry
|
|
198
|
+
# and the heartbeat's own throttle.
|
|
199
|
+
def stale_after
|
|
200
|
+
[(@config.get("telegram.poll_timeout", 30).to_i * 2) + 60, 120].max
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def polling_check
|
|
204
|
+
return nil unless daemon_response(@config.get("hooks.port", Daemon::DEFAULT_PORT)) == :ours
|
|
205
|
+
|
|
206
|
+
last = store.get(Channels::Telegram::Bot::HEARTBEAT_KEY)
|
|
207
|
+
return warn("Telegram", "the daemon hasn't reported a poll yet",
|
|
208
|
+
"if it persists, restart: agents_control stop && agents_control") unless last
|
|
209
|
+
|
|
210
|
+
age = Time.now.to_i - last.to_i
|
|
211
|
+
return ok("Telegram", "polling — last heard #{age}s ago") if age < stale_after
|
|
212
|
+
|
|
213
|
+
fail("Telegram", "the daemon is up but hasn't polled for #{age / 60} min — messages aren't arriving",
|
|
214
|
+
"agents_control stop && agents_control")
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def store = @store ||= Store.new
|
|
218
|
+
|
|
188
219
|
def daemon_response(port)
|
|
189
220
|
socket = Socket.tcp("127.0.0.1", port, connect_timeout: 1)
|
|
190
221
|
socket.print("POST /ping HTTP/1.1\r\nHost: localhost\r\nContent-Length: 2\r\n\r\n{}")
|