Skip to content

Commit 3f02b0f

Browse files
committed
telegram: implement /stop command with context cancellation and task summary
The /stop command previously only returned a placeholder message without actually interrupting anything. This commit makes it functional: - Add per-chat cancel function map (chatCancels) and run info map (chatRunInfos) for coordinating stop commands across goroutines. - Replace context.Background() in handleChatMessage with a cancellable context wired into chatCancels, so /stop can interrupt the agent loop. - Update IterationCallback to always capture the latest IterationInfo (not just on final answer), so /stop can report what was interrupted. - Add /stop handler in OnCommand that cancels the agent context and returns a formatted summary (turns, tokens, tools, latency). - Add formatStopSummary() helper for the stop summary message. - Add 9 tests: 5 unit tests for formatStopSummary and 4 integration tests for the stop handler flow (no task, with task, context cancellation, early cancellation).
1 parent baf1ba4 commit 3f02b0f

2 files changed

Lines changed: 460 additions & 5 deletions

File tree

cmd/odek/telegram.go

Lines changed: 152 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"os"
77
"os/signal"
8+
"path/filepath"
89
"sort"
910
"strconv"
1011
"strings"
@@ -28,6 +29,15 @@ import (
2829
// processed sequentially, preserving session history integrity.
2930
var chatMu sync.Map // map[int64]*sync.Mutex
3031

32+
// chatCancels stores per-chat cancel functions. When /stop is received, the
33+
// cancel function is called to interrupt the running agent loop.
34+
var chatCancels sync.Map // map[int64]context.CancelFunc
35+
36+
// chatRunInfos stores the latest IterationInfo for each chat, updated on
37+
// every agent loop iteration. Used by /stop to report a summary of the
38+
// interrupted task.
39+
var chatRunInfos sync.Map // map[int64]loop.IterationInfo
40+
3141
// getChatMutex returns the per-chat mutex for the given chat ID.
3242
func getChatMutex(chatID int64) *sync.Mutex {
3343
v, _ := chatMu.LoadOrStore(chatID, &sync.Mutex{})
@@ -36,6 +46,13 @@ func getChatMutex(chatID int64) *sync.Mutex {
3646

3747
// telegramCmd is the entry point for "odek telegram".
3848
func telegramCmd(args []string) error {
49+
// 0. Acquire singleton lock — kill any stale previous instance.
50+
lock, err := acquireLock()
51+
if err != nil {
52+
return fmt.Errorf("telegram: %w", err)
53+
}
54+
defer lock.release()
55+
3956
// 1. Load config from all sources (file → env).
4057
resolved := config.LoadConfig(config.CLIFlags{})
4158

@@ -285,6 +302,24 @@ func telegramCmd(args []string) error {
285302
return fmt.Sprintf("📋 *Plan loaded*: `%s`\n\n_Injected into session context. Send a message to continue._", slug), nil
286303
}
287304

305+
// Handle /stop — cancel the running agent task and report a summary.
306+
if cmdName == "stop" {
307+
// Cancel the running agent context, if any.
308+
if cancelVal, ok := chatCancels.LoadAndDelete(chatID); ok {
309+
cancel := cancelVal.(context.CancelFunc)
310+
cancel()
311+
}
312+
// Retrieve the latest run info for a summary of what was interrupted.
313+
var summary string
314+
if infoVal, ok := chatRunInfos.LoadAndDelete(chatID); ok {
315+
info := infoVal.(loop.IterationInfo)
316+
summary = formatStopSummary(info)
317+
} else {
318+
summary = "⏹️ No active task to stop."
319+
}
320+
return summary, nil
321+
}
322+
288323
return cmd.Handler(argsStr)
289324
}
290325

@@ -467,7 +502,11 @@ func handleChatMessage(
467502
for {
468503
select {
469504
case <-ticker.C:
470-
go bot.SendChatAction(chatID, "typing")
505+
go func() {
506+
if err := bot.SendChatAction(chatID, "typing"); err != nil {
507+
fmt.Fprintf(os.Stderr, "odek telegram: sendChatAction failed: %v\n", err)
508+
}
509+
}()
471510
case <-typingDone:
472511
return
473512
}
@@ -585,9 +624,10 @@ func handleChatMessage(
585624
}
586625
allToolsMu.Unlock()
587626

588-
if info.HasFinalAnswer {
589-
runInfo = info
590-
}
627+
// Always capture the latest iteration info — used by /stop
628+
// to report a summary of the interrupted task.
629+
runInfo = info
630+
chatRunInfos.Store(chatID, info)
591631
},
592632
}
593633

@@ -598,8 +638,17 @@ func handleChatMessage(
598638
}
599639
defer agent.Close()
600640

641+
// Create a cancellable context so /stop can interrupt the agent loop.
642+
agentCtx, agentCancel := context.WithCancel(context.Background())
643+
chatCancels.Store(chatID, agentCancel)
644+
defer func() {
645+
agentCancel()
646+
chatCancels.LoadAndDelete(chatID)
647+
chatRunInfos.LoadAndDelete(chatID)
648+
}()
649+
601650
// Run the agent with the full message history (multi-turn).
602-
response, updatedMessages, err := agent.RunWithMessages(context.Background(), cs.Messages)
651+
response, updatedMessages, err := agent.RunWithMessages(agentCtx, cs.Messages)
603652
if err != nil {
604653
reportError(bot, chatID, "Agent error: "+err.Error())
605654
return
@@ -710,10 +759,108 @@ func formatTelegramStats(info loop.IterationInfo, toolList []string) string {
710759
)
711760
}
712761

762+
// formatStopSummary formats a summary of an interrupted task for the /stop
763+
// command response. It includes turns completed, tokens consumed, tools used,
764+
// and total wall-clock time before cancellation.
765+
func formatStopSummary(info loop.IterationInfo) string {
766+
toolStr := "none"
767+
if len(info.ToolNames) > 0 {
768+
// Deduplicate and sort tool names for a clean display.
769+
seen := make(map[string]struct{}, len(info.ToolNames))
770+
unique := make([]string, 0, len(info.ToolNames))
771+
for _, name := range info.ToolNames {
772+
if _, ok := seen[name]; !ok {
773+
seen[name] = struct{}{}
774+
unique = append(unique, name)
775+
}
776+
}
777+
sort.Strings(unique)
778+
toolStr = strings.Join(unique, ", ")
779+
}
780+
781+
latency := info.TotalLatency.Truncate(time.Second)
782+
iters := fmt.Sprintf("%d turn", info.Turn)
783+
if info.Turn != 1 {
784+
iters += "s"
785+
}
786+
787+
return fmt.Sprintf(
788+
"⏹️ *Task Interrupted*\n\n"+
789+
"%s · %d in / %d out · %s — tools: %s",
790+
iters, info.InputTokens, info.OutputTokens, latency.String(), toolStr,
791+
)
792+
}
793+
713794
// reportError sends an error message to the given chat and logs to stderr.
714795
func reportError(bot *telegram.Bot, chatID int64, msg string) {
715796
fmt.Fprintf(os.Stderr, "odek telegram: %s\n", msg)
716797
if _, err := bot.SendMessage(chatID, "❌ "+msg, nil); err != nil {
717798
fmt.Fprintf(os.Stderr, "odek telegram: send error message: %v\n", err)
718799
}
719800
}
801+
802+
// ── Singleton Lock ─────────────────────────────────────────────────────
803+
//
804+
// Prevents two bot instances from polling Telegram simultaneously (which
805+
// causes 409 Conflict errors). Uses a PID file at ~/.odek/telegram.pid.
806+
// On startup, if a stale PID file exists, the old process is killed before
807+
// the new one starts.
808+
809+
type instanceLock struct {
810+
pidFile string
811+
}
812+
813+
// acquireLock reads any existing PID file, kills the old process if still
814+
// alive, then writes the current PID. Returns the lock for deferred release.
815+
func acquireLock() (*instanceLock, error) {
816+
home, err := os.UserHomeDir()
817+
if err != nil {
818+
return nil, fmt.Errorf("home dir: %w", err)
819+
}
820+
pidFile := filepath.Join(home, ".odek", "telegram.pid")
821+
822+
// Ensure parent dir exists.
823+
if err := os.MkdirAll(filepath.Dir(pidFile), 0755); err != nil {
824+
return nil, fmt.Errorf("mkdir pid: %w", err)
825+
}
826+
827+
// Read stale PID and kill it.
828+
if data, err := os.ReadFile(pidFile); err == nil {
829+
oldPID := strings.TrimSpace(string(data))
830+
if oldPID != "" {
831+
// Check if it's an odek telegram process.
832+
procPath := filepath.Join("/proc", oldPID, "cmdline")
833+
if cmdline, err := os.ReadFile(procPath); err == nil {
834+
if strings.Contains(string(cmdline), "odek") &&
835+
strings.Contains(string(cmdline), "telegram") {
836+
pid, _ := strconv.Atoi(oldPID)
837+
if pid > 1 {
838+
fmt.Fprintf(os.Stderr, "odek telegram: killing stale instance (PID %d)\n", pid)
839+
syscall.Kill(pid, syscall.SIGTERM)
840+
// Wait up to 5s for graceful shutdown.
841+
for i := 0; i < 50; i++ {
842+
time.Sleep(100 * time.Millisecond)
843+
if err := syscall.Kill(pid, 0); err != nil {
844+
break // process gone
845+
}
846+
}
847+
// Force kill if still alive.
848+
syscall.Kill(pid, syscall.SIGKILL)
849+
}
850+
}
851+
}
852+
}
853+
}
854+
855+
// Write our PID.
856+
if err := os.WriteFile(pidFile, []byte(strconv.Itoa(os.Getpid())+"\n"), 0644); err != nil {
857+
return nil, fmt.Errorf("write pid: %w", err)
858+
}
859+
860+
return &instanceLock{pidFile: pidFile}, nil
861+
}
862+
863+
// release removes the PID file on clean shutdown.
864+
func (l *instanceLock) release() {
865+
os.Remove(l.pidFile)
866+
}

0 commit comments

Comments
 (0)