package executor import ( "context" "encoding/json" "errors" "time" "github.com/thepeterstone/claudomator/internal/event" "github.com/thepeterstone/claudomator/internal/storage" "github.com/thepeterstone/claudomator/internal/task" "github.com/google/uuid" ) // ErrAgentBlocked is returned by AgentChannel.AskUser when the transport cannot // deliver an answer within the current run — e.g. the file transport, which can // only write-and-exit. Runners translate it into a *BlockedError so the task // blocks and resumes later. Transports that can suspend the agent in-session // (MCP, Phase 2) instead block and return the answer. var ErrAgentBlocked = errors.New("agent blocked awaiting user input") // SubtaskSpec describes a child task an agent wants to spawn. type SubtaskSpec struct { Name string Instructions string Model string MaxBudgetUSD float64 } // AgentChannel is how a Runner reports agent-originated signals to the rest of // the system. Implementations translate these into stored artifacts and events. // The transport by which a Runner detects these signals — post-exit files // today, MCP/tool-use in Phase 2 — is the Runner's private concern. type AgentChannel interface { // AskUser records that the agent needs human input. Transports that can // suspend the agent in-session return the answer; those that cannot return // ErrAgentBlocked. AskUser(ctx context.Context, questionJSON string) (answer string, err error) // ReportSummary records the agent's final summary text. ReportSummary(ctx context.Context, summary string) error // SpawnSubtask creates a child task and returns its ID. SpawnSubtask(ctx context.Context, spec SubtaskSpec) (taskID string, err error) // RecordProgress records a free-form progress note from the agent. RecordProgress(ctx context.Context, message string) error } // channelStore is the subset of storage the default channel needs. type channelStore interface { CreateTask(t *task.Task) error CreateEvent(e *event.Event) error } // storeChannel is the default AgentChannel backed by storage. Summary reports // are buffered onto the execution record so the pool persists them once (with // its extract/synthesize fallbacks); other signals are written immediately. type storeChannel struct { store channelStore taskID string exec *storage.Execution } var _ AgentChannel = (*storeChannel)(nil) func newStoreChannel(store channelStore, taskID string, exec *storage.Execution) *storeChannel { return &storeChannel{store: store, taskID: taskID, exec: exec} } // AskUser on the default (file-transport) channel cannot deliver an answer // in-session, so it always reports the run as blocked. Question persistence is // owned by the pool's BlockedError handling. func (c *storeChannel) AskUser(_ context.Context, _ string) (string, error) { return "", ErrAgentBlocked } // ReportSummary buffers the summary onto the execution record. The pool persists // it (preferring this value over its extract/synthesize fallbacks). func (c *storeChannel) ReportSummary(_ context.Context, summary string) error { if c.exec != nil { c.exec.Summary = summary } return nil } func (c *storeChannel) SpawnSubtask(_ context.Context, spec SubtaskSpec) (string, error) { now := time.Now().UTC() child := &task.Task{ ID: uuid.NewString(), Name: spec.Name, ParentTaskID: c.taskID, Agent: task.AgentConfig{ Type: "claude", Model: spec.Model, Instructions: spec.Instructions, MaxBudgetUSD: spec.MaxBudgetUSD, }, Priority: task.PriorityNormal, State: task.StatePending, CreatedAt: now, UpdatedAt: now, } if err := c.store.CreateTask(child); err != nil { return "", err } return child.ID, nil } func (c *storeChannel) RecordProgress(_ context.Context, message string) error { payload, _ := json.Marshal(struct { Message string `json:"message"` }{Message: message}) return c.store.CreateEvent(&event.Event{ TaskID: c.taskID, Kind: event.KindAgentMessage, Actor: event.ActorAgent, Payload: payload, }) }