summaryrefslogtreecommitdiff
path: root/internal/executor/channel.go
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-05-24 08:47:07 +0000
committerClaude <noreply@anthropic.com>2026-05-24 08:47:07 +0000
commitc7d95f3992d24f86ff71e5f3e18260a8ef8a09f0 (patch)
treeca62aca2287952907cdde0263672a0911b37d30e /internal/executor/channel.go
parent48a8e49cdda833f8af22e839ca3c102c40c97e17 (diff)
feat(executor): introduce AgentChannel seam for runner signals
Defines AgentChannel — the normalized interface by which a runner reports agent-originated signals (AskUser, ReportSummary, SpawnSubtask, RecordProgress) — plus a default storeChannel implementation backed by storage. Runner.Run now takes an AgentChannel; the pool constructs one per execution. The file transport routes its post-exit summary detection through ch.ReportSummary (buffered onto the execution so the pool still applies its extract/synthesize fallbacks, no double-write). AskUser returns ErrAgentBlocked since write-and-exit cannot answer in-session; question persistence stays with the pool's BlockedError handling. SpawnSubtask and RecordProgress are implemented and tested, ready for the MCP transport in Phase 2 where the channel becomes fully load-bearing. Store gains CreateEvent so the channel can emit agent_message events. https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39
Diffstat (limited to 'internal/executor/channel.go')
-rw-r--r--internal/executor/channel.go117
1 files changed, 117 insertions, 0 deletions
diff --git a/internal/executor/channel.go b/internal/executor/channel.go
new file mode 100644
index 0000000..76df94f
--- /dev/null
+++ b/internal/executor/channel.go
@@ -0,0 +1,117 @@
+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,
+ })
+} \ No newline at end of file