Skip to content

Commit e007210

Browse files
authored
Merge pull request #529 from bborn/task/2162-add-ty-sessions-suspend-command-to-kill
Add 'ty sessions suspend' command to kill blocked task processes
2 parents f4c1605 + 10c5deb commit e007210

File tree

1 file changed

+173
-0
lines changed

1 file changed

+173
-0
lines changed

cmd/task/main.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,37 @@ Tasks will automatically reconnect to their agent sessions when viewed.`,
409409
sessionsCleanupCmd.Flags().BoolP("force", "f", false, "Use SIGKILL instead of SIGTERM to force kill processes")
410410
sessionsCmd.AddCommand(sessionsCleanupCmd)
411411

412+
sessionsSuspendCmd := &cobra.Command{
413+
Use: "suspend [task-id...]",
414+
Short: "Kill agent processes for blocked tasks while preserving resume capability",
415+
Long: `Suspend blocked task sessions by killing their agent processes and tmux windows.
416+
The session ID is preserved in the database so tasks can be resumed later via 'ty retry'.
417+
418+
By default, suspends all blocked tasks that have running sessions.
419+
Optionally specify one or more task IDs to suspend specific tasks.
420+
421+
Examples:
422+
ty sessions suspend # Suspend all blocked tasks with running sessions
423+
ty sessions suspend 42 43 # Suspend specific tasks
424+
ty sessions suspend --all # Suspend all tasks with running sessions (not just blocked)`,
425+
Run: func(cmd *cobra.Command, args []string) {
426+
all, _ := cmd.Flags().GetBool("all")
427+
var taskIDs []int
428+
for _, arg := range args {
429+
var id int
430+
if _, err := fmt.Sscanf(arg, "%d", &id); err == nil {
431+
taskIDs = append(taskIDs, id)
432+
} else {
433+
fmt.Fprintln(os.Stderr, errorStyle.Render("Invalid task ID: "+arg))
434+
os.Exit(1)
435+
}
436+
}
437+
suspendSessions(taskIDs, all)
438+
},
439+
}
440+
sessionsSuspendCmd.Flags().Bool("all", false, "Suspend all tasks with running sessions, not just blocked ones")
441+
sessionsCmd.AddCommand(sessionsSuspendCmd)
442+
412443
rootCmd.AddCommand(sessionsCmd)
413444

414445
// Alias: claudes -> sessions (for backwards compatibility)
@@ -4574,6 +4605,148 @@ func killSession(taskID int) error {
45744605
return nil
45754606
}
45764607

4608+
// killSessionAcrossDaemons kills a task's tmux window across all task-daemon-* sessions.
4609+
// Returns true if a window was found and killed.
4610+
func killSessionAcrossDaemons(taskID int) bool {
4611+
sessionsOut, err := osexec.Command("tmux", "list-sessions", "-F", "#{session_name}").Output()
4612+
if err != nil {
4613+
return false
4614+
}
4615+
4616+
windowName := fmt.Sprintf("task-%d", taskID)
4617+
killed := false
4618+
4619+
for _, session := range strings.Split(strings.TrimSpace(string(sessionsOut)), "\n") {
4620+
if !strings.HasPrefix(session, "task-daemon-") {
4621+
continue
4622+
}
4623+
windowTarget := fmt.Sprintf("%s:%s", session, windowName)
4624+
if err := osexec.Command("tmux", "list-panes", "-t", windowTarget).Run(); err != nil {
4625+
continue // Window doesn't exist in this session
4626+
}
4627+
if err := osexec.Command("tmux", "kill-window", "-t", windowTarget).Run(); err == nil {
4628+
killed = true
4629+
}
4630+
}
4631+
return killed
4632+
}
4633+
4634+
// suspendSessions kills agent processes for tasks while preserving their session IDs
4635+
// so they can be resumed later. If taskIDs is empty, suspends all blocked tasks with
4636+
// running sessions. If all is true, suspends all tasks (not just blocked).
4637+
func suspendSessions(taskIDs []int, all bool) {
4638+
// Get running sessions
4639+
sessions := getSessions()
4640+
if len(sessions) == 0 {
4641+
fmt.Println(dimStyle.Render("No agent sessions running"))
4642+
return
4643+
}
4644+
4645+
// Build a set of running task IDs for quick lookup
4646+
runningSessions := make(map[int]agentSession)
4647+
for _, s := range sessions {
4648+
runningSessions[s.taskID] = s
4649+
}
4650+
4651+
// Open database for status checks and tmux ID cleanup
4652+
dbPath := db.DefaultPath()
4653+
database, err := db.Open(dbPath)
4654+
if err != nil {
4655+
fmt.Fprintln(os.Stderr, errorStyle.Render("Error opening database: "+err.Error()))
4656+
os.Exit(1)
4657+
}
4658+
defer database.Close()
4659+
4660+
// Determine which tasks to suspend
4661+
var toSuspend []agentSession
4662+
if len(taskIDs) > 0 {
4663+
// Suspend specific tasks
4664+
for _, id := range taskIDs {
4665+
s, running := runningSessions[id]
4666+
if !running {
4667+
fmt.Fprintf(os.Stderr, "%s\n", dimStyle.Render(fmt.Sprintf("task-%d: no running session, skipping", id)))
4668+
continue
4669+
}
4670+
toSuspend = append(toSuspend, s)
4671+
}
4672+
} else {
4673+
// Suspend based on status
4674+
for _, s := range sessions {
4675+
if all {
4676+
toSuspend = append(toSuspend, s)
4677+
continue
4678+
}
4679+
// Default: only blocked tasks
4680+
task, err := database.GetTask(int64(s.taskID))
4681+
if err != nil || task == nil {
4682+
continue
4683+
}
4684+
if task.Status == db.StatusBlocked {
4685+
toSuspend = append(toSuspend, s)
4686+
}
4687+
}
4688+
}
4689+
4690+
if len(toSuspend) == 0 {
4691+
if len(taskIDs) > 0 {
4692+
fmt.Println(dimStyle.Render("No matching running sessions found"))
4693+
} else if all {
4694+
fmt.Println(dimStyle.Render("No running sessions to suspend"))
4695+
} else {
4696+
fmt.Println(dimStyle.Render("No blocked tasks with running sessions to suspend"))
4697+
}
4698+
return
4699+
}
4700+
4701+
// Suspend each task
4702+
fmt.Printf("%s\n\n", boldStyle.Render(fmt.Sprintf("Suspending %d session(s):", len(toSuspend))))
4703+
4704+
totalFreedMB := 0
4705+
suspended := 0
4706+
for _, s := range toSuspend {
4707+
// Kill the tmux window (kills the agent process)
4708+
killed := killSessionAcrossDaemons(s.taskID)
4709+
4710+
// Clear tmux references in DB (window/pane IDs are now stale)
4711+
// but preserve claude_session_id for resume capability
4712+
database.ClearTaskTmuxIDs(int64(s.taskID))
4713+
4714+
// Also clear daemon_session since the window is gone
4715+
database.Exec(`UPDATE tasks SET daemon_session = '' WHERE id = ?`, int64(s.taskID))
4716+
4717+
title := s.taskTitle
4718+
if len(title) > 40 {
4719+
title = title[:37] + "..."
4720+
}
4721+
4722+
memStr := ""
4723+
if s.memoryMB > 0 {
4724+
memStr = fmt.Sprintf(" (%dMB freed)", s.memoryMB)
4725+
totalFreedMB += s.memoryMB
4726+
}
4727+
4728+
statusIcon := "✓"
4729+
if !killed {
4730+
statusIcon = "~"
4731+
}
4732+
4733+
fmt.Printf(" %s %s %s%s\n",
4734+
successStyle.Render(statusIcon),
4735+
successStyle.Render(fmt.Sprintf("task-%d", s.taskID)),
4736+
dimStyle.Render(title),
4737+
dimStyle.Render(memStr))
4738+
suspended++
4739+
}
4740+
4741+
fmt.Println()
4742+
if totalFreedMB > 0 {
4743+
fmt.Println(successStyle.Render(fmt.Sprintf("Suspended %d session(s), ~%dMB freed", suspended, totalFreedMB)))
4744+
} else {
4745+
fmt.Println(successStyle.Render(fmt.Sprintf("Suspended %d session(s)", suspended)))
4746+
}
4747+
fmt.Println(dimStyle.Render("Session IDs preserved — use 'ty retry <task-id>' to resume"))
4748+
}
4749+
45774750
// recoverStaleTmuxRefs clears stale daemon_session and tmux_window_id references
45784751
// from tasks after a crash or daemon restart. This allows tasks to automatically
45794752
// reconnect to their agent sessions when viewed.

0 commit comments

Comments
 (0)