summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorClaudomator Agent <agent@claudomator>2026-07-08 08:35:14 +0000
committerClaudomator Agent <agent@claudomator>2026-07-08 08:35:14 +0000
commit02489d25b98d67f332fe0c93156bb767c2e860a1 (patch)
tree987e71906e522f7d1dfd1af2d63c7e8ea3ccb06b /internal
parent8d9d42ba4f6c5f8cb729fcb851e5ef30fff46d37 (diff)
feat(agentchannel): add DependsOn to SubtaskSpec so a decomposing task can order its own subtasks
Diffstat (limited to 'internal')
-rw-r--r--internal/agentchannel/agentchannel.go8
-rw-r--r--internal/executor/channel.go1
-rw-r--r--internal/executor/channel_test.go69
3 files changed, 78 insertions, 0 deletions
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")