agents_control 0.3.0 → 0.4.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/lib/agents_control/channels/telegram/api.rb +9 -3
- data/lib/agents_control/channels/telegram/bot.rb +57 -3
- data/lib/agents_control/channels/telegram/channel.rb +51 -12
- data/lib/agents_control/channels/telegram/router.rb +78 -12
- data/lib/agents_control/console.rb +11 -1
- data/lib/agents_control/doctor.rb +33 -2
- data/lib/agents_control/inbox.rb +29 -7
- data/lib/agents_control/process_probe.rb +10 -2
- data/lib/agents_control/prompt.rb +33 -3
- data/lib/agents_control/version.rb +1 -1
- data/lib/agents_control.rb +1 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cce6dbc39dbec059d87d6f739af51d705b2e11e8b6c11225dd0021c37461f17f
|
|
4
|
+
data.tar.gz: 646165dca05e1718512e7d8d065d2c76568618429999f5eb15c7e58fa9dfab93
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d12b481351f8479279aa6867c2206609392d561236cd3680eed567272a496bfff8884ec0f3ebf72e50f1f9c4cac167882886c6087d9c011edda476b6e0229c3e
|
|
7
|
+
data.tar.gz: 907ff99d3b625927e0fba4b79a7aaf4010cd3875b00e6fe5f1d6b7d8e0d2756d34d0e1598cd9af73543cfe67041de7949ee2cf8c6ca080f8ddc885fd35b5b49a
|
|
@@ -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
|
|
@@ -12,9 +12,25 @@ module AgentsControl
|
|
|
12
12
|
class Bot
|
|
13
13
|
OFFSET_KEY = "telegram:offset"
|
|
14
14
|
|
|
15
|
-
# Pause after a network failure
|
|
16
|
-
#
|
|
17
|
-
|
|
15
|
+
# Pause after a network failure. The ceiling is low on purpose:
|
|
16
|
+
# nothing tells us the network is back, so the pause is how long
|
|
17
|
+
# the bot stays deaf after a switch between networks — and a
|
|
18
|
+
# minute of that read as "it froze". A closed laptop is asleep
|
|
19
|
+
# and doesn't retry at all, so a short ceiling costs nothing there.
|
|
20
|
+
BACKOFF = [1, 2, 5, 10].freeze
|
|
21
|
+
|
|
22
|
+
# A successful poll leaves a timestamp behind, so something
|
|
23
|
+
# outside this process can tell a bot that is listening from one
|
|
24
|
+
# that stopped hours ago. `polling?` answers that in-process, and
|
|
25
|
+
# doctor runs as a separate command — the daemon's port reports
|
|
26
|
+
# only that it is up, which a deaf daemon is too.
|
|
27
|
+
HEARTBEAT_KEY = "telegram:last_poll"
|
|
28
|
+
|
|
29
|
+
# A healthy poll returns at least every telegram.poll_timeout
|
|
30
|
+
# (30s), and immediately when something arrives. Writing on each
|
|
31
|
+
# one would rewrite the store several times a second under
|
|
32
|
+
# traffic, for a reading nobody needs that finely.
|
|
33
|
+
HEARTBEAT_EVERY = 15
|
|
18
34
|
|
|
19
35
|
def initialize(api:, router:, store:, config:, logger: $stdout)
|
|
20
36
|
@api = api
|
|
@@ -59,6 +75,12 @@ module AgentsControl
|
|
|
59
75
|
|
|
60
76
|
def stop = @stopped&.push(nil)
|
|
61
77
|
|
|
78
|
+
# Whether the Telegram side is actually alive. The daemon holds
|
|
79
|
+
# its port and answers hooks whether or not this thread is
|
|
80
|
+
# running, so without asking there is no way to tell a bot that
|
|
81
|
+
# is listening from one that died quietly hours ago.
|
|
82
|
+
def polling? = @poller&.alive? || false
|
|
83
|
+
|
|
62
84
|
def running? = @running
|
|
63
85
|
|
|
64
86
|
private
|
|
@@ -69,6 +91,7 @@ module AgentsControl
|
|
|
69
91
|
while @running
|
|
70
92
|
begin
|
|
71
93
|
poll_once
|
|
94
|
+
heartbeat
|
|
72
95
|
failures = 0
|
|
73
96
|
rescue Api::Conflict => e
|
|
74
97
|
# Another process is reading updates — looping further is
|
|
@@ -86,10 +109,41 @@ module AgentsControl
|
|
|
86
109
|
failures += 1
|
|
87
110
|
log("API error: #{e.message}")
|
|
88
111
|
pause(backoff(failures))
|
|
112
|
+
rescue StandardError => e
|
|
113
|
+
# A backstop, not a place to add expected errors above it.
|
|
114
|
+
# Whatever it is, the bot going deaf until someone notices
|
|
115
|
+
# is worse than retrying: the daemon keeps its port and its
|
|
116
|
+
# hooks either way, so a dead poller looks exactly like a
|
|
117
|
+
# healthy one from outside.
|
|
118
|
+
failures += 1
|
|
119
|
+
log("unexpected #{e.class}: #{redacted(e)}, retrying in #{backoff(failures)}s")
|
|
120
|
+
pause(backoff(failures))
|
|
89
121
|
end
|
|
90
122
|
end
|
|
91
123
|
end
|
|
92
124
|
|
|
125
|
+
# The message may carry the token, so it is only printed once
|
|
126
|
+
# redaction has actually succeeded — and redaction failing must
|
|
127
|
+
# not take the handler down with it, which is how the backstop
|
|
128
|
+
# above managed to kill the very thread it exists to keep alive.
|
|
129
|
+
def redacted(error)
|
|
130
|
+
@api.redact(error.message)
|
|
131
|
+
rescue StandardError
|
|
132
|
+
"(message withheld — could not redact)"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Best-effort by design: a store that can't be written is not a
|
|
136
|
+
# reason to stop reading Telegram, which is the whole job.
|
|
137
|
+
def heartbeat
|
|
138
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
139
|
+
return if @last_heartbeat && now - @last_heartbeat < HEARTBEAT_EVERY
|
|
140
|
+
|
|
141
|
+
@last_heartbeat = now
|
|
142
|
+
@store.put(Time.now.to_i, ttl: 86_400, key: HEARTBEAT_KEY)
|
|
143
|
+
rescue StandardError => e
|
|
144
|
+
log("couldn't record the poll heartbeat: #{e.class}")
|
|
145
|
+
end
|
|
146
|
+
|
|
93
147
|
def poll_once
|
|
94
148
|
updates = @api.get_updates(offset: offset, timeout: @config.get("telegram.poll_timeout", 30))
|
|
95
149
|
|
|
@@ -10,11 +10,19 @@ module AgentsControl
|
|
|
10
10
|
# because a button physically can't carry more than 64 bytes, and
|
|
11
11
|
# the whole content of an action lives on our side anyway.
|
|
12
12
|
class Channel < Channels::Base
|
|
13
|
-
|
|
13
|
+
# Roughly a quarter of an hour in all: long enough to ride out
|
|
14
|
+
# a switch between networks or a VPN reconnecting, short enough
|
|
15
|
+
# that a message doesn't turn up hours after it stopped mattering.
|
|
16
|
+
RESEND_AFTER = [5, 15, 30, 60, 120, 300, 300].freeze
|
|
17
|
+
|
|
18
|
+
def initialize(api:, store:, config:, registry: nil,
|
|
19
|
+
later: ->(&work) { Thread.new(&work) }, sleeper: ->(seconds) { sleep(seconds) })
|
|
14
20
|
@api = api
|
|
15
21
|
@store = store
|
|
16
22
|
@config = config
|
|
17
23
|
@registry = registry
|
|
24
|
+
@later = later
|
|
25
|
+
@sleeper = sleeper
|
|
18
26
|
end
|
|
19
27
|
|
|
20
28
|
def ready? = !chats.empty?
|
|
@@ -56,15 +64,44 @@ module AgentsControl
|
|
|
56
64
|
chunks = [""] if chunks.empty?
|
|
57
65
|
|
|
58
66
|
chats.each do |chat_id|
|
|
59
|
-
chunks
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
67
|
+
stuck = send_chunks(chat_id, chunks, 0, markup, event, question_id)
|
|
68
|
+
resend_later(chat_id, chunks, stuck, markup, event, question_id) if stuck
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Returns the index of the first chunk the network kept from
|
|
73
|
+
# going out, or nil once there's nothing left to send.
|
|
74
|
+
def send_chunks(chat_id, chunks, from, markup, event, question_id)
|
|
75
|
+
(from...chunks.size).each do |i|
|
|
76
|
+
body = "#{continuation_marker(i, chunks.size)}#{chunks[i]}"
|
|
77
|
+
sent = begin
|
|
78
|
+
send_formatted(chat_id, body, i == chunks.size - 1 ? markup : nil)
|
|
79
|
+
rescue Api::Unavailable
|
|
80
|
+
return i
|
|
81
|
+
end
|
|
82
|
+
remember(chat_id, sent, event, question_id) if event
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
nil
|
|
86
|
+
rescue Api::Error
|
|
87
|
+
# One unreachable chat must not affect the rest, and
|
|
88
|
+
# definitely must not take down the thread holding the agent.
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Off the calling thread: that thread is holding the agent's
|
|
93
|
+
# hook open, and the agent must not sit waiting for the Wi-Fi
|
|
94
|
+
# to come back. Dropping the message instead is how an agent
|
|
95
|
+
# that finished during a network switch went unheard of.
|
|
96
|
+
def resend_later(chat_id, chunks, from, markup, event, question_id)
|
|
97
|
+
@later.call do
|
|
98
|
+
RESEND_AFTER.each do |delay|
|
|
99
|
+
@sleeper.call(delay)
|
|
100
|
+
from = send_chunks(chat_id, chunks, from, markup, event, question_id)
|
|
101
|
+
break unless from
|
|
63
102
|
end
|
|
64
|
-
rescue
|
|
65
|
-
|
|
66
|
-
# definitely must not take down the thread holding the agent.
|
|
67
|
-
next
|
|
103
|
+
rescue StandardError
|
|
104
|
+
nil
|
|
68
105
|
end
|
|
69
106
|
end
|
|
70
107
|
|
|
@@ -98,9 +135,11 @@ module AgentsControl
|
|
|
98
135
|
id = sent.is_a?(Hash) ? sent["message_id"] : nil
|
|
99
136
|
return unless id
|
|
100
137
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
138
|
+
target = { "session_id" => event.session_id, "question_id" => question_id,
|
|
139
|
+
"cwd" => event.cwd, "label" => event.label }
|
|
140
|
+
@store.put(target, ttl: REPLY_TARGET_TTL, key: "reply:#{chat_id}:#{id}")
|
|
141
|
+
# Where a message sent without a reply goes — see the Router.
|
|
142
|
+
@store.put(target, ttl: REPLY_TARGET_TTL, key: "last:#{chat_id}")
|
|
104
143
|
end
|
|
105
144
|
|
|
106
145
|
# A short session tag in the header. Needed so that in a chat
|
|
@@ -126,6 +126,10 @@ module AgentsControl
|
|
|
126
126
|
|
|
127
127
|
return compose_answer(chat_id, text) if composing?(chat_id) && !text.start_with?("/")
|
|
128
128
|
|
|
129
|
+
if !text.empty? && !text.start_with?("/") && (target = @store.get(last_target_key(chat_id)))
|
|
130
|
+
return answer_target(chat_id, target, text)
|
|
131
|
+
end
|
|
132
|
+
|
|
129
133
|
command, argument = text.split(/\s+/, 2)
|
|
130
134
|
|
|
131
135
|
dispatch(chat_id, command.to_s.sub(/@.*\z/, ""), argument.to_s)
|
|
@@ -138,6 +142,10 @@ module AgentsControl
|
|
|
138
142
|
target = @store.get("reply:#{chat_id}:#{replied['message_id']}")
|
|
139
143
|
return say(chat_id, "I don't remember which session that message was about.") unless target
|
|
140
144
|
|
|
145
|
+
answer_target(chat_id, target, text)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def answer_target(chat_id, target, text)
|
|
141
149
|
question = target["question_id"]
|
|
142
150
|
return deliver(chat_id, question, Reply.text(text)) if question && @pending&.find(question)
|
|
143
151
|
|
|
@@ -153,19 +161,44 @@ module AgentsControl
|
|
|
153
161
|
# match wins there if one exists (the common case: replying to
|
|
154
162
|
# an agent's own notification), otherwise any matching tab does
|
|
155
163
|
# (a plain tab mid-interactive-command has no agent to prefer).
|
|
164
|
+
#
|
|
165
|
+
# Returns the session it typed into, or nil once it has said why not.
|
|
156
166
|
def type_into_session(chat_id, target, text)
|
|
167
|
+
session = session_for(chat_id, target)
|
|
168
|
+
execute(chat_id, session, text, show_result: !session.agent?) if session
|
|
169
|
+
session
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def session_for(chat_id, target)
|
|
157
173
|
exact = @registry.refresh.find(target["session_id"])
|
|
158
|
-
return
|
|
174
|
+
return exact if exact && !exact.terminalless?
|
|
159
175
|
|
|
160
|
-
|
|
161
|
-
agent_matches = candidates.select(&:agent?)
|
|
162
|
-
matching = agent_matches.empty? ? candidates : agent_matches
|
|
176
|
+
matching = tabs_for(target["cwd"])
|
|
163
177
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
178
|
+
return matching.first if matching.size == 1
|
|
179
|
+
|
|
180
|
+
say(chat_id, if matching.empty?
|
|
181
|
+
"#{target['label']} isn't waiting anymore, and I couldn't find the tab."
|
|
182
|
+
else
|
|
183
|
+
"#{target['label']} has several tabs — use /run NUMBER to pick one."
|
|
184
|
+
end)
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# A hook reports the directory the agent is in now, while the
|
|
189
|
+
# tab and the process only know the one it was started in — so
|
|
190
|
+
# an agent that cd'd into a subfolder matches no tab exactly.
|
|
191
|
+
# The agent in the nearest directory above is that same agent;
|
|
192
|
+
# a plain shell up there is just a neighbour.
|
|
193
|
+
def tabs_for(cwd)
|
|
194
|
+
tabs = @registry.sessions.reject(&:terminalless?)
|
|
195
|
+
|
|
196
|
+
same = tabs.select { |s| s.cwd == cwd }
|
|
197
|
+
return same.select(&:agent?).then { |agents| agents.empty? ? same : agents } unless same.empty?
|
|
198
|
+
|
|
199
|
+
above = tabs.select { |s| s.agent? && !s.cwd.to_s.empty? && cwd.to_s.start_with?(File.join(s.cwd, "")) }
|
|
200
|
+
nearest = above.map { |s| s.cwd.length }.max
|
|
201
|
+
above.select { |s| s.cwd.length == nearest }
|
|
169
202
|
end
|
|
170
203
|
|
|
171
204
|
# A file from the phone becomes a file on disk, and the agent is
|
|
@@ -194,22 +227,50 @@ module AgentsControl
|
|
|
194
227
|
# ends up pasted into the wrong project.
|
|
195
228
|
def deliver_file(chat_id, message, path)
|
|
196
229
|
caption = message["caption"].to_s.strip
|
|
230
|
+
album = album_key(chat_id, message)
|
|
197
231
|
|
|
198
232
|
if (target = caption[%r{\A/run\s+(\d+)}i, 1])
|
|
199
233
|
return with_session(chat_id, target) do |session|
|
|
200
234
|
execute(chat_id, session, with_path(caption.sub(%r{\A/run\s+\d+\s*}i, ""), path),
|
|
201
235
|
show_result: !session.agent?)
|
|
236
|
+
remember_album(album, session)
|
|
202
237
|
end
|
|
203
238
|
end
|
|
204
239
|
|
|
205
240
|
replied = message["reply_to_message"]
|
|
206
241
|
remembered = replied && @store.get("reply:#{chat_id}:#{replied['message_id']}")
|
|
207
|
-
return type_into_session(chat_id, remembered, with_path(caption, path)) if remembered
|
|
242
|
+
return remember_album(album, type_into_session(chat_id, remembered, with_path(caption, path))) if remembered
|
|
243
|
+
|
|
244
|
+
if (session = album_session(album))
|
|
245
|
+
return execute(chat_id, session, with_path("", path))
|
|
246
|
+
end
|
|
208
247
|
|
|
209
248
|
say(chat_id, "Saved:\n\n`#{path}`\n\nReply to a session's message with a file to hand it " \
|
|
210
249
|
"straight over, or caption it `/run N`.")
|
|
211
250
|
end
|
|
212
251
|
|
|
252
|
+
# Telegram splits an album into one message per file and puts the
|
|
253
|
+
# caption — and the reply — on only one of them, so the rest
|
|
254
|
+
# arrive saying nothing about where they go. They go where the
|
|
255
|
+
# album's caption sent it: sending files together means that.
|
|
256
|
+
def album_key(chat_id, message)
|
|
257
|
+
group = message["media_group_id"]
|
|
258
|
+
group && "album:#{chat_id}:#{group}"
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Only an agent gets the rest of the album: a bare path typed
|
|
262
|
+
# into a shell is a command, not a file handed over.
|
|
263
|
+
def remember_album(key, session)
|
|
264
|
+
@store.put(session.id, ttl: 600, key: key) if key && session&.agent?
|
|
265
|
+
session
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def album_session(key)
|
|
269
|
+
id = key && @store.get(key)
|
|
270
|
+
session = id && @registry.refresh.find(id)
|
|
271
|
+
session if session&.agent? && !session.terminalless?
|
|
272
|
+
end
|
|
273
|
+
|
|
213
274
|
# Escaped, because a name like "quarterly report.csv" is
|
|
214
275
|
# entirely ordinary and an unescaped one arrives at the shell as
|
|
215
276
|
# two arguments. Shellwords leaves a path that needs nothing
|
|
@@ -730,10 +791,15 @@ module AgentsControl
|
|
|
730
791
|
id = sent.is_a?(Hash) ? sent["message_id"] : nil
|
|
731
792
|
return unless id
|
|
732
793
|
|
|
733
|
-
|
|
734
|
-
|
|
794
|
+
target = { "session_id" => session.id, "cwd" => session.cwd, "label" => session.label }
|
|
795
|
+
@store.put(target, ttl: REPLY_TARGET_TTL, key: "reply:#{chat_id}:#{id}")
|
|
796
|
+
@store.put(target, ttl: REPLY_TARGET_TTL, key: last_target_key(chat_id))
|
|
735
797
|
end
|
|
736
798
|
|
|
799
|
+
# Text sent without a reply goes to the session of the last message
|
|
800
|
+
# the bot sent, so a back-and-forth with one agent needs no replies.
|
|
801
|
+
def last_target_key(chat_id) = "last:#{chat_id}"
|
|
802
|
+
|
|
737
803
|
def status(chat_id)
|
|
738
804
|
sessions = @registry.refresh.sessions
|
|
739
805
|
backends = @registry.available_backends.map(&:name).join(", ")
|
|
@@ -105,7 +105,7 @@ module AgentsControl
|
|
|
105
105
|
# The daemon comes up immediately: a console without it is just a
|
|
106
106
|
# window, and the point is to already be connected the moment the tab opens.
|
|
107
107
|
def start_daemon
|
|
108
|
-
@daemon = Daemon.new(config: @config, store: @store, secrets: @secrets, logger:
|
|
108
|
+
@daemon = Daemon.new(config: @config, store: @store, secrets: @secrets, logger: daemon_log)
|
|
109
109
|
|
|
110
110
|
return say("") if @daemon.start
|
|
111
111
|
|
|
@@ -114,6 +114,16 @@ module AgentsControl
|
|
|
114
114
|
say("")
|
|
115
115
|
end
|
|
116
116
|
|
|
117
|
+
# Only puts is ever called on a logger.
|
|
118
|
+
Interjection = Struct.new(:prompt) do
|
|
119
|
+
def puts(text = "") = prompt.interject(text)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# With a terminal the prompt owns it between commands, so the
|
|
123
|
+
# daemon's lines go through it; over a pipe there's no raw mode to
|
|
124
|
+
# work around.
|
|
125
|
+
def daemon_log = $stdin.tty? ? Interjection.new(editor) : @out
|
|
126
|
+
|
|
117
127
|
def loop_commands
|
|
118
128
|
while (line = prompt)
|
|
119
129
|
line = line.strip
|
|
@@ -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{}")
|
data/lib/agents_control/inbox.rb
CHANGED
|
@@ -57,10 +57,17 @@ module AgentsControl
|
|
|
57
57
|
|
|
58
58
|
class TooLarge < StandardError; end
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
# Seconds between attempts. A dropped connection on a phone's
|
|
61
|
+
# network or a reconnecting VPN is gone again within seconds, and
|
|
62
|
+
# the alternative is asking someone away from home to send the
|
|
63
|
+
# file again.
|
|
64
|
+
RETRY_AFTER = [1, 3, 5].freeze
|
|
65
|
+
|
|
66
|
+
def initialize(api:, dir: self.class.path, keep_days: 14, sleeper: ->(seconds) { sleep(seconds) })
|
|
61
67
|
@api = api
|
|
62
68
|
@dir = dir
|
|
63
69
|
@keep_days = keep_days
|
|
70
|
+
@sleeper = sleeper
|
|
64
71
|
end
|
|
65
72
|
|
|
66
73
|
# The local path the file landed at.
|
|
@@ -68,17 +75,32 @@ module AgentsControl
|
|
|
68
75
|
size = attachment.file_size.to_i
|
|
69
76
|
raise TooLarge, "#{human(size)} is over the #{human(MAX_BYTES)} Telegram allows a bot to download" if size > MAX_BYTES
|
|
70
77
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
78
|
+
retrying do
|
|
79
|
+
remote = @api.file_path_for(attachment.file_id)
|
|
80
|
+
prepare
|
|
81
|
+
destination = File.join(@dir, name_for(attachment, remote))
|
|
74
82
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
83
|
+
download(remote, destination)
|
|
84
|
+
prune
|
|
85
|
+
destination
|
|
86
|
+
end
|
|
78
87
|
end
|
|
79
88
|
|
|
80
89
|
private
|
|
81
90
|
|
|
91
|
+
def retrying
|
|
92
|
+
attempt = 0
|
|
93
|
+
begin
|
|
94
|
+
yield
|
|
95
|
+
rescue Channels::Telegram::Api::Unavailable
|
|
96
|
+
raise if attempt >= RETRY_AFTER.size
|
|
97
|
+
|
|
98
|
+
@sleeper.call(RETRY_AFTER[attempt])
|
|
99
|
+
attempt += 1
|
|
100
|
+
retry
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
82
104
|
# Downloaded beside the destination and renamed into place: a
|
|
83
105
|
# connection that drops half way through otherwise leaves a
|
|
84
106
|
# truncated file with a name that promises a whole one, and the
|
|
@@ -27,7 +27,12 @@ module AgentsControl
|
|
|
27
27
|
# i.e. what the user is currently interacting with.
|
|
28
28
|
def foreground? = stat.to_s.include?("+")
|
|
29
29
|
def name = File.basename(command.to_s)
|
|
30
|
-
|
|
30
|
+
|
|
31
|
+
# Claude Code retitles its own process to `Claude` partway through
|
|
32
|
+
# a long session, so an exact match loses the tab. The desktop app
|
|
33
|
+
# is also called `Claude`, but it never has a terminal — that's
|
|
34
|
+
# what keeps it from passing for an agent here.
|
|
35
|
+
def agent = AGENT_BINARIES[name] || (terminal? ? AGENT_BINARIES[name.downcase] : nil)
|
|
31
36
|
def agent? = !agent.nil?
|
|
32
37
|
def terminal? = tty != NO_TTY
|
|
33
38
|
end
|
|
@@ -37,10 +42,13 @@ module AgentsControl
|
|
|
37
42
|
end
|
|
38
43
|
|
|
39
44
|
# Re-reads the process tree. The result is cached on the object — one
|
|
40
|
-
# probe per registry pass.
|
|
45
|
+
# probe per registry pass. The per-tab index is derived from it, so it
|
|
46
|
+
# has to be dropped here too: a long-lived daemon would otherwise keep
|
|
47
|
+
# answering from the first snapshot it ever took.
|
|
41
48
|
def refresh
|
|
42
49
|
result = @executor.run(Which.find!("ps"), "-A", "-o", "tty=,pid=,stat=,comm=")
|
|
43
50
|
@processes = result.success? ? parse(result.stdout) : []
|
|
51
|
+
@by_tty = nil
|
|
44
52
|
self
|
|
45
53
|
end
|
|
46
54
|
|
|
@@ -19,6 +19,23 @@ module AgentsControl
|
|
|
19
19
|
@completer = completer || ->(_word) { [] }
|
|
20
20
|
@describer = describer || ->(_item) { nil }
|
|
21
21
|
@history = []
|
|
22
|
+
# Reentrant: interject redraws the line, and so does every keystroke.
|
|
23
|
+
@lock = Monitor.new
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A line from another thread — the daemon logs while someone types.
|
|
27
|
+
# Raw mode turns off the terminal's own "\n" → "\r\n", so printed
|
|
28
|
+
# as-is every line started where the previous one ended and landed
|
|
29
|
+
# on top of the line being typed. It goes above that line instead,
|
|
30
|
+
# and the line is drawn again underneath.
|
|
31
|
+
def interject(text)
|
|
32
|
+
@lock.synchronize do
|
|
33
|
+
return @out.puts(text) unless @reading
|
|
34
|
+
|
|
35
|
+
erase_drawn
|
|
36
|
+
@out.print("#{text.to_s.chomp.gsub("\n", "\r\n")}\r\n")
|
|
37
|
+
render
|
|
38
|
+
end
|
|
22
39
|
end
|
|
23
40
|
|
|
24
41
|
# Returns the entered line, or nil if input closed (Ctrl-D).
|
|
@@ -32,12 +49,25 @@ module AgentsControl
|
|
|
32
49
|
@history_at = @history.size
|
|
33
50
|
@result = nil
|
|
34
51
|
|
|
35
|
-
|
|
36
|
-
|
|
52
|
+
@lock.synchronize do
|
|
53
|
+
render
|
|
54
|
+
@reading = true
|
|
55
|
+
end
|
|
56
|
+
# The key is read outside the lock: waiting for one must not hold
|
|
57
|
+
# back a log line.
|
|
58
|
+
@in.raw do
|
|
59
|
+
loop do
|
|
60
|
+
key = read_key
|
|
61
|
+
break if @lock.synchronize { handle(key) } == :done
|
|
62
|
+
end
|
|
63
|
+
end
|
|
37
64
|
|
|
38
65
|
@result
|
|
39
66
|
ensure
|
|
40
|
-
|
|
67
|
+
@lock.synchronize do
|
|
68
|
+
@reading = false
|
|
69
|
+
finish
|
|
70
|
+
end
|
|
41
71
|
end
|
|
42
72
|
|
|
43
73
|
private
|
data/lib/agents_control.rb
CHANGED