diff --git a/README.md b/README.md index 5a93d72a..83079887 100644 --- a/README.md +++ b/README.md @@ -451,12 +451,13 @@ chmod +x ~/.config/task/hooks/task.completed | Event | When Emitted | |-------|--------------| | `task.created` | New task created | -| `task.updated` | Task fields changed | +| `task.updated` | Task fields changed (including status transitions) | | `task.deleted` | Task removed | | `task.started` | Execution begins | -| `task.blocked` | Task needs user input | -| `task.completed` | Task finished | -| `task.failed` | Execution failed | +| `task.blocked` | Task needs user input (or agent failed) | +| `task.completed` | Agent finished successfully (task moves to backlog for human review) | +| `task.failed` | Agent execution failed | +| `task.worktree_ready` | Worktree set up and ready for agent | ### Environment Variables diff --git a/cmd/task/main.go b/cmd/task/main.go index f90659a3..8180bba1 100644 --- a/cmd/task/main.go +++ b/cmd/task/main.go @@ -25,8 +25,10 @@ import ( "github.com/bborn/workflow/internal/autocomplete" "github.com/bborn/workflow/internal/config" "github.com/bborn/workflow/internal/db" + "github.com/bborn/workflow/internal/events" "github.com/bborn/workflow/internal/executor" "github.com/bborn/workflow/internal/github" + "github.com/bborn/workflow/internal/hooks" "github.com/bborn/workflow/internal/mcp" "github.com/bborn/workflow/internal/ui" "github.com/bborn/workflow/internal/web" @@ -64,6 +66,35 @@ func getDaemonSessionName() string { return fmt.Sprintf("task-daemon-%s", getSessionID()) } +// taskEmitter holds the process-wide events emitter so short-lived CLI +// commands can flush pending hooks via waitForEventHooks before exit. +var taskEmitter *events.Emitter + +// openTaskDB opens the task database and registers the events emitter so any +// caller of UpdateTaskStatus (CLI commands, TUI, Claude hooks, MCP) fires +// task.blocked/task.completed lifecycle hooks. Otherwise only the daemon +// process emits these events and external watchers miss most transitions. +func openTaskDB(path string) (*db.DB, error) { + database, err := db.Open(path) + if err != nil { + return nil, err + } + if taskEmitter == nil { + taskEmitter = events.New(hooks.DefaultHooksDir()) + } + database.SetEventEmitter(taskEmitter) + return database, nil +} + +// waitForEventHooks blocks until any in-flight hook scripts have completed. +// CLI commands that mutate task state must defer this before exit, otherwise +// the Go process terminates before the hook goroutine runs its subprocess. +func waitForEventHooks() { + if taskEmitter != nil { + taskEmitter.Wait() + } +} + func main() { var dangerous bool @@ -108,6 +139,10 @@ func main() { "claude-hook": true, } rootCmd.PersistentPostRun = func(cmd *cobra.Command, args []string) { + // Flush any pending event hook goroutines kicked off by the command. + // Without this, short-lived CLI commands exit before `task.completed` + // and similar hook scripts can append to notifications.jsonl. + waitForEventHooks() if skipVersionCheck[cmd.Name()] { return } @@ -142,7 +177,7 @@ Examples: keys, _ := cmd.Flags().GetString("keys") dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -468,7 +503,7 @@ Examples: // Get task info for confirmation dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -550,7 +585,7 @@ Examples: // Open database dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -722,7 +757,7 @@ Examples: // Open database dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -894,7 +929,7 @@ that the TUI shows, either as formatted text or JSON for automation.`, } dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -963,7 +998,7 @@ Press Ctrl+C to stop.`, showDone, _ := cmd.Flags().GetBool("done") dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -1012,7 +1047,7 @@ Examples: // Open database dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -1250,7 +1285,7 @@ Examples: // Open database dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -1380,7 +1415,7 @@ Examples: // Open database dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -1491,7 +1526,7 @@ Examples: // Open database dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -1564,7 +1599,7 @@ Valid statuses: backlog, queued, processing, blocked, done, archived.`, } dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -1607,7 +1642,7 @@ Valid statuses: backlog, queued, processing, blocked, done, archived.`, toggle, _ := cmd.Flags().GetBool("toggle") dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -1671,7 +1706,7 @@ Examples: // Open database dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -1730,7 +1765,7 @@ Examples: // Open database dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -1811,7 +1846,7 @@ Examples: // Open database dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -1897,7 +1932,7 @@ Examples: // Open database dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -2031,7 +2066,7 @@ Examples: } dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -2104,7 +2139,7 @@ Examples: Short: "View and manage app settings", Run: func(cmd *cobra.Command, args []string) { dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -2186,7 +2221,7 @@ Available settings: } dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -2228,7 +2263,7 @@ Examples: outputJSON, _ := cmd.Flags().GetBool("json") dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -2504,7 +2539,7 @@ Use --auto-queue to automatically move the blocked task to 'queued' when unblock autoQueue, _ := cmd.Flags().GetBool("auto-queue") dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -2565,7 +2600,7 @@ This removes the dependency where task #5 was blocked by task #3.`, } dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -2601,7 +2636,7 @@ This removes the dependency where task #5 was blocked by task #3.`, } dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -2670,7 +2705,7 @@ Examples: outputJSON, _ := cmd.Flags().GetBool("json") dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -2758,7 +2793,7 @@ Examples: outputJSON, _ := cmd.Flags().GetBool("json") dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -2876,7 +2911,7 @@ Examples: } dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -2937,7 +2972,7 @@ Examples: sortOrderSet := cmd.Flags().Changed("sort-order") dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -3031,7 +3066,7 @@ Examples: force, _ := cmd.Flags().GetBool("force") dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -3090,7 +3125,7 @@ The server shares the same SQLite database the daemon writes to (WAL mode).`, addr := fmt.Sprintf(":%d", port) dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error opening database: "+err.Error())) os.Exit(1) @@ -3421,7 +3456,7 @@ func runLocal(dangerousMode bool, debugStatePath string) error { // Open database dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { return fmt.Errorf("open database: %w", err) } @@ -3723,7 +3758,7 @@ func runDaemon() error { // Open database dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { return fmt.Errorf("open database: %w", err) } @@ -3795,7 +3830,7 @@ func handleClaudeHook(hookEvent string) error { // Open database dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { return fmt.Errorf("open database: %w", err) } @@ -3887,7 +3922,7 @@ func handleNotificationHook(database *db.DB, taskID int64, input *ClaudeHookInpu func runMCPServer(taskID int64) error { // Open database dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { return fmt.Errorf("open database: %w", err) } @@ -4653,7 +4688,7 @@ func suspendSessions(taskIDs []int, all bool) { // Open database for status checks and tmux ID cleanup dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error opening database: "+err.Error())) os.Exit(1) @@ -4755,7 +4790,7 @@ func suspendSessions(taskIDs []int, all bool) { // reconnect to their agent sessions when viewed. func recoverStaleTmuxRefs(dryRun bool) { dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error opening database: "+err.Error())) os.Exit(1) @@ -4940,7 +4975,7 @@ func cleanupOrphanedSessions(force bool) { // Step 1b: Get task IDs of done tasks older than 2 hours - these should be killed doneOldTaskIDs := make(map[int]bool) dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err == nil { defer database.Close() twoHoursAgo := time.Now().Add(-2 * time.Hour) @@ -5146,7 +5181,7 @@ func moveTask(database *db.DB, oldTask *db.Task, targetProject string) (int64, e func deleteTask(taskID int64) error { // Open database dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { return fmt.Errorf("open database: %w", err) } @@ -5245,7 +5280,7 @@ func listProjectsCLI(cmd *cobra.Command) { outputJSON, _ := cmd.Flags().GetBool("json") dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -5320,7 +5355,7 @@ func listProjectsCLI(cmd *cobra.Command) { // showProjectCLI shows detailed information about a single project. func showProjectCLI(name string, outputJSON bool) { dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -5451,7 +5486,7 @@ func createProjectCLI(name, path, instructions, color, aliases, claudeConfigDir } dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -5508,7 +5543,7 @@ func createProjectCLI(name, path, instructions, color, aliases, claudeConfigDir // updateProjectCLI updates an existing project. func updateProjectCLI(currentName, newName, path, instructions, color, aliases, claudeConfigDir, projectContext string, useWorktrees *bool, outputJSON bool) { dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) @@ -5624,7 +5659,7 @@ func updateProjectCLI(currentName, newName, path, instructions, color, aliases, // deleteProjectCLI deletes a project. func deleteProjectCLI(name string, force bool) { dbPath := db.DefaultPath() - database, err := db.Open(dbPath) + database, err := openTaskDB(dbPath) if err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1) diff --git a/examples/hooks/README.md b/examples/hooks/README.md index b16f169a..86fe2760 100644 --- a/examples/hooks/README.md +++ b/examples/hooks/README.md @@ -12,12 +12,25 @@ chmod +x ~/.config/task/hooks/task.completed ## Available Events - `task.created` - New task created -- `task.updated` - Task fields changed +- `task.updated` - Task fields changed (including every status transition) - `task.deleted` - Task removed - `task.started` - Execution begins -- `task.blocked` - Task needs user input -- `task.completed` - Task finished -- `task.failed` - Execution failed +- `task.blocked` - Task needs user input (also fires on failure via the status change) +- `task.completed` - Agent finished successfully (task moves to backlog for human review) +- `task.failed` - Agent execution failed (distinct signal from the `task.blocked` fired by the status change) +- `task.worktree_ready` - Worktree ready for the agent + +## Health Check + +See `notifications-health-check.sh` for a cron-friendly script that alerts when +the notifications file goes silent longer than expected (default: 24 hours). +Install as: + +```bash +cp examples/hooks/notifications-health-check.sh ~/bin/ +# Cron entry (runs hourly): +(crontab -l; echo "0 * * * * $HOME/bin/notifications-health-check.sh") | crontab - +``` ## Environment Variables diff --git a/examples/hooks/notifications-health-check.sh b/examples/hooks/notifications-health-check.sh new file mode 100755 index 00000000..1adce9fd --- /dev/null +++ b/examples/hooks/notifications-health-check.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Health check: alert if notifications.jsonl goes silent longer than expected. +# +# Run from cron (e.g. every hour): +# 0 * * * * /home/exedev/bin/notifications-health-check.sh +# +# Customize NOTIFICATIONS_FILE, MAX_AGE_HOURS, and ALERT_CMD for your setup. + +set -euo pipefail + +NOTIFICATIONS_FILE="${NOTIFICATIONS_FILE:-$HOME/notifications.jsonl}" +MAX_AGE_HOURS="${MAX_AGE_HOURS:-24}" +# ALERT_CMD receives the alert message on stdin. Examples: +# ALERT_CMD="mail -s 'TaskYou notifications stale' you@example.com" +# ALERT_CMD="curl -X POST -H 'Content-Type: application/json' --data-binary @- https://hooks.slack.com/..." +ALERT_CMD="${ALERT_CMD:-cat}" +STATE_FILE="${STATE_FILE:-/tmp/notifications-health-check.state}" + +if [[ ! -f "$NOTIFICATIONS_FILE" ]]; then + echo "notifications.jsonl missing at $NOTIFICATIONS_FILE — TaskYou hooks never ran" | eval "$ALERT_CMD" + exit 1 +fi + +mtime=$(stat -c %Y "$NOTIFICATIONS_FILE" 2>/dev/null || stat -f %m "$NOTIFICATIONS_FILE") +now=$(date +%s) +age_hours=$(( (now - mtime) / 3600 )) + +if (( age_hours < MAX_AGE_HOURS )); then + # Healthy — clear any prior alert state so we re-alert if it breaks again. + rm -f "$STATE_FILE" + exit 0 +fi + +# Don't re-alert on every run for the same outage. +last_alert_mtime=$(cat "$STATE_FILE" 2>/dev/null || echo 0) +if [[ "$last_alert_mtime" == "$mtime" ]]; then + exit 0 +fi + +cat </dev/null || date -r "$mtime") + +Check: + - Is \`ty daemon\` running? pgrep -a "ty daemon" + - Are hook scripts in ~/.config/task/hooks/ executable? + - Is the daemon running a version that emits task.completed? (requires TaskYou >= 0.2.37) +EOF + +echo "$mtime" > "$STATE_FILE" diff --git a/internal/db/events.go b/internal/db/events.go index 7ecced75..aa9395ab 100644 --- a/internal/db/events.go +++ b/internal/db/events.go @@ -8,6 +8,8 @@ type EventEmitter interface { EmitTaskDeleted(taskID int64, title string) EmitTaskPinned(task *Task) EmitTaskUnpinned(task *Task) + EmitTaskBlocked(task *Task, reason string) + EmitTaskCompleted(task *Task) } // SetEventEmitter sets the event emitter for this database. @@ -50,3 +52,17 @@ func (db *DB) emitTaskUnpinned(task *Task) { db.eventEmitter.EmitTaskUnpinned(task) } } + +// emitTaskBlocked emits a task blocked event if an emitter is configured. +func (db *DB) emitTaskBlocked(task *Task, reason string) { + if db.eventEmitter != nil { + db.eventEmitter.EmitTaskBlocked(task, reason) + } +} + +// emitTaskCompleted emits a task completed event if an emitter is configured. +func (db *DB) emitTaskCompleted(task *Task) { + if db.eventEmitter != nil { + db.eventEmitter.EmitTaskCompleted(task) + } +} diff --git a/internal/db/task_events_test.go b/internal/db/task_events_test.go index abcd66d4..f6499367 100644 --- a/internal/db/task_events_test.go +++ b/internal/db/task_events_test.go @@ -31,12 +31,14 @@ func setupTestDB(t *testing.T) *DB { // MockEventEmitter implements EventEmitter for testing. type MockEventEmitter struct { - CreatedTasks []*Task - UpdatedTasks []*Task - DeletedTasks []int64 - PinnedTasks []*Task - UnpinnedTasks []*Task - Changes []map[string]interface{} + CreatedTasks []*Task + UpdatedTasks []*Task + DeletedTasks []int64 + PinnedTasks []*Task + UnpinnedTasks []*Task + BlockedTasks []*Task + CompletedTasks []*Task + Changes []map[string]interface{} } func (m *MockEventEmitter) EmitTaskCreated(task *Task) { @@ -60,6 +62,14 @@ func (m *MockEventEmitter) EmitTaskUnpinned(task *Task) { m.UnpinnedTasks = append(m.UnpinnedTasks, task) } +func (m *MockEventEmitter) EmitTaskBlocked(task *Task, _ string) { + m.BlockedTasks = append(m.BlockedTasks, task) +} + +func (m *MockEventEmitter) EmitTaskCompleted(task *Task) { + m.CompletedTasks = append(m.CompletedTasks, task) +} + func TestUpdateTaskStatusEmitsEvents(t *testing.T) { database := setupTestDB(t) defer database.Close() @@ -448,3 +458,49 @@ func TestUpdateTaskEmitsEventWithChanges(t *testing.T) { } } } + +func TestUpdateTaskStatusEmitsLifecycleEvents(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + mockEmitter := &MockEventEmitter{} + database.SetEventEmitter(mockEmitter) + + task := &Task{ + Title: "Lifecycle Task", + Status: StatusProcessing, + Project: "personal", + } + if err := database.CreateTask(task); err != nil { + t.Fatalf("create: %v", err) + } + + // Processing → Blocked should emit task.blocked (plus task.updated) + if err := database.UpdateTaskStatus(task.ID, StatusBlocked); err != nil { + t.Fatalf("update blocked: %v", err) + } + if got := len(mockEmitter.BlockedTasks); got != 1 { + t.Errorf("BlockedTasks after transition to blocked: got %d, want 1", got) + } + if got := len(mockEmitter.CompletedTasks); got != 0 { + t.Errorf("CompletedTasks after blocked transition: got %d, want 0", got) + } + + // Blocked → Done should emit task.completed + if err := database.UpdateTaskStatus(task.ID, StatusDone); err != nil { + t.Fatalf("update done: %v", err) + } + if got := len(mockEmitter.CompletedTasks); got != 1 { + t.Errorf("CompletedTasks after transition to done: got %d, want 1", got) + } + + // Same-status update should not re-fire. + mockEmitter.CompletedTasks = nil + mockEmitter.BlockedTasks = nil + if err := database.UpdateTaskStatus(task.ID, StatusDone); err != nil { + t.Fatalf("no-op update: %v", err) + } + if got := len(mockEmitter.CompletedTasks); got != 0 { + t.Errorf("CompletedTasks after no-op: got %d, want 0", got) + } +} diff --git a/internal/db/tasks.go b/internal/db/tasks.go index 881a2c99..eaeaeba2 100644 --- a/internal/db/tasks.go +++ b/internal/db/tasks.go @@ -453,6 +453,16 @@ func (db *DB) UpdateTaskStatus(id int64, status string) error { }, } db.emitTaskUpdated(updatedTask, changes) + // Also emit lifecycle events so external watchers can react + // to blocked/completed transitions without parsing update metadata. + // These fire for every caller of UpdateTaskStatus — Claude hooks, + // MCP, CLI, TUI, and the executor — as long as an emitter is registered. + switch status { + case StatusBlocked: + db.emitTaskBlocked(updatedTask, "status change") + case StatusDone: + db.emitTaskCompleted(updatedTask) + } } } diff --git a/internal/events/events.go b/internal/events/events.go index fe8e71d9..bf2194bd 100644 --- a/internal/events/events.go +++ b/internal/events/events.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "sync" "time" "github.com/bborn/workflow/internal/db" @@ -38,6 +39,7 @@ type Event struct { // Emitter handles event emission via hooks. type Emitter struct { hooksDir string + wg sync.WaitGroup } // New creates a new event emitter. @@ -46,6 +48,8 @@ func New(hooksDir string) *Emitter { } // Emit triggers a hook script if it exists for the event type. +// Hooks run in a background goroutine — short-lived CLI commands should +// call Wait before exiting so the hook actually runs. func (e *Emitter) Emit(event Event) { if e.hooksDir == "" { return @@ -53,7 +57,18 @@ func (e *Emitter) Emit(event Event) { if event.Timestamp.IsZero() { event.Timestamp = time.Now() } - go e.runHook(event) + e.wg.Add(1) + go func() { + defer e.wg.Done() + e.runHook(event) + }() +} + +// Wait blocks until all in-flight hooks have completed. +// CLI commands that exit after triggering a state change must call this, +// otherwise the process terminates before the hook goroutine runs. +func (e *Emitter) Wait() { + e.wg.Wait() } // runHook executes the hook script for an event. diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 2e01495f..e39d592e 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -1150,19 +1150,24 @@ func (e *Executor) executeTask(ctx context.Context, task *db.Task) { // Kill executor process to free memory when task is interrupted taskExecutor.Kill(task.ID) } else if currentStatus == db.StatusBlocked { - // Hooks already marked as blocked - respect that + // Hooks already marked as blocked - respect that. + // The task.blocked hook already fired via db.UpdateTaskStatus in the Claude hook subprocess. e.logLine(task.ID, "system", "Task waiting for input") e.hooks.OnStatusChange(task, db.StatusBlocked, "Task waiting for input") } else if currentStatus == db.StatusDone { - // If somehow already marked as done (e.g. by human), respect that + // If somehow already marked as done (e.g. by human), respect that. + // task.completed already fired via db.UpdateTaskStatus when the status was set. e.logLine(task.ID, "system", "Task completed") e.hooks.OnStatusChange(task, db.StatusDone, "Task completed") } else if result.Success { - // Agent finished successfully - move to backlog for human review - // Only humans should mark tasks as done + // Agent finished successfully - move to backlog for human review. + // Only humans should mark tasks as done, but agent-success is the + // completion signal external watchers care about. StatusBacklog + // doesn't fire task.completed via the db emitter, so fire it here. e.updateStatus(task.ID, db.StatusBacklog) e.logLine(task.ID, "system", "Agent finished - awaiting human review to close") e.hooks.OnStatusChange(task, db.StatusBacklog, "Agent finished - awaiting human review to close") + e.events.EmitTaskCompleted(task) } else if result.NeedsInput { e.updateStatus(task.ID, db.StatusBlocked) // Log the question with special type so UI can display it @@ -1174,6 +1179,9 @@ func (e *Executor) executeTask(ctx context.Context, task *db.Task) { msg := fmt.Sprintf("Task failed: %s", result.Message) e.logLine(task.ID, "error", msg) e.hooks.OnStatusChange(task, db.StatusBlocked, msg) + // task.blocked already fired via updateStatus → db. Fire task.failed too + // so watchers can distinguish "needs input" from "agent died". + e.events.EmitTaskFailed(task, result.Message) } e.logger.Info("Task finished", "id", task.ID, "success", result.Success)