From 02489d25b98d67f332fe0c93156bb767c2e860a1 Mon Sep 17 00:00:00 2001 From: Claudomator Agent Date: Wed, 8 Jul 2026 08:35:14 +0000 Subject: feat(agentchannel): add DependsOn to SubtaskSpec so a decomposing task can order its own subtasks --- internal/agentchannel/agentchannel.go | 8 ++++ internal/executor/channel.go | 1 + internal/executor/channel_test.go | 69 +++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/internal/agentchannel/agentchannel.go b/internal/agentchannel/agentchannel.go index 202283e..98104df 100644 --- a/internal/agentchannel/agentchannel.go +++ b/internal/agentchannel/agentchannel.go @@ -44,6 +44,14 @@ type SubtaskSpec struct { // any other role-typed task. Empty (the default) preserves the prior // hardcoded "claude" behavior for backward compatibility. Role string + // DependsOn, when non-empty, names sibling task IDs (returned by a prior + // SpawnSubtask call in the same decomposition) this new subtask must wait + // for — set directly on the created child's task.DependsOn, so the + // existing dispatch-gating and cascade-fail-on-dependency-failure + // machinery (already built for top-level tasks) applies unchanged. Empty + // (the default) preserves today's behavior: every spawned subtask is + // structurally independent/parallel. + DependsOn []string } // EpicProposal describes an epic a discovery/planner-role agent wants to diff --git a/internal/executor/channel.go b/internal/executor/channel.go index 5afe22e..9392c77 100644 --- a/internal/executor/channel.go +++ b/internal/executor/channel.go @@ -156,6 +156,7 @@ func (c *storeChannel) SpawnSubtask(_ context.Context, spec SubtaskSpec) (string ParentTaskID: c.taskID, Agent: agent, Priority: task.PriorityNormal, + DependsOn: spec.DependsOn, State: task.StatePending, CreatedAt: now, UpdatedAt: now, diff --git a/internal/executor/channel_test.go b/internal/executor/channel_test.go index 522f23a..84cad89 100644 --- a/internal/executor/channel_test.go +++ b/internal/executor/channel_test.go @@ -269,6 +269,75 @@ func TestStoreChannel_SpawnSubtask_PropagatesError(t *testing.T) { } } +// TestStoreChannel_SpawnSubtask_SetsDependsOn proves a caller can chain a +// new subtask after a sibling it already spawned, by passing the sibling's +// returned ID back in DependsOn -- the mechanism a decomposing task uses to +// express "this step must wait for that one", not just fan-out-and-wait. +func TestStoreChannel_SpawnSubtask_SetsDependsOn(t *testing.T) { + store := &fakeChannelStore{} + ch := newStoreChannel(store, "parent-task-1") + + firstID, err := ch.SpawnSubtask(context.Background(), agentchannel.SubtaskSpec{ + Name: "step 1", + Instructions: "do the first thing", + }) + if err != nil { + t.Fatalf("spawn first subtask: %v", err) + } + + secondID, err := ch.SpawnSubtask(context.Background(), agentchannel.SubtaskSpec{ + Name: "step 2", + Instructions: "do the second thing", + DependsOn: []string{firstID}, + }) + if err != nil { + t.Fatalf("spawn second subtask: %v", err) + } + + var second *task.Task + for _, ct := range store.createdTasks { + if ct.ID == secondID { + second = ct + } + } + if second == nil { + t.Fatal("second subtask not found in store.createdTasks") + } + if len(second.DependsOn) != 1 || second.DependsOn[0] != firstID { + t.Errorf("expected DependsOn=[%q], got %v", firstID, second.DependsOn) + } +} + +// TestStoreChannel_SpawnSubtask_DependsOnDefaultsEmpty proves the +// backward-compatible default: a caller that never sets DependsOn (every +// pre-existing caller) gets a child task with no dependencies, exactly as +// before this change. +func TestStoreChannel_SpawnSubtask_DependsOnDefaultsEmpty(t *testing.T) { + store := &fakeChannelStore{} + ch := newStoreChannel(store, "parent-task-1") + + id, err := ch.SpawnSubtask(context.Background(), agentchannel.SubtaskSpec{ + Name: "solo step", + Instructions: "do the thing", + }) + if err != nil { + t.Fatalf("spawn subtask: %v", err) + } + + var got *task.Task + for _, ct := range store.createdTasks { + if ct.ID == id { + got = ct + } + } + if got == nil { + t.Fatal("subtask not found in store.createdTasks") + } + if len(got.DependsOn) != 0 { + t.Errorf("expected empty DependsOn by default, got %v", got.DependsOn) + } +} + func TestStoreChannel_RecordProgress_EmitsAgentMessage(t *testing.T) { store := &fakeChannelStore{} ch := newStoreChannel(store, "task-1") -- cgit v1.2.3