Split dependency gate into blocked and gateErrored
apps/backend/internal/orchestrator/event_handlers_dependencies.go ↗The gate now tells callers whether a block came from a read error or a real dependency, so only errors restore the one-shot token.
New signature and comment
func (s *Service) dependencyBlocksAutoStart(ctx context.Context, taskID, eventName string) (blocked, gateErrored bool) {
if s.dependencyReader == nil {
return false, false
}
isBlocked, reason, err := s.dependencyReader.DependencyGate(ctx, taskID)
if err != nil {
s.logger.Warn(eventName+": dependency lookup failed; skipping auto-start",
zap.String("task_id", taskID), zap.Error(err))
return true, true
}
if isBlocked {
s.logger.Debug(eventName+": task has unresolved dependencies; skipping auto-start",
zap.String("task_id", taskID), zap.String("blocked_reason", reason))
}
return isBlocked, false
}
Carry claimed token through auto-start chokepoint
apps/backend/internal/orchestrator/event_handlers_workflow.go ↗The chokepoint now knows if the caller already claimed the create token, and it restores the token on every early return that would otherwise strand the task.
Signature and early restores
func (s *Service) autoStartTaskForStep(ctx context.Context, taskID, stepID, eventName string, stepTransitionID int64, autoStartOnCreateClaimed bool) {
task, err := s.repo.GetTask(ctx, taskID)
if err != nil {
s.logger.Warn(eventName+": failed to load task for auto-start", zap.String("task_id", taskID), zap.Error(err))
if autoStartOnCreateClaimed {
s.restoreTaskLifecycleToken(ctx, taskID, models.MetaKeyAutoStartOnCreate, true, eventName)
}
return
}
if blocked, gateErrored := s.dependencyBlocksAutoStart(ctx, taskID, eventName); blocked {
if gateErrored && autoStartOnCreateClaimed {
s.restoreTaskLifecycleToken(ctx, taskID, models.MetaKeyAutoStartOnCreate, true, eventName)
}
return
}
if hasQueuePromotionPending(task) {
s.handleTaskQueuePromotedWithAutoStartOnCreateClaimed(ctx, watcher.TaskEventData{TaskID: task.ID, StepTransitionID: stepTransitionID}, autoStartOnCreateClaimed)
return
}
step, err := s.workflowStepGetter.GetStep(ctx, stepID)
if err != nil {
if autoStartOnCreateClaimed {
s.restoreTaskLifecycleToken(ctx, taskID, models.MetaKeyAutoStartOnCreate, true, eventName)
}
return
}
s.autoStartTaskForLoadedStep(ctx, task, step, eventName, false, stepTransitionID, autoStartOnCreateClaimed)
}
Synchronous claim and structured restore on failure
apps/backend/internal/orchestrator/event_handlers_workflow.go ↗The launch claims the token before the goroutine starts, and the failure handler restores it together with the promotion token.
Claim helper
func (s *Service) claimAutoStartOnCreateForLaunch(ctx context.Context, task *models.Task, alreadyClaimed bool) bool {
if alreadyClaimed {
return true
}
if !models.HasAutoStartOnCreateIntent(task.Metadata) {
return false
}
return s.claimTaskEventMetadata(ctx, task, models.MetaKeyAutoStartOnCreate)
}
Tokens struct and failure handler
type autoStartLaunchTokens struct {
hasGuard bool
restoreQueuePromotion bool
queuePromotionToken interface{}
restoreAutoStartOnCreate bool
}
func (s *Service) handleAutoStartFailure(ctx context.Context, taskID, eventName string, tokens autoStartLaunchTokens) {
if tokens.hasGuard {
s.restoreAutoStartClaim(ctx, taskID, eventName)
}
if tokens.restoreQueuePromotion {
s.restoreTaskLifecycleToken(ctx, taskID, models.MetaKeyQueuePromotionPending, tokens.queuePromotionToken, eventName)
}
if tokens.restoreAutoStartOnCreate {
s.restoreTaskLifecycleToken(ctx, taskID, models.MetaKeyAutoStartOnCreate, true, eventName)
}
s.setTaskAutoStartFailedMarker(ctx, taskID, eventName)
}
Use in loaded step
func (s *Service) autoStartTaskForLoadedStep(ctx context.Context, task *models.Task, step *wfmodels.WorkflowStep, eventName string, restoreQueuePromotion bool, stepTransitionID int64, autoStartOnCreateClaimed bool) {
// ...
restoreAutoStartOnCreate := s.claimAutoStartOnCreateForLaunch(ctx, task, autoStartOnCreateClaimed)
if s.isOfficeTask(ctx, task.ID) {
s.autoStartOfficeTaskForLoadedStep(ctx, task, step, eventName, restoreQueuePromotion, stepTransitionID, restoreAutoStartOnCreate)
return
}
// ...
go func() {
// ...
_, err := s.StartTask(asyncCtx, task.ID, startAgentProfileID, executorID, executorProfileID, "", task.Description, step.ID, planMode, true, nil)
if err != nil {
s.handleAutoStartFailure(asyncCtx, task.ID, eventName, autoStartLaunchTokens{
hasGuard: hasGuard, restoreQueuePromotion: restoreQueuePromotion, queuePromotionToken: queuePromotionToken, restoreAutoStartOnCreate: restoreAutoStartOnCreate,
})
}
}()
}
Promotion handler that preserves create-token ownership
apps/backend/internal/orchestrator/event_handlers_workflow.go ↗The promotion path now receives the already-claimed flag so a task with both tokens does not lose the create token on redirect.
Split and carry flag
func (s *Service) handleTaskQueuePromoted(ctx context.Context, data watcher.TaskEventData) {
s.handleTaskQueuePromotedWithAutoStartOnCreateClaimed(ctx, data, false)
}
func (s *Service) loadQueuePromotedTaskAndTargetStep(ctx context.Context, taskID string) (*models.Task, *wfmodels.WorkflowStep, bool) {
task, err := s.repo.GetTask(ctx, taskID)
if err != nil {
s.logger.Warn("task.queue_promoted: failed to load task", zap.String("task_id", taskID), zap.Error(err))
return nil, nil, false
}
if task.QueuedForStepID != "" || !task.WIPAdmitted {
return nil, nil, false
}
if queuedMoveExitPending(task) || manualMoveLifecyclePending(task) {
return nil, nil, false
}
targetStep, err := s.workflowStepGetter.GetStep(ctx, task.WorkflowStepID)
if err != nil || targetStep == nil {
return nil, nil, false
}
return task, targetStep, true
}
func (s *Service) handleTaskQueuePromotedWithAutoStartOnCreateClaimed(ctx context.Context, data watcher.TaskEventData, autoStartOnCreateClaimed bool) {
task, targetStep, ok := s.loadQueuePromotedTaskAndTargetStep(ctx, data.TaskID)
if !ok {
return
}
// ...
s.autoStartTaskForLoadedStep(ctx, task, targetStep, "task.queue_promoted", true, data.StepTransitionID, autoStartOnCreateClaimed)
}
Re-fetch after promotion to close stale-read race
apps/backend/internal/orchestrator/event_handlers_workflow.go ↗The sweep now reloads the task after the promotion branch so it sees the synchronous claim and does not schedule a second launch.
Re-fetch after promotion
if _, pending := task.Metadata[models.MetaKeyQueuePromotionPending]; pending {
s.handleTaskQueuePromoted(ctx, watcher.TaskEventData{TaskID: taskID})
// handleTaskQueuePromoted's no-session branch schedules a launch via
// autoStartTaskForLoadedStep, which synchronously claims
// MetaKeyAutoStartOnCreate when the task still carries it before this call returns.
task, err = s.repo.GetTask(ctx, taskID)
if err != nil || task == nil {
return false
}
}
if autoStartOnCreateActionable(task) {
s.recoverAutoStartOnCreate(ctx, task)
}
Downgrade helper for session.launch
apps/backend/internal/orchestrator/session_launch.go ↗The session launch path now uses one helper that checks both step policy and dependency gate, without token-restore side effects.
Helper and use
func (s *Service) blocksAutoStartLaunch(ctx context.Context, req *LaunchSessionRequest) bool {
if s.shouldBlockAutoStart(ctx, req) {
return true
}
blocked, _ := s.dependencyBlocksAutoStart(ctx, req.TaskID, "session.launch")
return blocked
}
func (s *Service) launchStart(ctx context.Context, req *LaunchSessionRequest) (*LaunchSessionResponse, error) {
if req.AutoStart && s.blocksAutoStartLaunch(ctx, req) {
req.LaunchWorkspace = true
return s.launchPrepare(ctx, req)
}
// ...
}