ScuttleBot
feat: add SIGUSR1 reconnect handler to codex-relay and gemini-relay All three relay binaries now handle SIGUSR1 for IRC reconnection, matching the claude-relay implementation. The relay-watchdog sidecar signals all relay processes regardless of runtime.
Commit
12ca93f7a2d9a592cdaf7d672952609f9979123c4f5c11d46cad038b0d515326
Parent
9f5df4d1183e856…
2 files changed
+85
-1
+85
-1
+85
-1
| --- cmd/codex-relay/main.go | ||
| +++ cmd/codex-relay/main.go | ||
| @@ -216,11 +216,12 @@ | ||
| 216 | 216 | "SCUTTLEBOT_NICK="+cfg.Nick, |
| 217 | 217 | "SCUTTLEBOT_ACTIVITY_VIA_BROKER="+boolString(relayActive), |
| 218 | 218 | ) |
| 219 | 219 | if relayActive { |
| 220 | 220 | go mirrorSessionLoop(ctx, relay, cfg, startedAt) |
| 221 | - go presenceLoop(ctx, relay, cfg.HeartbeatInterval) | |
| 221 | + go presenceLoopPtr(ctx, &relay, cfg.HeartbeatInterval) | |
| 222 | + go handleReconnectSignal(ctx, &relay, cfg) | |
| 222 | 223 | } |
| 223 | 224 | |
| 224 | 225 | if !isInteractiveTTY() { |
| 225 | 226 | cmd.Stdin = os.Stdin |
| 226 | 227 | cmd.Stdout = os.Stdout |
| @@ -328,10 +329,93 @@ | ||
| 328 | 329 | return |
| 329 | 330 | } |
| 330 | 331 | } |
| 331 | 332 | } |
| 332 | 333 | } |
| 334 | + | |
| 335 | +func handleReconnectSignal(ctx context.Context, relayPtr *sessionrelay.Connector, cfg config) { | |
| 336 | + sigCh := make(chan os.Signal, 1) | |
| 337 | + signal.Notify(sigCh, syscall.SIGUSR1) | |
| 338 | + defer signal.Stop(sigCh) | |
| 339 | + | |
| 340 | + for { | |
| 341 | + select { | |
| 342 | + case <-ctx.Done(): | |
| 343 | + return | |
| 344 | + case <-sigCh: | |
| 345 | + } | |
| 346 | + | |
| 347 | + fmt.Fprintf(os.Stderr, "codex-relay: received SIGUSR1, reconnecting IRC...\n") | |
| 348 | + old := *relayPtr | |
| 349 | + if old != nil { | |
| 350 | + _ = old.Close(context.Background()) | |
| 351 | + } | |
| 352 | + | |
| 353 | + // Retry with backoff. | |
| 354 | + wait := 2 * time.Second | |
| 355 | + for attempt := 0; attempt < 10; attempt++ { | |
| 356 | + if ctx.Err() != nil { | |
| 357 | + return | |
| 358 | + } | |
| 359 | + time.Sleep(wait) | |
| 360 | + | |
| 361 | + conn, err := sessionrelay.New(sessionrelay.Config{ | |
| 362 | + Transport: cfg.Transport, | |
| 363 | + URL: cfg.URL, | |
| 364 | + Token: cfg.Token, | |
| 365 | + Channel: cfg.Channel, | |
| 366 | + Channels: cfg.Channels, | |
| 367 | + Nick: cfg.Nick, | |
| 368 | + IRC: sessionrelay.IRCConfig{ | |
| 369 | + Addr: cfg.IRCAddr, | |
| 370 | + Pass: "", // force re-registration | |
| 371 | + AgentType: cfg.IRCAgentType, | |
| 372 | + DeleteOnClose: cfg.IRCDeleteOnClose, | |
| 373 | + }, | |
| 374 | + }) | |
| 375 | + if err != nil { | |
| 376 | + wait = min(wait*2, 30*time.Second) | |
| 377 | + continue | |
| 378 | + } | |
| 379 | + | |
| 380 | + connectCtx, cancel := context.WithTimeout(ctx, 20*time.Second) | |
| 381 | + if err := conn.Connect(connectCtx); err != nil { | |
| 382 | + _ = conn.Close(context.Background()) | |
| 383 | + cancel() | |
| 384 | + wait = min(wait*2, 30*time.Second) | |
| 385 | + continue | |
| 386 | + } | |
| 387 | + cancel() | |
| 388 | + | |
| 389 | + *relayPtr = conn | |
| 390 | + _ = conn.Post(context.Background(), fmt.Sprintf( | |
| 391 | + "reconnected in %s; mention %s to interrupt", | |
| 392 | + filepath.Base(cfg.TargetCWD), cfg.Nick, | |
| 393 | + )) | |
| 394 | + fmt.Fprintf(os.Stderr, "codex-relay: reconnected successfully\n") | |
| 395 | + break | |
| 396 | + } | |
| 397 | + } | |
| 398 | +} | |
| 399 | + | |
| 400 | +func presenceLoopPtr(ctx context.Context, relayPtr *sessionrelay.Connector, interval time.Duration) { | |
| 401 | + if interval <= 0 { | |
| 402 | + return | |
| 403 | + } | |
| 404 | + ticker := time.NewTicker(interval) | |
| 405 | + defer ticker.Stop() | |
| 406 | + for { | |
| 407 | + select { | |
| 408 | + case <-ctx.Done(): | |
| 409 | + return | |
| 410 | + case <-ticker.C: | |
| 411 | + if r := *relayPtr; r != nil { | |
| 412 | + _ = r.Touch(ctx) | |
| 413 | + } | |
| 414 | + } | |
| 415 | + } | |
| 416 | +} | |
| 333 | 417 | |
| 334 | 418 | func presenceLoop(ctx context.Context, relay sessionrelay.Connector, interval time.Duration) { |
| 335 | 419 | if interval <= 0 { |
| 336 | 420 | return |
| 337 | 421 | } |
| 338 | 422 |
| --- cmd/codex-relay/main.go | |
| +++ cmd/codex-relay/main.go | |
| @@ -216,11 +216,12 @@ | |
| 216 | "SCUTTLEBOT_NICK="+cfg.Nick, |
| 217 | "SCUTTLEBOT_ACTIVITY_VIA_BROKER="+boolString(relayActive), |
| 218 | ) |
| 219 | if relayActive { |
| 220 | go mirrorSessionLoop(ctx, relay, cfg, startedAt) |
| 221 | go presenceLoop(ctx, relay, cfg.HeartbeatInterval) |
| 222 | } |
| 223 | |
| 224 | if !isInteractiveTTY() { |
| 225 | cmd.Stdin = os.Stdin |
| 226 | cmd.Stdout = os.Stdout |
| @@ -328,10 +329,93 @@ | |
| 328 | return |
| 329 | } |
| 330 | } |
| 331 | } |
| 332 | } |
| 333 | |
| 334 | func presenceLoop(ctx context.Context, relay sessionrelay.Connector, interval time.Duration) { |
| 335 | if interval <= 0 { |
| 336 | return |
| 337 | } |
| 338 |
| --- cmd/codex-relay/main.go | |
| +++ cmd/codex-relay/main.go | |
| @@ -216,11 +216,12 @@ | |
| 216 | "SCUTTLEBOT_NICK="+cfg.Nick, |
| 217 | "SCUTTLEBOT_ACTIVITY_VIA_BROKER="+boolString(relayActive), |
| 218 | ) |
| 219 | if relayActive { |
| 220 | go mirrorSessionLoop(ctx, relay, cfg, startedAt) |
| 221 | go presenceLoopPtr(ctx, &relay, cfg.HeartbeatInterval) |
| 222 | go handleReconnectSignal(ctx, &relay, cfg) |
| 223 | } |
| 224 | |
| 225 | if !isInteractiveTTY() { |
| 226 | cmd.Stdin = os.Stdin |
| 227 | cmd.Stdout = os.Stdout |
| @@ -328,10 +329,93 @@ | |
| 329 | return |
| 330 | } |
| 331 | } |
| 332 | } |
| 333 | } |
| 334 | |
| 335 | func handleReconnectSignal(ctx context.Context, relayPtr *sessionrelay.Connector, cfg config) { |
| 336 | sigCh := make(chan os.Signal, 1) |
| 337 | signal.Notify(sigCh, syscall.SIGUSR1) |
| 338 | defer signal.Stop(sigCh) |
| 339 | |
| 340 | for { |
| 341 | select { |
| 342 | case <-ctx.Done(): |
| 343 | return |
| 344 | case <-sigCh: |
| 345 | } |
| 346 | |
| 347 | fmt.Fprintf(os.Stderr, "codex-relay: received SIGUSR1, reconnecting IRC...\n") |
| 348 | old := *relayPtr |
| 349 | if old != nil { |
| 350 | _ = old.Close(context.Background()) |
| 351 | } |
| 352 | |
| 353 | // Retry with backoff. |
| 354 | wait := 2 * time.Second |
| 355 | for attempt := 0; attempt < 10; attempt++ { |
| 356 | if ctx.Err() != nil { |
| 357 | return |
| 358 | } |
| 359 | time.Sleep(wait) |
| 360 | |
| 361 | conn, err := sessionrelay.New(sessionrelay.Config{ |
| 362 | Transport: cfg.Transport, |
| 363 | URL: cfg.URL, |
| 364 | Token: cfg.Token, |
| 365 | Channel: cfg.Channel, |
| 366 | Channels: cfg.Channels, |
| 367 | Nick: cfg.Nick, |
| 368 | IRC: sessionrelay.IRCConfig{ |
| 369 | Addr: cfg.IRCAddr, |
| 370 | Pass: "", // force re-registration |
| 371 | AgentType: cfg.IRCAgentType, |
| 372 | DeleteOnClose: cfg.IRCDeleteOnClose, |
| 373 | }, |
| 374 | }) |
| 375 | if err != nil { |
| 376 | wait = min(wait*2, 30*time.Second) |
| 377 | continue |
| 378 | } |
| 379 | |
| 380 | connectCtx, cancel := context.WithTimeout(ctx, 20*time.Second) |
| 381 | if err := conn.Connect(connectCtx); err != nil { |
| 382 | _ = conn.Close(context.Background()) |
| 383 | cancel() |
| 384 | wait = min(wait*2, 30*time.Second) |
| 385 | continue |
| 386 | } |
| 387 | cancel() |
| 388 | |
| 389 | *relayPtr = conn |
| 390 | _ = conn.Post(context.Background(), fmt.Sprintf( |
| 391 | "reconnected in %s; mention %s to interrupt", |
| 392 | filepath.Base(cfg.TargetCWD), cfg.Nick, |
| 393 | )) |
| 394 | fmt.Fprintf(os.Stderr, "codex-relay: reconnected successfully\n") |
| 395 | break |
| 396 | } |
| 397 | } |
| 398 | } |
| 399 | |
| 400 | func presenceLoopPtr(ctx context.Context, relayPtr *sessionrelay.Connector, interval time.Duration) { |
| 401 | if interval <= 0 { |
| 402 | return |
| 403 | } |
| 404 | ticker := time.NewTicker(interval) |
| 405 | defer ticker.Stop() |
| 406 | for { |
| 407 | select { |
| 408 | case <-ctx.Done(): |
| 409 | return |
| 410 | case <-ticker.C: |
| 411 | if r := *relayPtr; r != nil { |
| 412 | _ = r.Touch(ctx) |
| 413 | } |
| 414 | } |
| 415 | } |
| 416 | } |
| 417 | |
| 418 | func presenceLoop(ctx context.Context, relay sessionrelay.Connector, interval time.Duration) { |
| 419 | if interval <= 0 { |
| 420 | return |
| 421 | } |
| 422 |
+85
-1
| --- cmd/gemini-relay/main.go | ||
| +++ cmd/gemini-relay/main.go | ||
| @@ -164,11 +164,12 @@ | ||
| 164 | 164 | "SCUTTLEBOT_HOOKS_ENABLED="+boolString(cfg.HooksEnabled), |
| 165 | 165 | "SCUTTLEBOT_SESSION_ID="+cfg.SessionID, |
| 166 | 166 | "SCUTTLEBOT_NICK="+cfg.Nick, |
| 167 | 167 | ) |
| 168 | 168 | if relayActive { |
| 169 | - go presenceLoop(ctx, relay, cfg.HeartbeatInterval) | |
| 169 | + go presenceLoopPtr(ctx, &relay, cfg.HeartbeatInterval) | |
| 170 | + go handleReconnectSignal(ctx, &relay, cfg) | |
| 170 | 171 | } |
| 171 | 172 | |
| 172 | 173 | if !isInteractiveTTY() { |
| 173 | 174 | cmd.Stdin = os.Stdin |
| 174 | 175 | cmd.Stdout = os.Stdout |
| @@ -276,10 +277,93 @@ | ||
| 276 | 277 | return |
| 277 | 278 | } |
| 278 | 279 | } |
| 279 | 280 | } |
| 280 | 281 | } |
| 282 | + | |
| 283 | +func handleReconnectSignal(ctx context.Context, relayPtr *sessionrelay.Connector, cfg config) { | |
| 284 | + sigCh := make(chan os.Signal, 1) | |
| 285 | + signal.Notify(sigCh, syscall.SIGUSR1) | |
| 286 | + defer signal.Stop(sigCh) | |
| 287 | + | |
| 288 | + for { | |
| 289 | + select { | |
| 290 | + case <-ctx.Done(): | |
| 291 | + return | |
| 292 | + case <-sigCh: | |
| 293 | + } | |
| 294 | + | |
| 295 | + fmt.Fprintf(os.Stderr, "gemini-relay: received SIGUSR1, reconnecting IRC...\n") | |
| 296 | + old := *relayPtr | |
| 297 | + if old != nil { | |
| 298 | + _ = old.Close(context.Background()) | |
| 299 | + } | |
| 300 | + | |
| 301 | + // Retry with backoff. | |
| 302 | + wait := 2 * time.Second | |
| 303 | + for attempt := 0; attempt < 10; attempt++ { | |
| 304 | + if ctx.Err() != nil { | |
| 305 | + return | |
| 306 | + } | |
| 307 | + time.Sleep(wait) | |
| 308 | + | |
| 309 | + conn, err := sessionrelay.New(sessionrelay.Config{ | |
| 310 | + Transport: cfg.Transport, | |
| 311 | + URL: cfg.URL, | |
| 312 | + Token: cfg.Token, | |
| 313 | + Channel: cfg.Channel, | |
| 314 | + Channels: cfg.Channels, | |
| 315 | + Nick: cfg.Nick, | |
| 316 | + IRC: sessionrelay.IRCConfig{ | |
| 317 | + Addr: cfg.IRCAddr, | |
| 318 | + Pass: "", // force re-registration | |
| 319 | + AgentType: cfg.IRCAgentType, | |
| 320 | + DeleteOnClose: cfg.IRCDeleteOnClose, | |
| 321 | + }, | |
| 322 | + }) | |
| 323 | + if err != nil { | |
| 324 | + wait = min(wait*2, 30*time.Second) | |
| 325 | + continue | |
| 326 | + } | |
| 327 | + | |
| 328 | + connectCtx, cancel := context.WithTimeout(ctx, 20*time.Second) | |
| 329 | + if err := conn.Connect(connectCtx); err != nil { | |
| 330 | + _ = conn.Close(context.Background()) | |
| 331 | + cancel() | |
| 332 | + wait = min(wait*2, 30*time.Second) | |
| 333 | + continue | |
| 334 | + } | |
| 335 | + cancel() | |
| 336 | + | |
| 337 | + *relayPtr = conn | |
| 338 | + _ = conn.Post(context.Background(), fmt.Sprintf( | |
| 339 | + "reconnected in %s; mention %s to interrupt", | |
| 340 | + filepath.Base(cfg.TargetCWD), cfg.Nick, | |
| 341 | + )) | |
| 342 | + fmt.Fprintf(os.Stderr, "gemini-relay: reconnected successfully\n") | |
| 343 | + break | |
| 344 | + } | |
| 345 | + } | |
| 346 | +} | |
| 347 | + | |
| 348 | +func presenceLoopPtr(ctx context.Context, relayPtr *sessionrelay.Connector, interval time.Duration) { | |
| 349 | + if interval <= 0 { | |
| 350 | + return | |
| 351 | + } | |
| 352 | + ticker := time.NewTicker(interval) | |
| 353 | + defer ticker.Stop() | |
| 354 | + for { | |
| 355 | + select { | |
| 356 | + case <-ctx.Done(): | |
| 357 | + return | |
| 358 | + case <-ticker.C: | |
| 359 | + if r := *relayPtr; r != nil { | |
| 360 | + _ = r.Touch(ctx) | |
| 361 | + } | |
| 362 | + } | |
| 363 | + } | |
| 364 | +} | |
| 281 | 365 | |
| 282 | 366 | func presenceLoop(ctx context.Context, relay sessionrelay.Connector, interval time.Duration) { |
| 283 | 367 | if interval <= 0 { |
| 284 | 368 | return |
| 285 | 369 | } |
| 286 | 370 |
| --- cmd/gemini-relay/main.go | |
| +++ cmd/gemini-relay/main.go | |
| @@ -164,11 +164,12 @@ | |
| 164 | "SCUTTLEBOT_HOOKS_ENABLED="+boolString(cfg.HooksEnabled), |
| 165 | "SCUTTLEBOT_SESSION_ID="+cfg.SessionID, |
| 166 | "SCUTTLEBOT_NICK="+cfg.Nick, |
| 167 | ) |
| 168 | if relayActive { |
| 169 | go presenceLoop(ctx, relay, cfg.HeartbeatInterval) |
| 170 | } |
| 171 | |
| 172 | if !isInteractiveTTY() { |
| 173 | cmd.Stdin = os.Stdin |
| 174 | cmd.Stdout = os.Stdout |
| @@ -276,10 +277,93 @@ | |
| 276 | return |
| 277 | } |
| 278 | } |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | func presenceLoop(ctx context.Context, relay sessionrelay.Connector, interval time.Duration) { |
| 283 | if interval <= 0 { |
| 284 | return |
| 285 | } |
| 286 |
| --- cmd/gemini-relay/main.go | |
| +++ cmd/gemini-relay/main.go | |
| @@ -164,11 +164,12 @@ | |
| 164 | "SCUTTLEBOT_HOOKS_ENABLED="+boolString(cfg.HooksEnabled), |
| 165 | "SCUTTLEBOT_SESSION_ID="+cfg.SessionID, |
| 166 | "SCUTTLEBOT_NICK="+cfg.Nick, |
| 167 | ) |
| 168 | if relayActive { |
| 169 | go presenceLoopPtr(ctx, &relay, cfg.HeartbeatInterval) |
| 170 | go handleReconnectSignal(ctx, &relay, cfg) |
| 171 | } |
| 172 | |
| 173 | if !isInteractiveTTY() { |
| 174 | cmd.Stdin = os.Stdin |
| 175 | cmd.Stdout = os.Stdout |
| @@ -276,10 +277,93 @@ | |
| 277 | return |
| 278 | } |
| 279 | } |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | func handleReconnectSignal(ctx context.Context, relayPtr *sessionrelay.Connector, cfg config) { |
| 284 | sigCh := make(chan os.Signal, 1) |
| 285 | signal.Notify(sigCh, syscall.SIGUSR1) |
| 286 | defer signal.Stop(sigCh) |
| 287 | |
| 288 | for { |
| 289 | select { |
| 290 | case <-ctx.Done(): |
| 291 | return |
| 292 | case <-sigCh: |
| 293 | } |
| 294 | |
| 295 | fmt.Fprintf(os.Stderr, "gemini-relay: received SIGUSR1, reconnecting IRC...\n") |
| 296 | old := *relayPtr |
| 297 | if old != nil { |
| 298 | _ = old.Close(context.Background()) |
| 299 | } |
| 300 | |
| 301 | // Retry with backoff. |
| 302 | wait := 2 * time.Second |
| 303 | for attempt := 0; attempt < 10; attempt++ { |
| 304 | if ctx.Err() != nil { |
| 305 | return |
| 306 | } |
| 307 | time.Sleep(wait) |
| 308 | |
| 309 | conn, err := sessionrelay.New(sessionrelay.Config{ |
| 310 | Transport: cfg.Transport, |
| 311 | URL: cfg.URL, |
| 312 | Token: cfg.Token, |
| 313 | Channel: cfg.Channel, |
| 314 | Channels: cfg.Channels, |
| 315 | Nick: cfg.Nick, |
| 316 | IRC: sessionrelay.IRCConfig{ |
| 317 | Addr: cfg.IRCAddr, |
| 318 | Pass: "", // force re-registration |
| 319 | AgentType: cfg.IRCAgentType, |
| 320 | DeleteOnClose: cfg.IRCDeleteOnClose, |
| 321 | }, |
| 322 | }) |
| 323 | if err != nil { |
| 324 | wait = min(wait*2, 30*time.Second) |
| 325 | continue |
| 326 | } |
| 327 | |
| 328 | connectCtx, cancel := context.WithTimeout(ctx, 20*time.Second) |
| 329 | if err := conn.Connect(connectCtx); err != nil { |
| 330 | _ = conn.Close(context.Background()) |
| 331 | cancel() |
| 332 | wait = min(wait*2, 30*time.Second) |
| 333 | continue |
| 334 | } |
| 335 | cancel() |
| 336 | |
| 337 | *relayPtr = conn |
| 338 | _ = conn.Post(context.Background(), fmt.Sprintf( |
| 339 | "reconnected in %s; mention %s to interrupt", |
| 340 | filepath.Base(cfg.TargetCWD), cfg.Nick, |
| 341 | )) |
| 342 | fmt.Fprintf(os.Stderr, "gemini-relay: reconnected successfully\n") |
| 343 | break |
| 344 | } |
| 345 | } |
| 346 | } |
| 347 | |
| 348 | func presenceLoopPtr(ctx context.Context, relayPtr *sessionrelay.Connector, interval time.Duration) { |
| 349 | if interval <= 0 { |
| 350 | return |
| 351 | } |
| 352 | ticker := time.NewTicker(interval) |
| 353 | defer ticker.Stop() |
| 354 | for { |
| 355 | select { |
| 356 | case <-ctx.Done(): |
| 357 | return |
| 358 | case <-ticker.C: |
| 359 | if r := *relayPtr; r != nil { |
| 360 | _ = r.Touch(ctx) |
| 361 | } |
| 362 | } |
| 363 | } |
| 364 | } |
| 365 | |
| 366 | func presenceLoop(ctx context.Context, relay sessionrelay.Connector, interval time.Duration) { |
| 367 | if interval <= 0 { |
| 368 | return |
| 369 | } |
| 370 |