summaryrefslogtreecommitdiff
path: root/internal/executor/executor.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/executor/executor.go')
-rw-r--r--internal/executor/executor.go46
1 files changed, 37 insertions, 9 deletions
diff --git a/internal/executor/executor.go b/internal/executor/executor.go
index 03b368e..659bcda 100644
--- a/internal/executor/executor.go
+++ b/internal/executor/executor.go
@@ -533,12 +533,10 @@ func (p *Pool) handleRunResult(ctx context.Context, t *task.Task, exec *storage.
if err := p.store.UpdateTaskState(t.ID, task.StateReady); err != nil {
p.logger.Error("failed to update task state", "taskID", t.ID, "state", task.StateReady, "error", err)
}
+ } else if p.promoteNestedTask(t) == task.StateReady {
+ exec.Status = "READY"
} else {
exec.Status = "COMPLETED"
- if err := p.store.UpdateTaskState(t.ID, task.StateCompleted); err != nil {
- p.logger.Error("failed to update task state", "taskID", t.ID, "state", task.StateCompleted, "error", err)
- }
- p.maybeUnblockParent(t.ParentTaskID)
}
}
@@ -1227,6 +1225,40 @@ func withFailureHistory(t *task.Task, execs []*storage.Execution, err error) *ta
// parentID is itself a subtask (ParentTaskID set) -- mirroring
// handleRunResult's own subtask-completion branch, since a subtask never
// needs a human accept regardless of whether it got there by finishing its
+// promoteNestedTask transitions a nested task (t.ParentTaskID != "", with no
+// further subtasks of its own) to the state its own completion should
+// reach, and returns that state. A builder-role nested task goes to READY,
+// not COMPLETED: builder-role tasks -- roll-up or leaf, root or nested --
+// require arbitrated review before their completion can be trusted (see
+// docs/superpowers/specs/2026-07-09-recursive-arbitrated-review-design.md).
+// executor stays story-agnostic here -- it has no idea what a "story" is,
+// it just treats "builder" as always requiring external approval before
+// COMPLETED, uniformly, the same rule internal/scheduler.StoryOrchestrator
+// already applies to a story's root task (see that package's
+// finalizeArbitration). A builder-role task nobody's story tree ever
+// adopts simply sits at READY (and its parent BLOCKED) forever -- the same
+// category of graceful degradation already tolerated elsewhere in this
+// codebase for a role with no active role_configs row.
+//
+// Every other task transitions to COMPLETED exactly as before this change,
+// and this is the one place that triggers the parent-unblock cascade on
+// t.ParentTaskID -- callers must NOT also call maybeUnblockParent
+// themselves; this method owns that decision entirely.
+func (p *Pool) promoteNestedTask(t *task.Task) task.State {
+ if t.Agent.Role == "builder" {
+ if err := p.store.UpdateTaskState(t.ID, task.StateReady); err != nil {
+ p.logger.Error("promoteNestedTask: update task state", "taskID", t.ID, "error", err)
+ }
+ return task.StateReady
+ }
+ if err := p.store.UpdateTaskState(t.ID, task.StateCompleted); err != nil {
+ p.logger.Error("promoteNestedTask: update task state", "taskID", t.ID, "error", err)
+ return t.State
+ }
+ p.maybeUnblockParent(t.ParentTaskID)
+ return task.StateCompleted
+}
+
// own direct work or by having all of its delegated children finish. That
// COMPLETED transition recurses into maybeUnblockParent(parent.ParentTaskID)
// so a chain of nested decomposition (subtask spawns subtask spawns
@@ -1269,11 +1301,7 @@ func (p *Pool) maybeUnblockParent(parentID string) {
}
}
if parent.State == task.StateBlocked && parent.ParentTaskID != "" {
- if err := p.store.UpdateTaskState(parentID, task.StateCompleted); err != nil {
- p.logger.Error("maybeUnblockParent: update parent state", "parentID", parentID, "error", err)
- return
- }
- p.maybeUnblockParent(parent.ParentTaskID)
+ p.promoteNestedTask(parent)
return
}
if err := p.store.UpdateTaskState(parentID, task.StateReady); err != nil {