PR #3103
Sections
Review

fix(office): recover lost children_completed wakes for stalled parents

main ← feature/office-add-a-childre-74o 15 files +612 −47 PR #3103 ↗

A level-triggered cron reconciler re-delivers lost task_children_completed wakes for parents whose children are all terminal, so stalled parents resume without manual retry.

Why this change

The edge-triggered children_completed wake fires once when the last child completes. If that dispatch is lost, the parent stays stalled forever with no retry path.

What it does

Architecture, end to end

The edge path can lose a wake. The reconciler re-derives stuck state from the database each tick and sends the same trigger through the engine.

flowchart LR
  Child[Child task completes] --> Moved[task.moved to Done]
  Moved --> Edge[queueChildrenCompletedRun edge trigger]
  Edge --> Engine[Workflow engine]
  Engine --> ParentRun[Parent task_children_completed run]
  Edge -. lost .-> Stalled[Parent stalled]
  Cron[Shared cron loop 30s] --> Reconciler[ParentWakeReconciler Tick]
  Reconciler --> Query[ListStuckParents SQL]
  Query --> Check{Receipt matches current child set?}
  Check -- no --> Engine
  Check -- yes --> Skip[Skip, steady state]
  Engine --> Receipt[(parent_child_wake_receipts)]
  Receipt --> Query

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

func (r *Repository) ListStuckParents(ctx context.Context, reason string, limit int) ([]StuckParentCandidate, error)
Click for details →

The query finds parents whose non-archived children are all terminal and whose wake has not been delivered for the current child set.

Candidate shape
type StuckParentCandidate struct {
  ParentTaskID           string `db:"parent_task_id"`
  AssigneeAgentProfileID string `db:"assignee_agent_profile_id"`
  WorkflowStepID         string `db:"workflow_step_id"`
  ChildSetKey            string `db:"child_set_key"`
}
Core sweep SQL
WITH stuck AS (
  SELECT
    p.id AS parent_task_id,
    COALESCE((SELECT GROUP_CONCAT(c.id || ':' || c.state, ',')
      FROM (SELECT id, state FROM tasks WHERE parent_id = p.id AND archived_at IS NULL ORDER BY id) c), '') AS child_set_key,
    (SELECT MAX(c.updated_at) FROM tasks c WHERE c.parent_id = p.id AND c.archived_at IS NULL) AS newest_child_updated_at
  FROM tasks p
  WHERE p.archived_at IS NULL
    AND p.is_ephemeral = 0
    AND p.state NOT IN ('COMPLETED', 'CANCELLED')
    AND EXISTS (SELECT 1 FROM tasks c WHERE c.parent_id = p.id AND c.archived_at IS NULL)
    AND NOT EXISTS (SELECT 1 FROM tasks c WHERE c.parent_id = p.id AND c.archived_at IS NULL AND c.state NOT IN ('COMPLETED', 'CANCELLED'))
)
SELECT s.parent_task_id, s.assignee_agent_profile_id, s.workflow_step_id, s.child_set_key
FROM stuck s
LEFT JOIN parent_child_wake_receipts r ON r.parent_task_id = s.parent_task_id
INNER JOIN agent_profiles ap ON ap.id = s.assignee_agent_profile_id
WHERE s.assignee_agent_profile_id != ''
  AND ap.status NOT IN ('paused', 'stopped', 'pending_approval')
  AND (r.child_set_key IS NOT s.child_set_key OR (NOT EXISTS (SELECT 1 FROM runs delivered WHERE delivered.id = r.delivered_run_id) AND COALESCE(r.delivery_operation_id, '') = ''))
  AND NOT EXISTS (SELECT 1 FROM runs w WHERE json_extract(w.payload, '$.task_id') = s.parent_task_id AND w.reason = ? AND (w.status IN ('queued', 'claimed') OR (w.status IN ('finished', 'failed', 'cancelled') AND w.requested_at >= s.newest_child_updated_at)))
ORDER BY s.parent_task_id LIMIT ?
Receipt write
func (r *Repository) UpsertWakeReceiptTx(ctx context.Context, tx *sqlx.Tx, parentTaskID, childSetKey, deliveredRunID, deliveryOperationID string, deliveredAt time.Time) error {
  _, err := tx.ExecContext(ctx, tx.Rebind(`
    INSERT INTO parent_child_wake_receipts (parent_task_id, child_set_key, delivered_run_id, delivery_operation_id, delivered_at)
    VALUES (?, ?, ?, ?, ?)
    ON CONFLICT (parent_task_id) DO UPDATE SET
      child_set_key = excluded.child_set_key,
      delivered_run_id = excluded.delivered_run_id,
      delivery_operation_id = excluded.delivery_operation_id,
      delivered_at = excluded.delivered_at
  `), parentTaskID, childSetKey, deliveredRunID, deliveryOperationID, deliveredAt)
  return err
}
func (h *ParentWakeReconciler) Tick(ctx context.Context) error
Click for details →

The handler sweeps for stuck parents each tick and re-delivers the wake through the workflow engine with a deterministic operation id.

Tick gate
func (h *ParentWakeReconciler) Tick(ctx context.Context) error {
  adopted, err := h.scheduler.svc.repo.HasOfficeAdoption(ctx)
  if err != nil {
    if ctx.Err() != nil {
      return nil
    }
    return fmt.Errorf("check Office adoption: %w", err)
  }
  if !adopted {
    return nil
  }
  h.reconcile(ctx)
  return nil
}
Reconcile one parent
func (h *ParentWakeReconciler) reconcileOne(ctx context.Context, svc *Service, c sqlite.StuckParentCandidate) {
  if err := svc.guardAgentStatus(ctx, c.AssigneeAgentProfileID); err != nil {
    svc.recordWakeAssigneeUnresolved(c.ParentTaskID, err.Error())
    return
  }
  payload, err := h.buildPayload(ctx, svc, c.ParentTaskID)
  if err != nil {
    return
  }
  currentKey, err := svc.repo.GetChildSetKey(ctx, c.ParentTaskID)
  if err != nil || currentKey != c.ChildSetKey {
    return
  }
  operationID := wakeOperationID(c.ParentTaskID, c.ChildSetKey)
  accepted, err := svc.dispatchEngineTriggerForRecovery(ctx, c.ParentTaskID, engine.TriggerOnChildrenCompleted, payload, operationID)
  if err != nil || !accepted {
    return
  }
  h.recordReceipt(ctx, svc, c, operationID)
}
Deterministic operation id
func wakeOperationID(parentTaskID, childSetKey string) string {
  sum := sha256.Sum256([]byte(parentTaskID + "\x00" + childSetKey))
  return fmt.Sprintf("task_children_completed:%s:%s", parentTaskID, hex.EncodeToString(sum[:]))
}
func startCronScheduler(ctx context.Context, repos *Repositories, dispatcher *officeenginedispatcher.Dispatcher, routineSvc *officeroutines.RoutineService, officeRecovery schedulercron.Handler, parentWakeReconciler schedulercron.Handler, log *logger.Logger) *schedulercron.Loop
Click for details →

The shared cron loop now includes the parent-wake reconciler as a fifth handler.

Patch
	repos *Repositories,
	dispatcher *officeenginedispatcher.Dispatcher,
	routineSvc *officeroutines.RoutineService,
	officeRecovery schedulercron.Handler,
	parentWakeReconciler schedulercron.Handler,
	log *logger.Logger,
) *schedulercron.Loop {
	routines := schedulercron.NewRoutinesHandler(routineTicker, nil, log)
	loop := schedulercron.NewLoop(schedulercron.DefaultTickInterval, log,
		heartbeat, budget, routines, officeRecovery)
		heartbeat, budget, routines, officeRecovery, parentWakeReconciler)
	loop.Start(ctx)
Startup gate
var officeRecovery schedulercron.Handler
var parentWakeReconciler schedulercron.Handler
if services.Office != nil {
  officeRecovery = officeservice.NewOfficeRecoveryHandler(orchScheduler)
  parentWakeReconciler = officeservice.NewParentWakeReconciler(orchScheduler)
}
cronLoop := startCronScheduler(ctx, repos, engineDispatcher, officeRoutines, officeRecovery, parentWakeReconciler, log)
func (r *Repository) createParentChildWakeReceiptsTable() error
Click for details →

The new table stores one receipt per parent for the last delivered child set, so steady state costs one lookup per tick.

Table
CREATE TABLE IF NOT EXISTS parent_child_wake_receipts (
  parent_task_id        TEXT PRIMARY KEY,
  child_set_key         TEXT NOT NULL,
  delivered_run_id      TEXT NOT NULL DEFAULT '',
  delivery_operation_id TEXT NOT NULL DEFAULT '',
  delivered_at          TIMESTAMP NOT NULL
);
Migrations
func (r *Repository) migrateParentWakeIndexes() {
  r.migrate.Apply("idx_tasks_parent_id", `CREATE INDEX IF NOT EXISTS idx_tasks_parent_id ON tasks(parent_id)`)
}
func (r *Repository) migrateParentWakeReceiptColumns() {
  r.migrate.Apply("parent_child_wake_receipts.delivery_operation_id", `ALTER TABLE parent_child_wake_receipts ADD COLUMN delivery_operation_id TEXT NOT NULL DEFAULT ''`)
}
func (s *Service) recordWakeEmitted(parentTaskID, operationID string)
Click for details →

Counters and logs make the sweep observable and guard the receipt race between SELECT and write.

Counters
var (
  parentWakeCandidatesTotal         = expvar.NewInt("parent_wake_candidates_total")
  parentWakeEmittedTotal            = expvar.NewInt("parent_wake_emitted_total")
  parentWakeAssigneeUnresolvedTotal = expvar.NewInt("parent_wake_assignee_unresolved_total")
)
func (s *Service) recordWakeCandidate(parentTaskID string) {
  parentWakeCandidatesTotal.Add(1)
}
func (s *Service) recordWakeEmitted(parentTaskID, operationID string) {
  parentWakeEmittedTotal.Add(1)
}
Transaction guard
func (r *Repository) GetTaskAssigneeTx(ctx context.Context, tx *sqlx.Tx, taskID string) (string, error) {
  var assignee string
  err := tx.QueryRowxContext(ctx, tx.Rebind(`SELECT `+RunnerProjection("tasks")+` FROM tasks WHERE id = ?`), taskID).Scan(&assignee)
  return assignee, err
}
// recordReceipt re-reads child set inside the tx before upsert
func (h *ParentWakeReconciler) recordReceipt(ctx context.Context, svc *Service, c sqlite.StuckParentCandidate, operationID string) {
  tx, _ := svc.repo.Writer().BeginTxx(ctx, nil)
  defer func() { _ = tx.Rollback() }()
  currentKey, _ := svc.repo.GetChildSetKeyTx(ctx, tx, c.ParentTaskID)
  if currentKey != c.ChildSetKey {
    return
  }
  svc.repo.UpsertWakeReceiptTx(ctx, tx, c.ParentTaskID, c.ChildSetKey, "", operationID, time.Now().UTC())
  tx.Commit()
}
Engine dispatch helper
func (s *Service) dispatchEngineTriggerForRecovery(ctx context.Context, taskID string, trigger engine.Trigger, payload any, opID string) (bool, error) {
  if s.engineDispatcher == nil {
    return false, nil
  }
  if d, ok := s.engineDispatcher.(interface{ HandleTriggerHandled(context.Context, string, engine.Trigger, any, string) (bool, error) }); ok {
    _, err := d.HandleTriggerHandled(ctx, taskID, trigger, payload, opID)
    if errors.Is(err, shared.ErrEngineNoSession) {
      return false, nil
    }
    return err == nil, err
  }
  return true, s.dispatchEngineTrigger(ctx, taskID, trigger, payload, opID)
}
Read the changes as a list

Stuck-parent query and receipt store

apps/backend/internal/office/repository/sqlite/wake_receipts.go

The query finds parents whose non-archived children are all terminal and whose wake has not been delivered for the current child set.

Candidate shape
type StuckParentCandidate struct {
  ParentTaskID           string `db:"parent_task_id"`
  AssigneeAgentProfileID string `db:"assignee_agent_profile_id"`
  WorkflowStepID         string `db:"workflow_step_id"`
  ChildSetKey            string `db:"child_set_key"`
}
Core sweep SQL
WITH stuck AS (
  SELECT
    p.id AS parent_task_id,
    COALESCE((SELECT GROUP_CONCAT(c.id || ':' || c.state, ',')
      FROM (SELECT id, state FROM tasks WHERE parent_id = p.id AND archived_at IS NULL ORDER BY id) c), '') AS child_set_key,
    (SELECT MAX(c.updated_at) FROM tasks c WHERE c.parent_id = p.id AND c.archived_at IS NULL) AS newest_child_updated_at
  FROM tasks p
  WHERE p.archived_at IS NULL
    AND p.is_ephemeral = 0
    AND p.state NOT IN ('COMPLETED', 'CANCELLED')
    AND EXISTS (SELECT 1 FROM tasks c WHERE c.parent_id = p.id AND c.archived_at IS NULL)
    AND NOT EXISTS (SELECT 1 FROM tasks c WHERE c.parent_id = p.id AND c.archived_at IS NULL AND c.state NOT IN ('COMPLETED', 'CANCELLED'))
)
SELECT s.parent_task_id, s.assignee_agent_profile_id, s.workflow_step_id, s.child_set_key
FROM stuck s
LEFT JOIN parent_child_wake_receipts r ON r.parent_task_id = s.parent_task_id
INNER JOIN agent_profiles ap ON ap.id = s.assignee_agent_profile_id
WHERE s.assignee_agent_profile_id != ''
  AND ap.status NOT IN ('paused', 'stopped', 'pending_approval')
  AND (r.child_set_key IS NOT s.child_set_key OR (NOT EXISTS (SELECT 1 FROM runs delivered WHERE delivered.id = r.delivered_run_id) AND COALESCE(r.delivery_operation_id, '') = ''))
  AND NOT EXISTS (SELECT 1 FROM runs w WHERE json_extract(w.payload, '$.task_id') = s.parent_task_id AND w.reason = ? AND (w.status IN ('queued', 'claimed') OR (w.status IN ('finished', 'failed', 'cancelled') AND w.requested_at >= s.newest_child_updated_at)))
ORDER BY s.parent_task_id LIMIT ?
Receipt write
func (r *Repository) UpsertWakeReceiptTx(ctx context.Context, tx *sqlx.Tx, parentTaskID, childSetKey, deliveredRunID, deliveryOperationID string, deliveredAt time.Time) error {
  _, err := tx.ExecContext(ctx, tx.Rebind(`
    INSERT INTO parent_child_wake_receipts (parent_task_id, child_set_key, delivered_run_id, delivery_operation_id, delivered_at)
    VALUES (?, ?, ?, ?, ?)
    ON CONFLICT (parent_task_id) DO UPDATE SET
      child_set_key = excluded.child_set_key,
      delivered_run_id = excluded.delivered_run_id,
      delivery_operation_id = excluded.delivery_operation_id,
      delivered_at = excluded.delivered_at
  `), parentTaskID, childSetKey, deliveredRunID, deliveryOperationID, deliveredAt)
  return err
}

Level-triggered reconciler

apps/backend/internal/office/service/scheduler_wake_reconciler.go

The handler sweeps for stuck parents each tick and re-delivers the wake through the workflow engine with a deterministic operation id.

Tick gate
func (h *ParentWakeReconciler) Tick(ctx context.Context) error {
  adopted, err := h.scheduler.svc.repo.HasOfficeAdoption(ctx)
  if err != nil {
    if ctx.Err() != nil {
      return nil
    }
    return fmt.Errorf("check Office adoption: %w", err)
  }
  if !adopted {
    return nil
  }
  h.reconcile(ctx)
  return nil
}
Reconcile one parent
func (h *ParentWakeReconciler) reconcileOne(ctx context.Context, svc *Service, c sqlite.StuckParentCandidate) {
  if err := svc.guardAgentStatus(ctx, c.AssigneeAgentProfileID); err != nil {
    svc.recordWakeAssigneeUnresolved(c.ParentTaskID, err.Error())
    return
  }
  payload, err := h.buildPayload(ctx, svc, c.ParentTaskID)
  if err != nil {
    return
  }
  currentKey, err := svc.repo.GetChildSetKey(ctx, c.ParentTaskID)
  if err != nil || currentKey != c.ChildSetKey {
    return
  }
  operationID := wakeOperationID(c.ParentTaskID, c.ChildSetKey)
  accepted, err := svc.dispatchEngineTriggerForRecovery(ctx, c.ParentTaskID, engine.TriggerOnChildrenCompleted, payload, operationID)
  if err != nil || !accepted {
    return
  }
  h.recordReceipt(ctx, svc, c, operationID)
}
Deterministic operation id
func wakeOperationID(parentTaskID, childSetKey string) string {
  sum := sha256.Sum256([]byte(parentTaskID + "\x00" + childSetKey))
  return fmt.Sprintf("task_children_completed:%s:%s", parentTaskID, hex.EncodeToString(sum[:]))
}

Cron wiring

apps/backend/internal/backendapp/cron.go

The shared cron loop now includes the parent-wake reconciler as a fifth handler.

Patch
	repos *Repositories,
	dispatcher *officeenginedispatcher.Dispatcher,
	routineSvc *officeroutines.RoutineService,
	officeRecovery schedulercron.Handler,
	parentWakeReconciler schedulercron.Handler,
	log *logger.Logger,
) *schedulercron.Loop {
	routines := schedulercron.NewRoutinesHandler(routineTicker, nil, log)
	loop := schedulercron.NewLoop(schedulercron.DefaultTickInterval, log,
		heartbeat, budget, routines, officeRecovery)
		heartbeat, budget, routines, officeRecovery, parentWakeReconciler)
	loop.Start(ctx)
Startup gate
var officeRecovery schedulercron.Handler
var parentWakeReconciler schedulercron.Handler
if services.Office != nil {
  officeRecovery = officeservice.NewOfficeRecoveryHandler(orchScheduler)
  parentWakeReconciler = officeservice.NewParentWakeReconciler(orchScheduler)
}
cronLoop := startCronScheduler(ctx, repos, engineDispatcher, officeRoutines, officeRecovery, parentWakeReconciler, log)

Receipt table and indexes

apps/backend/internal/office/repository/sqlite/base.go

The new table stores one receipt per parent for the last delivered child set, so steady state costs one lookup per tick.

Table
CREATE TABLE IF NOT EXISTS parent_child_wake_receipts (
  parent_task_id        TEXT PRIMARY KEY,
  child_set_key         TEXT NOT NULL,
  delivered_run_id      TEXT NOT NULL DEFAULT '',
  delivery_operation_id TEXT NOT NULL DEFAULT '',
  delivered_at          TIMESTAMP NOT NULL
);
Migrations
func (r *Repository) migrateParentWakeIndexes() {
  r.migrate.Apply("idx_tasks_parent_id", `CREATE INDEX IF NOT EXISTS idx_tasks_parent_id ON tasks(parent_id)`)
}
func (r *Repository) migrateParentWakeReceiptColumns() {
  r.migrate.Apply("parent_child_wake_receipts.delivery_operation_id", `ALTER TABLE parent_child_wake_receipts ADD COLUMN delivery_operation_id TEXT NOT NULL DEFAULT ''`)
}

Observability and race guards

apps/backend/internal/office/service/wake_metrics.go

Counters and logs make the sweep observable and guard the receipt race between SELECT and write.

Counters
var (
  parentWakeCandidatesTotal         = expvar.NewInt("parent_wake_candidates_total")
  parentWakeEmittedTotal            = expvar.NewInt("parent_wake_emitted_total")
  parentWakeAssigneeUnresolvedTotal = expvar.NewInt("parent_wake_assignee_unresolved_total")
)
func (s *Service) recordWakeCandidate(parentTaskID string) {
  parentWakeCandidatesTotal.Add(1)
}
func (s *Service) recordWakeEmitted(parentTaskID, operationID string) {
  parentWakeEmittedTotal.Add(1)
}
Transaction guard
func (r *Repository) GetTaskAssigneeTx(ctx context.Context, tx *sqlx.Tx, taskID string) (string, error) {
  var assignee string
  err := tx.QueryRowxContext(ctx, tx.Rebind(`SELECT `+RunnerProjection("tasks")+` FROM tasks WHERE id = ?`), taskID).Scan(&assignee)
  return assignee, err
}
// recordReceipt re-reads child set inside the tx before upsert
func (h *ParentWakeReconciler) recordReceipt(ctx context.Context, svc *Service, c sqlite.StuckParentCandidate, operationID string) {
  tx, _ := svc.repo.Writer().BeginTxx(ctx, nil)
  defer func() { _ = tx.Rollback() }()
  currentKey, _ := svc.repo.GetChildSetKeyTx(ctx, tx, c.ParentTaskID)
  if currentKey != c.ChildSetKey {
    return
  }
  svc.repo.UpsertWakeReceiptTx(ctx, tx, c.ParentTaskID, c.ChildSetKey, "", operationID, time.Now().UTC())
  tx.Commit()
}
Engine dispatch helper
func (s *Service) dispatchEngineTriggerForRecovery(ctx context.Context, taskID string, trigger engine.Trigger, payload any, opID string) (bool, error) {
  if s.engineDispatcher == nil {
    return false, nil
  }
  if d, ok := s.engineDispatcher.(interface{ HandleTriggerHandled(context.Context, string, engine.Trigger, any, string) (bool, error) }); ok {
    _, err := d.HandleTriggerHandled(ctx, taskID, trigger, payload, opID)
    if errors.Is(err, shared.ErrEngineNoSession) {
      return false, nil
    }
    return err == nil, err
  }
  return true, s.dispatchEngineTrigger(ctx, taskID, trigger, payload, opID)
}

Data and storage

One row per parent records the child set that was last delivered. The key is the sorted id:state list, compared directly without hashing.

FieldTypeNotes
parent_task_idTEXT PKparent task id, one row per parent
child_set_keyTEXTGROUP_CONCAT of child id:state ordered by id
delivered_run_idTEXTlegacy direct-run id, empty for engine path
delivery_operation_idTEXTsha256 of parent id + child set, used for engine dedup
delivered_atTIMESTAMPtime the receipt was recorded
idx_tasks_parent_idINDEXon tasks(parent_id) for the three correlated subqueries

Risk

5 / 10 Medium
1 low5 medium10 high

Why this score

  • Touches the hot task and runs tables on every 30s tick, but the query is indexed and capped at 5 rows.
  • Adds a new table and two migrations; rollback needs a DB restore if the table is already in use.
  • Well covered by SQL and service tests, but a wrong predicate could starve or spam parent wakes.

Trade-offs and review notes

Where to look first

  1. Read wake_receipts.go ListStuckParents: verify every sticky predicate runs before LIMIT and the Office predicate is present.
  2. Read scheduler_wake_reconciler.go reconcileOne: check the double child-set revalidation and the operation id dedup.
  3. Read base.go and base_migrations.go: confirm the table and the tasks(parent_id) index use the non-fatal migrate path.
  4. Check cron.go and main.go wiring: ensure the handler is built only when Office is enabled and passed to NewLoop.