diffbroker 0.10.0 → 0.12.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 90e8bd666f8efcb2f12429d70caee992e7e06337041a32333b334beedf0c6053
4
- data.tar.gz: 48507f0c813906471b8345e3473f8f7f5c729c4c68ebeb46ac45579f3b3ed87e
3
+ metadata.gz: 5b3251c8cf1dc34ca4cfda98157ac33e2cdd3181d2b7464b6732a73b6731de43
4
+ data.tar.gz: cf1c80fb6862c480b58442d6432ef38d05e7817a44f5813642b402d2b1450a7e
5
5
  SHA512:
6
- metadata.gz: 58362a84f6cc68cde8a081c5f02d6177db48656557bb9262390b272357e574343c33153d2118728b8dd18971cb68962199d327d740cc342ca9490e54fbe55196
7
- data.tar.gz: 6d4ee8a4c7fdc7d8619ed4efc2caef559a354bc28f6987ebe213cb94122ba989ff4686a8ae6a03aec2e72c993318e3aca2a4cc99bb923495082d4493340dab1e
6
+ metadata.gz: 4561d11449c3387cd2f97b44bb60b2f4bf0f3259ae5d5bc97fb7c13764c00c68a60f787fec09f45a876dccb1c861edd4d7f8acf24448fec0d2f16b3878e0e336
7
+ data.tar.gz: 79116f0dff8028b473d6c526606cca711c091e1b71bdf7fdc304482d41e2ca9d171e3d0aa01b1396c0c4e22870abf1300b8aa2360ed2a66ccbf566ea6e8cfab5
data/exe/diffbroker CHANGED
@@ -3,6 +3,9 @@
3
3
 
4
4
  require_relative "../lib/diffbroker"
5
5
 
6
+ # A daemon's log is read when something is wrong, so lines must reach the file as they happen, not when a buffer fills.
7
+ $stdout.sync = $stderr.sync = true
8
+
6
9
  begin
7
10
  exit Diffbroker::CLI.new(ARGV).run
8
11
  rescue Interrupt
@@ -16,6 +16,9 @@ module Diffbroker
16
16
  # The run's lines for the user's log: what the model said and the tools it called. Subclasses override.
17
17
  def parse_log(_line) = []
18
18
 
19
+ # Whether a chat message can continue this agent's earlier session (task["resume_session_id"]).
20
+ def resumes? = false
21
+
19
22
  def env(token, task_id) = { TOKEN_ENV => token, "DIFFBROKER_TASK_ID" => task_id }
20
23
 
21
24
  private
@@ -27,12 +27,17 @@ module Diffbroker
27
27
 
28
28
  def name = "claude_code"
29
29
 
30
+ def resumes? = true
31
+
30
32
  def autonomous_command(task, mcp_config:)
31
33
  args = task["read_only"] ? READ_ONLY_ARGS : @config.claude_args
32
- [ @config.claude_command, "-p", task.fetch("prompt"), "--output-format", "stream-json", "--verbose", *args,
34
+ [ @config.claude_command, "-p", task.fetch("prompt"), *resume(task), "--output-format", "stream-json", "--verbose", *args,
33
35
  "--mcp-config", mcp_config, *strict(task), "--plugin-dir", @plugin_dir ]
34
36
  end
35
37
 
38
+ # The permissions above are passed again: a resumed session keeps its conversation, not its sandbox.
39
+ def resume(task) = task["resume_session_id"] ? [ "--resume", task["resume_session_id"] ] : []
40
+
36
41
  def terminal_command(task, mcp_config:)
37
42
  [ @config.claude_command, "--mcp-config", mcp_config, *strict(task), "--plugin-dir", @plugin_dir ]
38
43
  end
@@ -49,17 +49,35 @@ module Diffbroker
49
49
  POLL_EVERY = 1
50
50
  SELECT_TIMEOUT = 1
51
51
  CONNECT_TIMEOUT = 10
52
+ SUBSCRIBE_TIMEOUT = 15
53
+ WRITE_TIMEOUT = 15
52
54
  READ_CHUNK = 16_384
53
55
 
56
+ # Writes never block for ever on a peer that vanished (a laptop that slept, a dropped Wi-Fi): after WRITE_TIMEOUT the
57
+ # connection is an error and the daemon reconnects.
54
58
  SocketAdapter = Struct.new(:url, :io) do
55
- def write(data) = io.write(data)
59
+ def write(data)
60
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + WRITE_TIMEOUT
61
+ until data.empty?
62
+ written = io.write_nonblock(data, exception: false)
63
+ case written
64
+ when Integer then data = data.byteslice(written..)
65
+ when :wait_readable, :wait_writable
66
+ left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
67
+ raise Errno::ETIMEDOUT, "write to #{url}" if left <= 0
68
+
69
+ written == :wait_readable ? io.wait_readable(left) : io.wait_writable(left)
70
+ end
71
+ end
72
+ end
56
73
  end
57
74
 
58
- def initialize(url:, token:, origin:, protocol: Protocol.new)
75
+ def initialize(url:, token:, origin:, protocol: Protocol.new, subscribe_timeout: SUBSCRIBE_TIMEOUT)
59
76
  @url = url
60
77
  @token = token
61
78
  @origin = origin
62
79
  @protocol = protocol
80
+ @subscribe_timeout = subscribe_timeout
63
81
  @outbox = Queue.new
64
82
  @wake_reader, @wake_writer = IO.pipe
65
83
  end
@@ -91,8 +109,9 @@ module Diffbroker
91
109
  pump
92
110
  final_flush
93
111
  ensure
94
- @io.close if @io && !@io.closed?
95
- [ @wake_reader, @wake_writer ].each(&:close)
112
+ close_quietly(@io)
113
+ close_quietly(@wake_reader)
114
+ close_quietly(@wake_writer)
96
115
  end
97
116
 
98
117
  private
@@ -147,6 +166,7 @@ module Diffbroker
147
166
  def stale!
148
167
  if @welcomed
149
168
  @stopped = true if monotonic - @last_ping > STALE_AFTER
169
+ raise HandshakeError, "The server never confirmed the device channel subscription" if !@subscribed && monotonic - @welcomed_at > @subscribe_timeout
150
170
  elsif monotonic - @started > STALE_AFTER
151
171
  raise HandshakeError, "No welcome from #{@url} within #{STALE_AFTER} s"
152
172
  end
@@ -171,7 +191,7 @@ module Diffbroker
171
191
  case kind
172
192
  when :welcome
173
193
  @welcomed = true
174
- @last_ping = monotonic
194
+ @welcomed_at = @last_ping = monotonic
175
195
  @driver.text(@protocol.subscribe)
176
196
  when :ping then @last_ping = monotonic
177
197
  when :confirmed
@@ -186,6 +206,19 @@ module Diffbroker
186
206
  end
187
207
  end
188
208
 
209
+ # Best effort: lets the OS notice a peer that vanished without a goodbye.
210
+ def keep_alive(tcp)
211
+ tcp.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
212
+ rescue SystemCallError
213
+ nil
214
+ end
215
+
216
+ def close_quietly(io)
217
+ io.close if io && !io.closed?
218
+ rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
219
+ nil
220
+ end
221
+
189
222
  def wake
190
223
  @wake_writer.write_nonblock(".", exception: false)
191
224
  rescue IOError
@@ -196,7 +229,8 @@ module Diffbroker
196
229
  uri = URI(@url)
197
230
  raise Error, "Invalid cable URL #{@url.inspect}" unless %w[ws wss].include?(uri.scheme) && uri.host
198
231
 
199
- tcp = Socket.tcp(uri.host, uri.port, connect_timeout: CONNECT_TIMEOUT)
232
+ tcp = Socket.tcp(uri.host, uri.port, connect_timeout: CONNECT_TIMEOUT, resolv_timeout: CONNECT_TIMEOUT)
233
+ keep_alive(tcp)
200
234
  return tcp unless uri.scheme == "wss"
201
235
 
202
236
  context = OpenSSL::SSL::SSLContext.new
@@ -204,10 +238,26 @@ module Diffbroker
204
238
  ssl = OpenSSL::SSL::SSLSocket.new(tcp, context)
205
239
  ssl.hostname = uri.host
206
240
  ssl.sync_close = true
207
- ssl.connect
241
+ connect_tls(ssl)
208
242
  ssl
209
243
  end
210
244
 
245
+ # The TLS handshake has no timeout of its own: a captive portal or a stalled VPN would hold the daemon for ever.
246
+ def connect_tls(ssl)
247
+ deadline = monotonic + CONNECT_TIMEOUT
248
+ loop do
249
+ case ssl.connect_nonblock(exception: false)
250
+ when :wait_readable then ssl.wait_readable([ deadline - monotonic, 0 ].max)
251
+ when :wait_writable then ssl.wait_writable([ deadline - monotonic, 0 ].max)
252
+ else return
253
+ end
254
+ raise Errno::ETIMEDOUT, "TLS handshake with #{@url}" if monotonic > deadline
255
+ end
256
+ rescue StandardError
257
+ close_quietly(ssl)
258
+ raise
259
+ end
260
+
211
261
  def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
212
262
  end
213
263
  end
@@ -36,10 +36,13 @@ module Diffbroker
36
36
  Agent activity is visible only to you; only status is sent, never prompts, file contents or tool input.
37
37
  TEXT
38
38
 
39
- def initialize(argv, out: $stdout, err: $stderr, env: ENV, home: Dir.home, input: $stdin, maintenance: nil, lifecycle: nil)
39
+ def initialize(argv, out: $stdout, err: $stderr, env: ENV, home: Dir.home, input: $stdin, maintenance: nil, lifecycle: nil, reconciler: nil,
40
+ sleeper: ->(seconds) { sleep(seconds) })
40
41
  @argv = argv.dup
41
42
  @maintenance = maintenance
42
43
  @lifecycle = lifecycle
44
+ @reconciler = reconciler
45
+ @sleeper = sleeper
43
46
  @out = out
44
47
  @err = err
45
48
  @env = env
@@ -134,6 +137,7 @@ module Diffbroker
134
137
  raise Error, "This machine is not paired. Run `diffbroker start` in a terminal to pair it." unless config.paired?
135
138
 
136
139
  @out.puts "Diff Broker companion #{VERSION} → #{config.server} (agent: #{config.agent}, terminal: #{config.terminal})"
140
+ heal_service_definition
137
141
  Daemon.build(config, out: @out).run
138
142
  rescue Daemon::Rejected
139
143
  # The pairing is dead. Forget it and remove the service, so nothing restarts a daemon that can never connect.
@@ -142,11 +146,20 @@ module Diffbroker
142
146
  raise
143
147
  end
144
148
 
149
+ # A daemon the service manager started under an old definition (another launcher or Ruby, or the old restart policy)
150
+ # corrects the file for the next load. Only the file: the running job keeps going.
151
+ def heal_service_definition
152
+ service = ServiceInstaller.new(home: @home, env: @env)
153
+ service.rewrite! if lifecycle.service_process? && service.installed? && service.stale?
154
+ rescue StandardError
155
+ nil
156
+ end
157
+
145
158
  def set_up(name)
146
159
  forget_dead_pairing if config.paired? && pairing_rejected?
147
160
  Login.new(config:, out: @out, name:).run unless config.paired?
161
+ remove_legacy_service
148
162
  install_background_service
149
- @out.puts "Diff Broker is running in the background. Open #{config.server} to see it connected."
150
163
  rescue Error => e
151
164
  raise unless service_unavailable?(e)
152
165
 
@@ -155,8 +168,16 @@ module Diffbroker
155
168
  run_daemon
156
169
  end
157
170
 
158
- # True only when the server answered "unauthorized". An unreachable server proves nothing, so the pairing is kept.
171
+ # True only when the server answered "unauthorized" twice, a few seconds apart (a deploy or a restore can say it once).
172
+ # An unreachable server proves nothing, so the pairing is kept.
159
173
  def pairing_rejected?
174
+ return false unless ping_unauthorized?
175
+
176
+ @sleeper.call(3)
177
+ ping_unauthorized?
178
+ end
179
+
180
+ def ping_unauthorized?
160
181
  Api.new(server: config.server, token: config.device_token).get("/device/ping")
161
182
  false
162
183
  rescue Api::HttpError => e
@@ -171,16 +192,37 @@ module Diffbroker
171
192
  ServiceInstaller.new(home: @home, env: @env).uninstall!
172
193
  end
173
194
 
174
- # Installs what is missing and starts it. A service that is already installed is only started (installing again
175
- # would restart a daemon that may be running a task).
195
+ # Installs what is missing, then makes sure the newest companion runs and is connected (Reconciler): an installed
196
+ # service is not just started, because starting a running job does nothing and the daemon in it may be an older version.
176
197
  def install_background_service
177
198
  service = ServiceInstaller.new(home: @home, env: @env)
178
- return lifecycle.launch if service.installed?
199
+ install_service unless service.installed?
200
+ raise Error, "The companion is not connected yet." unless reconciler(service).call
201
+ end
179
202
 
180
- install_service
203
+ def service_unavailable?(error)
204
+ error.is_a?(ServiceInstaller::Unavailable) || error.message.include?("not found on PATH") || error.message.include?("systemctl")
181
205
  end
182
206
 
183
- def service_unavailable?(error) = error.message.include?("not found on PATH") || error.message.include?("systemctl")
207
+ # The service of the companion's old name (gitbroker) keeps crash-looping against a domain that no longer answers.
208
+ def remove_legacy_service
209
+ @out.puts "Removed the background service of the companion's old name (gitbroker)." if ServiceInstaller.new(home: @home, env: @env).remove_legacy!
210
+ rescue Error
211
+ nil
212
+ end
213
+
214
+ def reconciler(service = ServiceInstaller.new(home: @home, env: @env), version: VERSION)
215
+ @reconciler ||= Reconciler.new(service:, lifecycle:, state: DaemonState.new(dir: config.dir), out: @out, server: config.server, version:,
216
+ on_refresh: method(:refresh_integrations))
217
+ end
218
+
219
+ # A rewritten service runs a new launcher: the Claude Code hooks and the start link name it too.
220
+ def refresh_integrations
221
+ refresh_hooks
222
+ UrlHandlerInstaller.new(home: @home).install!
223
+ rescue Error
224
+ nil
225
+ end
184
226
 
185
227
  def install_service
186
228
  raise Error, "This machine is not paired. Run `diffbroker start` first." unless config.paired?
@@ -200,7 +242,8 @@ module Diffbroker
200
242
  # service, or the daemon itself when none is installed. Safe to run when it is already running.
201
243
  def launch
202
244
  raise Error, "This machine is not paired. Run `diffbroker start` first." unless config.paired?
203
- lifecycle.launch
245
+
246
+ reconciler.call(verify: false)
204
247
  end
205
248
 
206
249
  def lifecycle = @lifecycle ||= Lifecycle.new(dir: config.dir, home: @home, service: ServiceInstaller.new(home: @home, env: @env))
@@ -214,8 +257,8 @@ module Diffbroker
214
257
  def restart
215
258
  raise Error, "This machine is not paired. Run `diffbroker start` first." unless config.paired?
216
259
 
217
- lifecycle.restart
218
260
  @out.puts "Restarting the companion."
261
+ raise Error, "The companion is not connected yet." unless reconciler.call(force: true)
219
262
  end
220
263
 
221
264
  def maintenance = @maintenance ||= Maintenance.new(hooks: hooks_installer)
@@ -226,8 +269,14 @@ module Diffbroker
226
269
  maintenance.upgrade!
227
270
  return @out.puts("Upgraded. Run `diffbroker start` to pair this machine.") unless config.paired?
228
271
 
229
- lifecycle.restart
230
272
  @out.puts "Upgraded. Restarting the companion."
273
+ raise Error, "The companion is not connected yet." unless reconciler(version: newest_installed_version).call(force: true)
274
+ end
275
+
276
+ # This process runs the old code, so the version to wait for is the newest one on disk.
277
+ def newest_installed_version
278
+ Gem::Specification.reset
279
+ Gem::Specification.find_all_by_name("diffbroker").map(&:version).max&.to_s || VERSION
231
280
  end
232
281
 
233
282
  def uninstall
@@ -249,6 +298,7 @@ module Diffbroker
249
298
  @out.puts "Paired: #{config.paired? ? "yes (device #{config.device_id})" : "no (run `diffbroker start`)"}"
250
299
  @out.puts "Agent: #{config.agent}"
251
300
  @out.puts "Terminal: #{config.terminal}"
301
+ daemon_status
252
302
  @out.puts "Hooks: #{hooks_installer.installed? ? "installed" : "not installed (diffbroker install-hooks)"}"
253
303
  @out.puts "Roots: #{config.roots.join(", ")} (plus the projects Claude Code and Codex know)"
254
304
  @out.puts "Worktrees: #{config.worktree_root}"
@@ -258,6 +308,29 @@ module Diffbroker
258
308
  repositories.each { @out.puts " #{_1["full_name"]} #{_1["path"]}" }
259
309
  end
260
310
 
311
+ # What the daemon wrote about itself (DaemonState), next to what this installed version and service would run.
312
+ def daemon_status
313
+ data = DaemonState.new(dir: config.dir).read
314
+ pid = data&.dig("pid")
315
+ running = pid && Process.kill(0, pid.to_i) && true
316
+ if running
317
+ line = "running (pid #{pid}, #{data["state"]}"
318
+ line += ", version #{data["version"]} but #{VERSION} is installed: run `diffbroker start`" if data["version"] != VERSION
319
+ @out.puts "Daemon: #{line})"
320
+ elsif (legacy = lifecycle.running_pid)
321
+ @out.puts "Daemon: running an older version (pid #{legacy}) that reports no state: run `diffbroker start` to replace it"
322
+ else
323
+ @out.puts "Daemon: not running (run `diffbroker start`)#{" — last state: #{data["state"]}" if data}"
324
+ end
325
+ error = data&.dig("last_error")
326
+ @out.puts "Last error: #{error["message"]} (#{error["kind"]})" if error
327
+ service = ServiceInstaller.new(home: @home, env: @env)
328
+ @out.puts "Service: #{service.installed? ? (service.stale? ? "installed but out of date: run `diffbroker start`" : "installed") : "not installed"}"
329
+ @out.puts "Log: #{lifecycle.log_path}"
330
+ rescue SystemCallError
331
+ @out.puts "Daemon: not running (run `diffbroker start`)"
332
+ end
333
+
261
334
  # `git worktree remove` without --force refuses a worktree with modified or untracked files, so work is never lost.
262
335
  def clean
263
336
  worktree = Worktree.new(root: config.worktree_root)
@@ -3,6 +3,8 @@
3
3
  require "openssl"
4
4
  require "socket"
5
5
  require "set"
6
+ require "timeout"
7
+ require "rbconfig"
6
8
 
7
9
  module Diffbroker
8
10
  # `diffbroker start`: keep the device connected, report checkouts, claim and run tasks, and relay the status of
@@ -10,6 +12,17 @@ module Diffbroker
10
12
  class Daemon
11
13
  class Rejected < Error; end
12
14
 
15
+ # The daemon's own log lines carry the time, so a log that stops can be read against the clock.
16
+ class Stamped
17
+ def initialize(out, clock: -> { Time.now })
18
+ @out = out
19
+ @clock = clock
20
+ end
21
+
22
+ def puts(*lines) = lines.flatten.each { @out.puts("#{@clock.call.strftime("%F %T")} #{_1}") }
23
+ def flush = @out.flush
24
+ end
25
+
13
26
  RESCAN_EVERY = 300
14
27
  USAGE_EVERY = 300
15
28
  MAX_BACKOFF = 60
@@ -26,9 +39,21 @@ module Diffbroker
26
39
 
27
40
  new(scanner: config.repo_scanner, roots_changed: roots_changed(config), device_roots: config.device_roots, ignored_roots: config.ignored_roots,
28
41
  client_factory: -> { CableClient.new(url: config.cable_url, token: config.device_token, origin: config.server) },
29
- task_runner_factory: ->(report) { TaskRunner.new(config:, report:, out:) }, out:, relay:, process_scanner:, agent: config.agent,
42
+ task_runner_factory: ->(report) { TaskRunner.new(config:, report:, out:) }, out: Stamped.new(out), relay:, process_scanner:, agent: config.agent,
30
43
  plan_usage: PlanUsage.new(agent: config.agent),
31
- lifecycle: Lifecycle.new(dir: config.dir, server: config.server), reaper: Reaper.new(root: config.worktree_root))
44
+ lifecycle: Lifecycle.new(dir: config.dir, server: config.server), reaper: Reaper.new(root: config.worktree_root),
45
+ state: DaemonState.new(dir: config.dir), lock: DaemonLock.new(dir: config.dir), server: config.server,
46
+ rejection_confirmed: -> { server_rejects?(config) }, jitter: ->(seconds) { seconds * rand(0.8..1.25) })
47
+ end
48
+
49
+ # True only when the server itself answers 401 to the device token. An unreachable or erroring server proves nothing.
50
+ def self.server_rejects?(config)
51
+ Api.new(server: config.server, token: config.device_token).get("/device/ping")
52
+ false
53
+ rescue Api::HttpError => e
54
+ e.status == 401
55
+ rescue StandardError
56
+ false
32
57
  end
33
58
 
34
59
  # The server's "roots" message: remember the folders in config.yml (so they survive a restart without the server) and
@@ -56,7 +81,8 @@ module Diffbroker
56
81
 
57
82
  def initialize(scanner:, client_factory:, task_runner_factory:, out: $stdout, sleeper: ->(seconds) { sleep(seconds) },
58
83
  clock: -> { Time.now }, spawner: ->(&block) { Thread.new(&block) }, relay: nil, process_scanner: nil, lifecycle: nil,
59
- maintenance: Maintenance.new, roots_changed: nil, device_roots: [], ignored_roots: [], agent: "claude", reaper: nil, plan_usage: nil)
84
+ maintenance: Maintenance.new, roots_changed: nil, device_roots: [], ignored_roots: [], agent: "claude", reaper: nil, plan_usage: nil,
85
+ state: nil, lock: nil, rejection_confirmed: nil, server: nil, jitter: ->(seconds) { seconds })
60
86
  @scanner = scanner
61
87
  @device_roots = device_roots
62
88
  @ignored_roots = ignored_roots
@@ -74,6 +100,11 @@ module Diffbroker
74
100
  @agent = agent
75
101
  @reaper = reaper
76
102
  @plan_usage = plan_usage
103
+ @state = state
104
+ @lock = lock
105
+ @rejection_confirmed = rejection_confirmed
106
+ @server = server
107
+ @jitter = jitter
77
108
  @repositories = []
78
109
  @running = Set.new
79
110
  @running_lock = Mutex.new
@@ -82,32 +113,65 @@ module Diffbroker
82
113
  def run
83
114
  @backoff = 1
84
115
  @rejections = 0
116
+ # One daemon per settings directory. A second one (a hand-started daemon next to the service, a double click on
117
+ # "Start companion") leaves quietly and untouched: the first keeps the relay socket and the connection.
118
+ if @lock && !@lock.acquire
119
+ @out.puts "Another diffbroker daemon already runs here; leaving it alone."
120
+ return
121
+ end
122
+ @owner = true
85
123
  @relay&.start
86
124
  @lifecycle&.write_pid
125
+ @state&.update(pid: Process.pid, version: VERSION, ruby: RbConfig.ruby, server: @server, started_at: @clock.call.to_i,
126
+ service_managed: @lifecycle ? @lifecycle.service_process? : false, state: "starting", busy_tasks: 0, error: nil)
87
127
  sweep_strays
88
128
  until @stopping
89
129
  begin
90
130
  @client = @client_factory.call
131
+ @state&.update(state: "connecting")
91
132
  wire(@client)
92
133
  @client.run
93
- rescue CableClient::Unauthorized
94
- @rejections += 1
95
- raise Rejected, "Diff Broker no longer knows this device (revoked?). Run `diffbroker start` to pair again." if @rejections >= REJECTIONS_BEFORE_GIVING_UP
96
-
97
- @out.puts "Diff Broker rejected this device (#{@rejections} of #{REJECTIONS_BEFORE_GIVING_UP}); trying again."
98
- rescue Error, SystemCallError, IOError, SocketError, OpenSSL::SSL::SSLError => e
99
- @out.puts "Connection problem: #{e.message}"
134
+ @out.puts "Connection closed; reconnecting." unless @stopping
135
+ rescue CableClient::Unauthorized => e
136
+ give_up_or_retry(e)
137
+ rescue Error, SystemCallError, IOError, SocketError, OpenSSL::SSL::SSLError, Timeout::Error => e
138
+ failed(e)
139
+ rescue StandardError, ScriptError => e
140
+ # A bug or a half-loaded gem after an upgrade must cost one reconnect, never the daemon: with no service
141
+ # manager nothing would bring it back.
142
+ failed(e, backtrace: true)
100
143
  end
101
144
  break if @stopping
102
145
 
103
- @sleeper.call(@backoff)
146
+ @state&.update(state: "backoff")
147
+ @sleeper.call(@jitter.call(@backoff))
104
148
  @backoff = [ @backoff * 2, MAX_BACKOFF ].min
105
149
  end
106
150
  ensure
107
- @relay&.stop
108
- @lifecycle&.clear_pid
109
- # Only now, with the connection closed, so two daemons never share the device.
110
- @lifecycle.spawn_daemon if @respawn && @lifecycle
151
+ if @owner
152
+ @relay&.stop
153
+ @lifecycle&.clear_pid
154
+ @state&.clear(pid: Process.pid) unless @rejected # a rejected daemon leaves its reason for `status`
155
+ @lock&.release
156
+ # Only now, with the connection closed, so two daemons never share the device.
157
+ @lifecycle.spawn_daemon if @respawn && @lifecycle
158
+ end
159
+ end
160
+
161
+ # What went wrong, as a kind a person can act on and the message to show next to it.
162
+ def self.classify(error)
163
+ message = error.message.to_s
164
+ status = message[/response code: (\d+)/, 1]&.to_i
165
+ kind = case error
166
+ when SocketError then "dns"
167
+ when Errno::ECONNREFUSED then "refused"
168
+ when OpenSSL::SSL::SSLError then "tls"
169
+ when Errno::ETIMEDOUT, Timeout::Error then "timeout"
170
+ when CableClient::HandshakeError then "handshake"
171
+ when Errno::ECONNRESET, Errno::EPIPE, EOFError, IOError, SystemCallError then "network"
172
+ else "internal"
173
+ end
174
+ { kind:, message: message.lines.first.to_s.strip[0, 300], status: }.compact
111
175
  end
112
176
 
113
177
  def stop
@@ -121,10 +185,38 @@ module Diffbroker
121
185
 
122
186
  private
123
187
 
188
+ def failed(error, backtrace: false)
189
+ info = self.class.classify(error)
190
+ @state&.update(error: info.merge(at: @clock.call.to_i))
191
+ @out.puts "Connection problem (#{info[:kind]}): #{info[:message]}"
192
+ @out.puts error.backtrace.first(5).map { " #{_1}" } if backtrace && error.backtrace
193
+ end
194
+
195
+ # The server said "unauthorized". Three in a row are not proof: a deploy or a restore can answer that for a moment.
196
+ # The pairing is only dead when the server's own HTTP check (GET /device/ping) says 401 too.
197
+ def give_up_or_retry(error)
198
+ @rejections += 1
199
+ if @rejections < REJECTIONS_BEFORE_GIVING_UP
200
+ @out.puts "Diff Broker rejected this device (#{@rejections} of #{REJECTIONS_BEFORE_GIVING_UP}); trying again."
201
+ return
202
+ end
203
+
204
+ if @rejection_confirmed.nil? || @rejection_confirmed.call
205
+ @rejected = true
206
+ @state&.update(state: "rejected", error: { kind: "unauthorized", message: error.message, at: @clock.call.to_i })
207
+ raise Rejected, "Diff Broker no longer knows this device (revoked?). Run `diffbroker start` to pair again."
208
+ end
209
+
210
+ @rejections = 0
211
+ @state&.update(error: { kind: "unauthorized", message: "rejected on the cable, but the server still knows this device", at: @clock.call.to_i })
212
+ @out.puts "Diff Broker rejected the connection, but the server still knows this device; trying again."
213
+ end
214
+
124
215
  def wire(client)
125
216
  client.on_subscribed do
126
217
  @backoff = 1
127
218
  @rejections = 0
219
+ @state&.update(state: "connected", last_connect_at: @clock.call.to_i, error: nil)
128
220
  rescan(client)
129
221
  @usage_at = nil
130
222
  client.perform("hello", "version" => VERSION, "platform" => Diffbroker.platform,
@@ -271,6 +363,7 @@ module Diffbroker
271
363
  def start_task(id, message)
272
364
  return unless @running_lock.synchronize { !@upgrading && @running.add?(id) } # the upgrade's restart would kill it
273
365
 
366
+ @state&.update(busy_tasks: @running_lock.synchronize { @running.size })
274
367
  repositories = @repositories
275
368
  @spawner.call do
276
369
  @task_runner.run(message, repositories:)
@@ -278,6 +371,7 @@ module Diffbroker
278
371
  @out.puts "Task #{id} crashed: #{e.class}: #{e.message}"
279
372
  ensure
280
373
  upgrade_now = @running_lock.synchronize { @running.delete(id) && @pending_upgrade && @running.empty? }
374
+ @state&.update(busy_tasks: @running_lock.synchronize { @running.size })
281
375
  upgrade if upgrade_now
282
376
  sweep_strays
283
377
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Diffbroker
6
+ # One daemon per settings directory. An exclusive flock held for the daemon's life: the kernel releases it when the
7
+ # process dies, however it dies, so there is never a stale lock to clean up (unlike a pid file).
8
+ class DaemonLock
9
+ FILE = "daemon.lock"
10
+
11
+ def initialize(dir:)
12
+ @dir = dir
13
+ end
14
+
15
+ def path = File.join(@dir, FILE)
16
+
17
+ # True when this process now holds the lock.
18
+ def acquire
19
+ FileUtils.mkdir_p(@dir, mode: 0o700)
20
+ @file = File.open(path, File::RDWR | File::CREAT, 0o600)
21
+ return true if @file.flock(File::LOCK_EX | File::LOCK_NB)
22
+
23
+ @file.close
24
+ @file = nil
25
+ false
26
+ end
27
+
28
+ def release
29
+ @file&.flock(File::LOCK_UN)
30
+ @file&.close
31
+ @file = nil
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module Diffbroker
7
+ # What the running daemon is doing, as a small JSON file next to config.yml. The daemon writes it on every transition;
8
+ # `diffbroker start` / `status` read it to tell a connected daemon of this version from a stale, dead or failing one.
9
+ # Writes are atomic (temp file + rename), so a reader never sees half a file.
10
+ class DaemonState
11
+ FILE = "daemon.state"
12
+
13
+ def initialize(dir:)
14
+ @dir = dir
15
+ end
16
+
17
+ def path = File.join(@dir, FILE)
18
+
19
+ def read
20
+ data = JSON.parse(File.read(path))
21
+ data.is_a?(Hash) ? data : nil
22
+ rescue SystemCallError, JSON::ParserError
23
+ nil
24
+ end
25
+
26
+ # `error: nil` clears the last error; a Hash sets it. Other keys are merged in as given.
27
+ def update(**changes)
28
+ current = read || {}
29
+ error = changes.key?(:error) ? changes.delete(:error) : current["last_error"]
30
+ data = current.merge(changes.transform_keys(&:to_s))
31
+ error ? data["last_error"] = error.transform_keys(&:to_s) : data.delete("last_error")
32
+ write(data)
33
+ end
34
+
35
+ # Only the owner removes it, so a late-exiting old daemon never erases a newer daemon's state.
36
+ def clear(pid:)
37
+ File.delete(path) if read&.dig("pid") == pid
38
+ rescue SystemCallError
39
+ nil
40
+ end
41
+
42
+ private
43
+
44
+ def write(data)
45
+ FileUtils.mkdir_p(@dir, mode: 0o700)
46
+ temp = "#{path}.#{Process.pid}.tmp"
47
+ File.write(temp, JSON.generate(data), perm: 0o600)
48
+ File.rename(temp, path)
49
+ rescue SystemCallError
50
+ nil # the state file is a convenience; a read-only home never stops the daemon
51
+ end
52
+ end
53
+ end
@@ -9,6 +9,8 @@ module Diffbroker
9
9
  # restarted through its manager, otherwise it would resurrect a stopped daemon (KeepAlive / Restart=always).
10
10
  class Lifecycle
11
11
  PID_FILE = "daemon.pid"
12
+ # `diffbroker start` / `launch` as a process: a recycled pid that is `tail -f ~/.config/diffbroker/companion.log` is not it.
13
+ DAEMON_COMMAND = %r{(?:\A|[/\s])diffbroker\s+(?:start|launch)\b}
12
14
  WAIT = 10
13
15
 
14
16
  def initialize(dir:, home: Dir.home, service: ServiceInstaller.new(home:), runner: Runner.new, exe: ServiceInstaller.launcher,
@@ -87,11 +89,14 @@ module Diffbroker
87
89
  return nil if pid <= 1 || pid == @pid
88
90
 
89
91
  Process.kill(0, pid)
90
- @runner.capture("ps", "-p", pid.to_s, "-o", "command=").include?("diffbroker") ? pid : nil
92
+ @runner.capture("ps", "-p", pid.to_s, "-o", "command=").match?(DAEMON_COMMAND) ? pid : nil
91
93
  rescue SystemCallError
92
94
  nil
93
95
  end
94
96
 
97
+ # Ends a daemon started outside the service manager (and waits for it to go).
98
+ def terminate_daemon = terminate_running
99
+
95
100
  private
96
101
 
97
102
  def terminate_running
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Diffbroker
4
+ # Brings this machine to "the newest companion is running and connected", whatever it looks like now: an older daemon
5
+ # still running after `gem install`, a service definition that names a deleted launcher or another Ruby, a daemon
6
+ # started by hand next to the service, nothing running at all. Safe to run any number of times: a machine that is
7
+ # already right is left untouched. `diffbroker start`, `launch`, `restart` and `upgrade` all end here.
8
+ class Reconciler
9
+ INTERVAL = 1
10
+
11
+ # `attempts` x INTERVAL seconds are given to the daemon to report "connected" before it counts as a failure.
12
+ def initialize(service:, lifecycle:, state:, out:, server:, sleeper: ->(seconds) { sleep(seconds) }, alive: method(:process_alive?),
13
+ attempts: 20, version: VERSION, on_refresh: nil)
14
+ @service = service
15
+ @lifecycle = lifecycle
16
+ @state = state
17
+ @out = out
18
+ @server = server
19
+ @sleeper = sleeper
20
+ @alive = alive
21
+ @attempts = attempts
22
+ @version = version
23
+ @on_refresh = on_refresh
24
+ end
25
+
26
+ # True when the daemon is (or, with verify: false, was asked to be) running the current version. `force` restarts a
27
+ # current daemon too (`diffbroker restart`, the end of an upgrade).
28
+ def call(force: false, verify: true)
29
+ current = running_daemon
30
+ if current && busy?(current) && (force || stale?(current))
31
+ @out.puts "The companion is running a task; leaving it until the task ends. Run this again then."
32
+ return true
33
+ end
34
+
35
+ act(current, force:)
36
+ verify ? wait_until_connected : true
37
+ end
38
+
39
+ private
40
+
41
+ def act(current, force:)
42
+ if @service.installed? && @service.stale?
43
+ # launchd keeps the definition it loaded, so the new one only counts after the job is loaded again; that ends the
44
+ # daemon it was running and starts the new one.
45
+ @lifecycle.terminate_daemon if current && !current["service_managed"]
46
+ @service.refresh!
47
+ @on_refresh&.call
48
+ @out.puts "Updated the background service so it runs this version."
49
+ elsif current && (force || stale?(current))
50
+ restart(current)
51
+ elsif !current
52
+ @lifecycle.launch
53
+ end
54
+ end
55
+
56
+ # Under a service the manager restarts the daemon (it loads the newest gem); a daemon started by hand is ended first,
57
+ # because the manager does not know it and would leave it running next to the new one.
58
+ def restart(current)
59
+ return @lifecycle.restart unless @service.installed?
60
+
61
+ @lifecycle.terminate_daemon unless current["service_managed"]
62
+ done = current["service_managed"] ? @service.restart! : @service.start!
63
+ @lifecycle.spawn_daemon unless done
64
+ end
65
+
66
+ # Not the current version, aimed at another server, left by a rejection, or too old to have a state file.
67
+ def stale?(current)
68
+ current["legacy"] || current["version"] != @version || current["server"] != @server || current["state"] == "rejected"
69
+ end
70
+
71
+ def busy?(current) = current["busy_tasks"].to_i.positive?
72
+
73
+ # The live daemon as its state file describes it; a pid file without a state file is a daemon from before this
74
+ # release, which has to be restarted once to start writing one.
75
+ def running_daemon
76
+ data = @state.read
77
+ return data if data && data["pid"] && @alive.call(data["pid"])
78
+
79
+ pid = @lifecycle.running_pid
80
+ pid ? { "pid" => pid, "legacy" => true } : nil
81
+ end
82
+
83
+ def verified?
84
+ data = @state.read
85
+ connected = data && data["state"] == "connected" && data["version"] == @version && data["pid"] && @alive.call(data["pid"])
86
+ @out.puts "Connected (companion #{@version}, pid #{data["pid"]})." if connected
87
+ connected
88
+ end
89
+
90
+ def wait_until_connected
91
+ @attempts.times do
92
+ return true if verified?
93
+
94
+ @sleeper.call(INTERVAL)
95
+ end
96
+ return true if verified?
97
+
98
+ report_failure
99
+ false
100
+ end
101
+
102
+ def report_failure
103
+ data = @state.read || {}
104
+ error = data["last_error"]
105
+ @out.puts "The companion did not connect within #{@attempts * INTERVAL} seconds."
106
+ @out.puts "Last problem: #{error["message"]} (#{error["kind"]})" if error
107
+ @out.puts "State: #{data["state"] || "no daemon is running"}#{" (version #{data["version"]}, wanted #{@version})" if data["version"] && data["version"] != @version}"
108
+ @out.puts "Log: #{@lifecycle.log_path}"
109
+ @out.puts "It keeps trying in the background; `diffbroker status` shows what it sees."
110
+ end
111
+
112
+ def process_alive?(pid)
113
+ Process.kill(0, pid.to_i)
114
+ true
115
+ rescue Errno::EPERM
116
+ true
117
+ rescue SystemCallError
118
+ false
119
+ end
120
+ end
121
+ end
@@ -7,6 +7,11 @@ module Diffbroker
7
7
  # Runs `diffbroker start` in the background as the logged-in user (launchd on macOS, systemd --user on Linux).
8
8
  class ServiceInstaller
9
9
  LABEL = "broker.diff.companion"
10
+ LEGACY_LABEL = "broker.git.companion"
11
+ # The service manager refused (no GUI session over ssh, no systemd user bus): the caller falls back to a daemon of its own.
12
+ class Unavailable < Error; end
13
+
14
+ SETTLE_ATTEMPTS = 20
10
15
  EXE = File.join(Diffbroker::ROOT, "exe", "diffbroker")
11
16
 
12
17
  # What the service and the "Start companion" app run. RubyGems' own wrapper activates the newest installed diffbroker
@@ -27,7 +32,8 @@ module Diffbroker
27
32
  def self.pinned?(launcher = self.launcher) = launcher == EXE
28
33
 
29
34
  def initialize(platform: Diffbroker.platform, home: Dir.home, ruby: RbConfig.ruby, exe: ServiceInstaller.launcher, env: ENV, runner: Runner.new,
30
- uid: Process.uid)
35
+ uid: Process.uid, sleeper: ->(seconds) { sleep(seconds) })
36
+ @sleeper = sleeper
31
37
  @platform = platform
32
38
  @home = home
33
39
  @ruby = ruby
@@ -39,7 +45,63 @@ module Diffbroker
39
45
 
40
46
  def install! = @platform == "macos" ? install_launchd! : install_systemd!
41
47
 
42
- def installed? = File.exist?(@platform == "macos" ? launchd_path : systemd_path)
48
+ def installed? = File.exist?(definition_path)
49
+
50
+ # True when the installed definition would not start what this process is: another Ruby or launcher, a launcher or Ruby
51
+ # that no longer exists (`gem cleanup` removed the version a pinned definition names), or a definition from before
52
+ # the duplicate-safe restart policy. PATH is left out: it differs between a terminal and the service, so comparing it
53
+ # would call every definition stale.
54
+ def stale?
55
+ return false unless installed?
56
+
57
+ text = File.read(definition_path)
58
+ !text.include?(program_arguments) || !text.include?(restart_policy_marker) || !File.exist?(@ruby) || !File.exist?(@exe)
59
+ rescue SystemCallError
60
+ true
61
+ end
62
+
63
+ # The companion was called gitbroker before the rename. Its service points at a domain that no longer answers and
64
+ # restarts for ever, so it goes. Returns true when one was there.
65
+ def remove_legacy!
66
+ if @platform == "macos"
67
+ path = File.join(@home, "Library", "LaunchAgents", "#{LEGACY_LABEL}.plist")
68
+ return false unless File.exist?(path)
69
+
70
+ File.delete(path)
71
+ @runner.success?("launchctl", "bootout", "gui/#{@uid}/#{LEGACY_LABEL}")
72
+ else
73
+ path = File.join(@home, ".config", "systemd", "user", "gitbroker.service")
74
+ return false unless File.exist?(path)
75
+
76
+ @runner.success?("systemctl", "--user", "disable", "--now", "gitbroker.service")
77
+ File.delete(path)
78
+ @runner.success?("systemctl", "--user", "daemon-reload")
79
+ end
80
+ true
81
+ end
82
+
83
+ # Writes the current definition without touching the manager (the running job keeps what it loaded).
84
+ def rewrite!
85
+ path = definition_path
86
+ FileUtils.mkdir_p(File.dirname(path))
87
+ FileUtils.mkdir_p(File.join(@home, "Library", "Logs")) if @platform == "macos"
88
+ File.write(path, @platform == "macos" ? plist : unit)
89
+ path
90
+ end
91
+
92
+ # Writes the current definition and makes the manager use it. launchd keeps the plist it loaded, so the job is booted
93
+ # out and loaded again (this ends a running daemon); systemd re-reads its unit and the next restart uses it.
94
+ def refresh!
95
+ path = rewrite!
96
+ if @platform == "macos"
97
+ reload_launchd!(path)
98
+ else
99
+ @runner.run!("systemctl", "--user", "daemon-reload")
100
+ end
101
+ path
102
+ rescue Error => e
103
+ raise Unavailable, e.message
104
+ end
43
105
 
44
106
  # Starts the installed service if it is not running (a running one is left alone). False when there is no
45
107
  # service or the manager refused, so the caller can start the daemon itself.
@@ -117,7 +179,12 @@ module Diffbroker
117
179
  <key>RunAtLoad</key>
118
180
  <true/>
119
181
  <key>KeepAlive</key>
120
- <true/>
182
+ <dict>
183
+ <key>SuccessfulExit</key>
184
+ <false/>
185
+ </dict>
186
+ <key>ThrottleInterval</key>
187
+ <integer>30</integer>
121
188
  <key>StandardOutPath</key>
122
189
  <string>#{log}</string>
123
190
  <key>StandardErrorPath</key>
@@ -132,10 +199,11 @@ module Diffbroker
132
199
  [Unit]
133
200
  Description=Diff Broker companion (runs your own agents on your pull requests)
134
201
  After=network-online.target
202
+ StartLimitIntervalSec=0
135
203
 
136
204
  [Service]
137
205
  ExecStart="#{@ruby}" "#{@exe}" start
138
- Restart=always
206
+ Restart=on-failure
139
207
  RestartSec=5
140
208
  Environment="PATH=#{@env["PATH"]}"
141
209
  Environment="SHELL=#{@env["SHELL"] || "/bin/bash"}"
@@ -158,22 +226,40 @@ module Diffbroker
158
226
  def systemd_path = File.join(@home, ".config", "systemd", "user", "diffbroker.service")
159
227
 
160
228
  def install_launchd!
161
- path = launchd_path
162
- FileUtils.mkdir_p(File.dirname(path))
163
- FileUtils.mkdir_p(File.join(@home, "Library", "Logs"))
164
- File.write(path, plist)
165
- @runner.success?("launchctl", "bootout", "gui/#{@uid}/#{LABEL}") # not loaded yet is fine
166
- @runner.run!("launchctl", "bootstrap", "gui/#{@uid}", path)
229
+ path = rewrite!
230
+ reload_launchd!(path)
167
231
  path
232
+ rescue Error => e
233
+ raise Unavailable, e.message
234
+ end
235
+
236
+ # bootout answers before the job is gone, and bootstrap onto a job that is still there fails ("Bootstrap failed: 5"),
237
+ # so wait until launchd no longer knows the label.
238
+ def reload_launchd!(path)
239
+ @runner.success?("launchctl", "bootout", target) # not loaded yet is fine
240
+ SETTLE_ATTEMPTS.times do
241
+ break unless @runner.success?("launchctl", "print", target)
242
+
243
+ @sleeper.call(0.25)
244
+ end
245
+ @runner.run!("launchctl", "bootstrap", "gui/#{@uid}", path)
168
246
  end
169
247
 
170
248
  def install_systemd!
171
- path = systemd_path
172
- FileUtils.mkdir_p(File.dirname(path))
173
- File.write(path, unit)
249
+ path = rewrite!
174
250
  @runner.run!("systemctl", "--user", "daemon-reload")
175
251
  @runner.run!("systemctl", "--user", "enable", "--now", "diffbroker.service")
176
252
  path
253
+ rescue Error => e
254
+ raise Unavailable, e.message
177
255
  end
256
+
257
+ def definition_path = @platform == "macos" ? launchd_path : systemd_path
258
+
259
+ def program_arguments
260
+ @platform == "macos" ? [ @ruby, @exe, "start" ].map { " <string>#{xml(_1)}</string>" }.join("\n") : %(ExecStart="#{@ruby}" "#{@exe}" start)
261
+ end
262
+
263
+ def restart_policy_marker = @platform == "macos" ? "<key>SuccessfulExit</key>" : "Restart=on-failure"
178
264
  end
179
265
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Diffbroker
7
+ # The agent sessions this companion started itself. The server offers to resume a chat's earlier session by id, but it is
8
+ # only ever resumed here when this machine made it: a server (or anyone who could fake its message) cannot make an agent
9
+ # continue another conversation of the user's, such as an interactive session in their own terminal.
10
+ class SessionLedger
11
+ FILE = "sessions.json"
12
+ LIMIT = 500
13
+ ID = /\A\w[\w-]{0,99}\z/
14
+
15
+ def initialize(state_dir:)
16
+ @path = File.join(state_dir, FILE)
17
+ @lock = Mutex.new
18
+ end
19
+
20
+ def remember(id)
21
+ return unless id.to_s.match?(ID)
22
+
23
+ @lock.synchronize do
24
+ ids = read
25
+ next if ids.include?(id)
26
+
27
+ write((ids + [ id ]).last(LIMIT))
28
+ end
29
+ end
30
+
31
+ def known?(id) = id.to_s.match?(ID) && @lock.synchronize { read.include?(id) }
32
+
33
+ private
34
+
35
+ def read
36
+ data = JSON.parse(File.read(@path))
37
+ data.is_a?(Array) ? data.grep(String) : []
38
+ rescue Errno::ENOENT, JSON::ParserError
39
+ []
40
+ end
41
+
42
+ def write(ids)
43
+ FileUtils.mkdir_p(File.dirname(@path), mode: 0o700)
44
+ File.write(@path, JSON.generate(ids), perm: 0o600)
45
+ end
46
+ end
47
+ end
@@ -12,12 +12,12 @@ module Diffbroker
12
12
  PRINT_OUTPUT = 120
13
13
 
14
14
  Handle = Struct.new(:pid, :cancelled)
15
- Outcome = Struct.new(:status, :cost_usd, :error_code, :last_output)
15
+ Outcome = Struct.new(:status, :cost_usd, :error_code, :last_output, :no_result)
16
16
 
17
17
  def initialize(config:, report:, worktree: Worktree.new(root: config.worktree_root), neutralizer: Neutralizer.new,
18
18
  gh_check: GhCheck.new, launcher: Launcher.new(name: config.terminal), out: $stdout, grace: CANCEL_GRACE,
19
19
  progress_interval: PROGRESS_INTERVAL, run_script: RunScript.new(state_dir: config.state_dir),
20
- reaper: Reaper.new(root: config.worktree_root))
20
+ reaper: Reaper.new(root: config.worktree_root), sessions: SessionLedger.new(state_dir: config.state_dir))
21
21
  @config = config
22
22
  @report = report
23
23
  @worktree = worktree
@@ -29,6 +29,7 @@ module Diffbroker
29
29
  @progress_interval = progress_interval
30
30
  @run_script = run_script
31
31
  @reaper = reaper
32
+ @sessions = sessions
32
33
  @handles = {}
33
34
  @running = Set.new
34
35
  @cancelled = Set.new
@@ -132,7 +133,7 @@ module Diffbroker
132
133
  def run_autonomous(task, agent, mcp_config, dir)
133
134
  id = task["task_id"]
134
135
  @lock.synchronize { @running << id }
135
- outcome = run_process(task, agent, mcp_config, dir)
136
+ outcome = run_with_resume(task, agent, mcp_config, dir)
136
137
  outcome = verify_after_write(task, outcome, agent, mcp_config, dir) if ExplainVerifier.verified?(task) && outcome.status == "done"
137
138
  outcome = publish_branch(task, outcome, dir) if outcome.status == "done"
138
139
  finished(id, outcome.status, outcome.cost_usd, outcome.error_code, outcome.last_output)
@@ -143,6 +144,27 @@ module Diffbroker
143
144
  end
144
145
  end
145
146
 
147
+ # A chat message the server offered to resume (task["resume"]) continues the agent's earlier session, when this machine made it
148
+ # and this agent can: it gets only the new question and keeps what it read. A session that cannot be resumed (deleted, expired)
149
+ # makes the agent exit without a word: the message then runs again from scratch with the full prompt, which carries the chat.
150
+ def run_with_resume(task, agent, mcp_config, dir)
151
+ resume = resume_for(task, agent)
152
+ return run_process(task, agent, mcp_config, dir) unless resume
153
+
154
+ outcome = run_process(task.merge("prompt" => resume["prompt"], "resume_session_id" => resume["session_id"]), agent, mcp_config, dir)
155
+ return outcome unless outcome.status == "failed" && outcome.no_result
156
+
157
+ say(task["task_id"], "Could not resume the earlier session: starting this message from scratch")
158
+ run_process(task, agent, mcp_config, dir)
159
+ end
160
+
161
+ def resume_for(task, agent)
162
+ resume = task["resume"]
163
+ return unless resume.is_a?(Hash) && agent.resumes? && resume["agent"] == agent.name && resume["prompt"].to_s != ""
164
+
165
+ resume if @sessions.known?(resume["session_id"])
166
+ end
167
+
146
168
  # An update_branch task ends with a rebase the agent may not push (see BranchPublisher): the companion does, here.
147
169
  def publish_branch(task, outcome, dir)
148
170
  return outcome unless publishes_branch?(task)
@@ -197,7 +219,7 @@ module Diffbroker
197
219
 
198
220
  if handle.cancelled then Outcome.new("cancelled", result&.cost_usd, "cancelled", output)
199
221
  elsif status.success? && !result&.error then Outcome.new("done", result&.cost_usd, nil, output)
200
- else Outcome.new("failed", result&.cost_usd, "agent_failed", output)
222
+ else Outcome.new("failed", result&.cost_usd, "agent_failed", output, result.nil?)
201
223
  end
202
224
  ensure
203
225
  writer.close if writer && !writer.closed?
@@ -221,6 +243,10 @@ module Diffbroker
221
243
  case (event = agent.parse_stream(line))
222
244
  when Agents::Result then result = event
223
245
  when Agents::Progress
246
+ if event.session_id
247
+ @sessions.remember(event.session_id)
248
+ progress(id, event.step, nil, nil, event.session_id)
249
+ end
224
250
  next if event.step == last_step || monotonic - last_sent < @progress_interval
225
251
 
226
252
  progress(id, event.step)
@@ -256,8 +282,8 @@ module Diffbroker
256
282
  end
257
283
  end
258
284
 
259
- def progress(id, step, agent = nil, phase = nil)
260
- @report.call("progress", { "task_id" => id, "step" => step, "agent" => agent, "phase" => phase }.compact)
285
+ def progress(id, step, agent = nil, phase = nil, session_id = nil)
286
+ @report.call("progress", { "task_id" => id, "step" => step, "agent" => agent, "phase" => phase, "session_id" => session_id }.compact)
261
287
  say(id, "progress: #{step}") unless step == "starting"
262
288
  end
263
289
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Diffbroker
4
- VERSION = "0.10.0"
4
+ VERSION = "0.12.0"
5
5
  end
data/lib/diffbroker.rb CHANGED
@@ -21,6 +21,8 @@ module Diffbroker
21
21
  autoload :CLI, File.expand_path("diffbroker/cli", __dir__)
22
22
  autoload :Config, File.expand_path("diffbroker/config", __dir__)
23
23
  autoload :Daemon, File.expand_path("diffbroker/daemon", __dir__)
24
+ autoload :DaemonLock, File.expand_path("diffbroker/daemon_lock", __dir__)
25
+ autoload :DaemonState, File.expand_path("diffbroker/daemon_state", __dir__)
24
26
  autoload :ExplainVerifier, File.expand_path("diffbroker/explain_verifier", __dir__)
25
27
  autoload :GhCheck, File.expand_path("diffbroker/gh_check", __dir__)
26
28
  autoload :GitContext, File.expand_path("diffbroker/git_context", __dir__)
@@ -35,10 +37,12 @@ module Diffbroker
35
37
  autoload :PlanUsage, File.expand_path("diffbroker/plan_usage", __dir__)
36
38
  autoload :ProcessScanner, File.expand_path("diffbroker/process_scanner", __dir__)
37
39
  autoload :Reaper, File.expand_path("diffbroker/reaper", __dir__)
40
+ autoload :Reconciler, File.expand_path("diffbroker/reconciler", __dir__)
38
41
  autoload :RepoScanner, File.expand_path("diffbroker/repo_scanner", __dir__)
39
42
  autoload :RunScript, File.expand_path("diffbroker/run_script", __dir__)
40
43
  autoload :Runner, File.expand_path("diffbroker/runner", __dir__)
41
44
  autoload :ServiceInstaller, File.expand_path("diffbroker/service_installer", __dir__)
45
+ autoload :SessionLedger, File.expand_path("diffbroker/session_ledger", __dir__)
42
46
  autoload :SkillInstaller, File.expand_path("diffbroker/skill_installer", __dir__)
43
47
  autoload :TaskFiles, File.expand_path("diffbroker/task_files", __dir__)
44
48
  autoload :TaskRunner, File.expand_path("diffbroker/task_runner", __dir__)
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "diffbroker",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Skills for working on Diff Broker agent tasks: the diffbroker MCP tools, write rules and report_task."
5
5
  }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: diffbroker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yuri Sidorov (@newstler)
@@ -45,6 +45,8 @@ files:
45
45
  - lib/diffbroker/cli.rb
46
46
  - lib/diffbroker/config.rb
47
47
  - lib/diffbroker/daemon.rb
48
+ - lib/diffbroker/daemon_lock.rb
49
+ - lib/diffbroker/daemon_state.rb
48
50
  - lib/diffbroker/explain_verifier.rb
49
51
  - lib/diffbroker/gh_check.rb
50
52
  - lib/diffbroker/git_context.rb
@@ -59,10 +61,12 @@ files:
59
61
  - lib/diffbroker/plan_usage.rb
60
62
  - lib/diffbroker/process_scanner.rb
61
63
  - lib/diffbroker/reaper.rb
64
+ - lib/diffbroker/reconciler.rb
62
65
  - lib/diffbroker/repo_scanner.rb
63
66
  - lib/diffbroker/run_script.rb
64
67
  - lib/diffbroker/runner.rb
65
68
  - lib/diffbroker/service_installer.rb
69
+ - lib/diffbroker/session_ledger.rb
66
70
  - lib/diffbroker/skill_installer.rb
67
71
  - lib/diffbroker/task_files.rb
68
72
  - lib/diffbroker/task_runner.rb