diff --git a/diff_dev_jules.txt b/diff_dev_jules.txt new file mode 100644 index 00000000..e69de29b diff --git a/diff_jules_dev.txt b/diff_jules_dev.txt new file mode 100644 index 00000000..e69de29b diff --git a/docs/pkg/PACKAGE_STANDARDS.md b/docs/pkg/PACKAGE_STANDARDS.md index c9462fe6..ddafd2cb 100644 --- a/docs/pkg/PACKAGE_STANDARDS.md +++ b/docs/pkg/PACKAGE_STANDARDS.md @@ -564,3 +564,53 @@ When creating a new package, ensure: - **`pkg/i18n`** - Full reference with handlers, modes, hooks, grammar - **`pkg/process`** - Simpler example with ACTION events and runner orchestration - **`pkg/cli`** - Service integration with runtime lifecycle + +--- + +## Background Operations + +For long-running operations that could block the UI, use the framework's background task mechanism. + +### Principles + +1. **Non-blocking**: Long-running operations must not block the main IPC thread. +2. **Lifecycle Events**: Use `PerformAsync` to automatically broadcast start and completion events. +3. **Progress Reporting**: Services should broadcast `ActionTaskProgress` for granular updates. + +### Using PerformAsync + +The `Core.PerformAsync(task)` method runs any registered task in a background goroutine and returns a unique `TaskID` immediately. + +```go +// From the frontend or another service +taskID := core.PerformAsync(git.TaskPush{Path: "/repo"}) +// taskID is returned immediately, e.g., "task-123" +``` + +The framework automatically broadcasts lifecycle actions: +- `ActionTaskStarted`: When the background goroutine begins. +- `ActionTaskCompleted`: When the task finishes (contains Result and Error). + +### Reporting Progress + +For very long operations, the service handler should broadcast progress: + +```go +func (s *Service) handleTask(c *framework.Core, t framework.Task) (any, bool, error) { + switch m := t.(type) { + case MyLongTask: + // Optional: If you need to report progress, you might need to pass + // a TaskID or use a specific progress channel. + // For now, simple tasks just use ActionTaskCompleted. + return s.doLongWork(m), true, nil + } + return nil, false, nil +} +``` + +### Implementing Background-Safe Handlers + +Ensure that handlers for long-running tasks: +1. Use `context.Background()` or a long-lived context, as the request context might expire. +2. Are thread-safe and don't hold global locks for the duration of the work. +3. Do not use interactive CLI functions like `cli.Scanln` if they are intended for GUI use. diff --git a/internal/cmd/dev/service.go b/internal/cmd/dev/service.go index b086f9aa..8c035698 100644 --- a/internal/cmd/dev/service.go +++ b/internal/cmd/dev/service.go @@ -18,6 +18,7 @@ type TaskWork struct { RegistryPath string StatusOnly bool AutoCommit bool + AutoPush bool } // TaskStatus displays git status for all repos. @@ -173,13 +174,15 @@ func (s *Service) runWork(task TaskWork) error { cli.Print(" %s: %d commits\n", st.Name, st.Ahead) } - cli.Blank() - cli.Print("Push all? [y/N] ") - var answer string - _, _ = cli.Scanln(&answer) - if strings.ToLower(answer) != "y" { - cli.Println("Aborted") - return nil + if !task.AutoPush { + cli.Blank() + cli.Print("Push all? [y/N] ") + var answer string + _, _ = cli.Scanln(&answer) + if strings.ToLower(answer) != "y" { + cli.Println("Aborted") + return nil + } } cli.Blank() diff --git a/pkg/agentic/service.go b/pkg/agentic/service.go index 810abbcb..1670aa23 100644 --- a/pkg/agentic/service.go +++ b/pkg/agentic/service.go @@ -24,8 +24,13 @@ type TaskPrompt struct { Prompt string WorkDir string AllowedTools []string + + taskID string } +func (t *TaskPrompt) SetTaskID(id string) { t.taskID = id } +func (t *TaskPrompt) GetTaskID() string { return t.taskID } + // ServiceOptions for configuring the AI service. type ServiceOptions struct { DefaultTools []string @@ -97,6 +102,10 @@ func (s *Service) doCommit(task TaskCommit) error { } func (s *Service) doPrompt(task TaskPrompt) error { + if task.taskID != "" { + s.Core().Progress(task.taskID, 0.1, "Starting Claude...", &task) + } + opts := s.Opts() tools := opts.DefaultTools if len(tools) == 0 { @@ -115,5 +124,19 @@ func (s *Service) doPrompt(task TaskPrompt) error { cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin - return cmd.Run() + if task.taskID != "" { + s.Core().Progress(task.taskID, 0.5, "Running Claude prompt...", &task) + } + + err := cmd.Run() + + if task.taskID != "" { + if err != nil { + s.Core().Progress(task.taskID, 1.0, "Failed: "+err.Error(), &task) + } else { + s.Core().Progress(task.taskID, 1.0, "Completed", &task) + } + } + + return err } diff --git a/pkg/framework/core/core.go b/pkg/framework/core/core.go index b627473d..a6322aac 100644 --- a/pkg/framework/core/core.go +++ b/pkg/framework/core/core.go @@ -205,6 +205,51 @@ func (c *Core) PERFORM(t Task) (any, bool, error) { return c.bus.perform(t) } +// PerformAsync dispatches a task to be executed in a background goroutine. +// It returns a unique task ID that can be used to track the task's progress. +// The result of the task will be broadcasted via an ActionTaskCompleted message. +func (c *Core) PerformAsync(t Task) string { + taskID := fmt.Sprintf("task-%d", c.taskIDCounter.Add(1)) + + // If the task supports it, inject the ID + if tid, ok := t.(TaskWithID); ok { + tid.SetTaskID(taskID) + } + + // Broadcast task started + _ = c.ACTION(ActionTaskStarted{ + TaskID: taskID, + Task: t, + }) + + go func() { + result, handled, err := c.PERFORM(t) + if !handled && err == nil { + err = fmt.Errorf("no handler found for task type %T", t) + } + + // Broadcast task completed + _ = c.ACTION(ActionTaskCompleted{ + TaskID: taskID, + Task: t, + Result: result, + Error: err, + }) + }() + + return taskID +} + +// Progress broadcasts a progress update for a background task. +func (c *Core) Progress(taskID string, progress float64, message string, t Task) { + _ = c.ACTION(ActionTaskProgress{ + TaskID: taskID, + Task: t, + Progress: progress, + Message: message, + }) +} + // RegisterQuery adds a query handler to the Core. func (c *Core) RegisterQuery(handler QueryHandler) { c.bus.registerQuery(handler) diff --git a/pkg/framework/core/interfaces.go b/pkg/framework/core/interfaces.go index 8455e686..8d587d20 100644 --- a/pkg/framework/core/interfaces.go +++ b/pkg/framework/core/interfaces.go @@ -4,6 +4,7 @@ import ( "context" "embed" goio "io" + "sync/atomic" ) // This file defines the public API contracts (interfaces) for the services @@ -53,6 +54,14 @@ type Query interface{} // Used with PERFORM (first responder executes). type Task interface{} +// TaskWithID is an optional interface for tasks that need to know their assigned ID. +// This is useful for tasks that want to report progress back to the frontend. +type TaskWithID interface { + Task + SetTaskID(id string) + GetTaskID() string +} + // QueryHandler handles Query requests. Returns (result, handled, error). // If handled is false, the query will be passed to the next handler. type QueryHandler func(*Core, Query) (any, bool, error) @@ -78,6 +87,8 @@ type Core struct { Features *Features svc *serviceManager bus *messageBus + + taskIDCounter atomic.Uint64 } // Config provides access to application configuration. @@ -128,3 +139,25 @@ type ActionServiceStartup struct{} // ActionServiceShutdown is a message sent when the application is shutting down. // This allows services to perform cleanup tasks, such as saving state or closing resources. type ActionServiceShutdown struct{} + +// ActionTaskStarted is a message sent when a background task has started. +type ActionTaskStarted struct { + TaskID string + Task Task +} + +// ActionTaskProgress is a message sent when a task has progress updates. +type ActionTaskProgress struct { + TaskID string + Task Task + Progress float64 // 0.0 to 1.0 + Message string +} + +// ActionTaskCompleted is a message sent when a task has completed. +type ActionTaskCompleted struct { + TaskID string + Task Task + Result any + Error error +} diff --git a/pkg/framework/core/ipc_test.go b/pkg/framework/core/ipc_test.go index 87b65707..e019297a 100644 --- a/pkg/framework/core/ipc_test.go +++ b/pkg/framework/core/ipc_test.go @@ -3,6 +3,7 @@ package core import ( "errors" "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -75,3 +76,44 @@ func TestIPC_Perform(t *testing.T) { assert.Error(t, err) assert.Nil(t, res) } + +func TestIPC_PerformAsync(t *testing.T) { + c, _ := New() + + type AsyncResult struct { + TaskID string + Result any + Error error + } + done := make(chan AsyncResult, 1) + + c.RegisterTask(func(c *Core, task Task) (any, bool, error) { + if tt, ok := task.(IPCTestTask); ok { + return tt.Value + "-done", true, nil + } + return nil, false, nil + }) + + c.RegisterAction(func(c *Core, msg Message) error { + if m, ok := msg.(ActionTaskCompleted); ok { + done <- AsyncResult{ + TaskID: m.TaskID, + Result: m.Result, + Error: m.Error, + } + } + return nil + }) + + taskID := c.PerformAsync(IPCTestTask{Value: "async"}) + assert.NotEmpty(t, taskID) + + select { + case res := <-done: + assert.Equal(t, taskID, res.TaskID) + assert.Equal(t, "async-done", res.Result) + assert.Nil(t, res.Error) + case <-time.After(time.Second): + t.Fatal("timed out waiting for task completion") + } +}