PR #3475
Sections
Review

feat(tasks): mark unstartable tasks with orphaned workspaces on the board

main ← feature/board-marker-for-uns-h7s 60 files +1240 −180 PR #3475 ↗

The board now shows a muted folder icon for inherit_parent subtasks whose parent workspace was archived, with a guarded metadata marker and a startup repair that backfills the state.

Why this change

An inherit_parent subtask borrows its parent workspace. When the parent is archived the workspace is removed, but the card still looks launchable until start fails closed.

What it does

Architecture, end to end

Archive stamps a guarded marker. The DTO derives a boolean. Boot and WS carry it to the board. Startup repair backfills the same marker.

flowchart LR
  A[Archive parent] --> B[markOrphanedInheritParentChild]
  B --> C[SetTaskWorkspaceMetadataIfUnchanged]
  C --> D[metadata.workspace.orphaned]
  D --> E[WorkspaceOrphaned derivation]
  E --> F[TaskDTO workspace_orphaned]
  F --> G[Boot mapKanbanTaskState]
  F --> H[WS task.updated]
  G --> I[Kanban card]
  H --> I
  I --> J[WorkspaceOrphanedTaskIcon]
  K[Startup RepairOrphanedWorkspaceMarkers] --> C

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

Backend
Frontend
func WorkspaceOrphaned(metadata map[string]interface{}) bool
Click for details →

One function decides if a task is orphaned, so DTO, events, and repair agree.

Derivation
func WorkspaceOrphaned(metadata map[string]interface{}) bool {
  ws, ok := metadata[workspaceMetadataKey].(map[string]interface{})
  if !ok {
    return false
  }
  orphaned, _ := ws[WorkspaceOrphanedKey].(bool)
  mode, _ := ws[WorkspaceModeKey].(string)
  return orphaned && mode == WorkspaceModeInheritParent
}
Guard shape
type OrphanWriteGuard struct {
  ExpectedOrphanedParentID string
  ExpectedMode             string
  RequireParentArchivedID   string
  RequireParentUnarchivedID string
  RequireParentID string
  RequireNoOwnEnvironment bool
  RequireTaskNotArchived bool
}

func ObservedWorkspaceGuard(workspace map[string]interface{}) OrphanWriteGuard {
  claim, _ := workspace["orphaned_parent_id"].(string)
  mode, _ := workspace[WorkspaceModeKey].(string)
  return OrphanWriteGuard{ExpectedOrphanedParentID: claim, ExpectedMode: mode}
}
func (r *Repository) SetTaskWorkspaceMetadataIfUnchanged(ctx context.Context, taskID string, guard models.OrphanWriteGuard, value map[string]interface{}) (bool, error)
Click for details →

The check and the write run in one SQL statement, so concurrent archive and unarchive cannot mix keys.

Guarded UPDATE
func (r *Repository) SetTaskWorkspaceMetadataIfUnchanged(
  ctx context.Context, taskID string,
  guard models.OrphanWriteGuard, value map[string]interface{},
) (bool, error) {
  payload, err := json.Marshal(value)
  if err != nil {
    return false, err
  }
  isPostgres := dialect.IsPostgres(r.db.DriverName())
  var b strings.Builder
  args := make([]interface{}, 0, 12)
  if isPostgres {
    const m = `CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}'::jsonb ELSE metadata::jsonb END`
    b.WriteString(`UPDATE tasks SET metadata = jsonb_set(` + m + `, ARRAY['workspace'], ?::jsonb, true)::text, updated_at = ? WHERE id = ?`)
    args = append(args, string(payload), time.Now().UTC(), taskID)
    b.WriteString(` AND COALESCE(CASE WHEN jsonb_typeof(` + m + ` -> 'workspace' -> 'orphaned_parent_id') = 'string' THEN ` + m + ` #>> ARRAY['workspace','orphaned_parent_id'] END, '') = ?`)
    args = append(args, guard.ExpectedOrphanedParentID)
    b.WriteString(` AND COALESCE(CASE WHEN jsonb_typeof(` + m + ` -> 'workspace' -> 'mode') = 'string' THEN ` + m + ` #>> ARRAY['workspace','mode'] END, '') = ?`)
    args = append(args, guard.ExpectedMode)
  } else {
    b.WriteString(`UPDATE tasks SET metadata = json_set(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}' ELSE metadata END, '$.workspace', json(?)), updated_at = ? WHERE id = ?`)
    args = append(args, string(payload), time.Now().UTC(), taskID)
    b.WriteString(` AND COALESCE(CASE WHEN json_valid(metadata) AND json_type(metadata,'$.workspace.orphaned_parent_id') = 'text' THEN json_extract(metadata,'$.workspace.orphaned_parent_id') END,'') = ?`)
    args = append(args, guard.ExpectedOrphanedParentID)
    b.WriteString(` AND COALESCE(CASE WHEN json_valid(metadata) AND json_type(metadata,'$.workspace.mode') = 'text' THEN json_extract(metadata,'$.workspace.mode') END,'') = ?`)
    args = append(args, guard.ExpectedMode)
  }
  if guard.RequireParentID != "" {
    b.WriteString(` AND parent_id = ?`)
    args = append(args, guard.RequireParentID)
  }
  if guard.RequireParentArchivedID != "" {
    b.WriteString(` AND EXISTS (SELECT 1 FROM tasks p WHERE p.id = ? AND p.archived_at IS NOT NULL AND p.workspace_id = (SELECT workspace_id FROM tasks t WHERE t.id = ?))`)
    args = append(args, guard.RequireParentArchivedID, taskID)
  }
  if guard.RequireParentUnarchivedID != "" {
    b.WriteString(` AND EXISTS (SELECT 1 FROM tasks p WHERE p.id = ? AND p.archived_at IS NULL AND p.workspace_id = (SELECT workspace_id FROM tasks t WHERE t.id = ?))`)
    args = append(args, guard.RequireParentUnarchivedID, taskID)
  }
  if guard.RequireNoOwnEnvironment {
    b.WriteString(` AND NOT EXISTS (SELECT 1 FROM task_environments e WHERE e.task_id = ?)`)
    args = append(args, taskID)
  }
  if guard.RequireTaskNotArchived {
    b.WriteString(` AND archived_at IS NULL`)
  }
  result, err := r.db.ExecContext(ctx, r.db.Rebind(b.String()), args...)
  if err != nil {
    return false, err
  }
  rows, err := result.RowsAffected()
  if err != nil {
    return false, err
  }
  return rows > 0, nil
}
func (s *HandoffService) markOrphanedInheritParentChild(ctx context.Context, envRepo workspaceEnvironmentRepository, archived, child *models.Task)
Click for details →

Archive marks direct inherit_parent children without their own environment. Unarchive clears only the matching parent claim.

Stamp helper
func stampOrphanedWorkspaceMetadata(workspace map[string]interface{}, parentID string) {
  workspace[orphanedWorkspaceKey] = true
  workspace[orphanedReasonWorkspaceKey] = orphanedReasonParentArchived
  workspace[orphanedParentIDKey] = parentID
  workspace[orphanedAtKey] = time.Now().UTC().Format(time.RFC3339)
}

func clearOrphanedWorkspaceMetadata(workspace map[string]interface{}) bool {
  changed := false
  for _, key := range []string{orphanedWorkspaceKey, orphanedReasonWorkspaceKey, orphanedParentIDKey, orphanedAtKey} {
    if _, ok := workspace[key]; ok {
      delete(workspace, key)
      changed = true
    }
  }
  return changed
}
Mark one child
func (s *HandoffService) markOrphanedInheritParentChild(
  ctx context.Context,
  envRepo workspaceEnvironmentRepository,
  archived, child *models.Task,
) {
  if child == nil || taskWorkspaceMode(child.Metadata) != workspaceModeInheritParent {
    return
  }
  if envRepo != nil {
    ownEnv, err := envRepo.GetTaskEnvironmentByTaskID(ctx, child.ID)
    if err != nil {
      s.logf().Warn("check child task environment for orphan marking failed",
        zap.String("task_id", child.ID), zap.String("parent_task_id", archived.ID), zap.Error(err))
      return
    }
    if ownEnv != nil {
      return
    }
  }
  workspace, _ := child.Metadata["workspace"].(map[string]interface{})
  guard := models.ObservedWorkspaceGuard(workspace)
  guard.RequireParentArchivedID = archived.ID
  guard.RequireParentID = archived.ID
  guard.RequireTaskNotArchived = true
  stampOrphanedWorkspaceMetadata(workspace, archived.ID)
  landed, err := s.updateWorkspaceMetadata(ctx, child, guard)
  if err != nil {
    s.logf().Warn("mark orphaned inherit_parent child failed",
      zap.String("task_id", child.ID), zap.String("parent_task_id", archived.ID), zap.Error(err))
    return
  }
  if !landed {
    s.logf().Debug("mark orphaned inherit_parent child lost its guard",
      zap.String("task_id", child.ID), zap.String("parent_task_id", archived.ID))
    return
  }
  if s.eventPublisher != nil {
    s.eventPublisher.PublishTaskUpdated(ctx, child)
  }
  s.logf().Info("marked inherit_parent child orphaned by parent archive",
    zap.String("task_id", child.ID), zap.String("parent_task_id", archived.ID))
}
func (s *HandoffService) RepairOrphanedWorkspaceMarkers(ctx context.Context)
Click for details →

Every boot stamps unmarked orphans and clears stale claims, so old boards converge without a reload.

Repair entry
func (s *HandoffService) RepairOrphanedWorkspaceMarkers(ctx context.Context) {
  repo, ok := s.tasks.(workspaceOrphanRepairRepository)
  if !ok {
    s.logf().Info("task repository does not support the orphan marker repair; skipping")
    return
  }
  if count, err := repo.CountMalformedTaskMetadata(ctx); err != nil {
    s.logf().Warn("count malformed task metadata for orphan marker repair failed", zap.Error(err))
  } else if count > 0 {
    s.logf().Warn("tasks with malformed metadata excluded from orphan marker repair",
      zap.Int("count", count))
  }
  stamped := s.repairStampOrphanMarkers(ctx, repo)
  cleared := s.repairClearStaleOrphanMarkers(ctx, repo)
  s.logf().Info("orphan marker repair pass complete",
    zap.Int("stamped", stamped), zap.Int("cleared", cleared))
}
SQL selection
func (r *Repository) ListOrphanRepairCandidates(ctx context.Context) ([]models.OrphanRepairCandidate, error) {
  query := `
    SELECT c.id, p.id AS parent_id,
           json_extract(c.metadata,'$.workspace') AS workspace
    FROM tasks c JOIN tasks p ON c.parent_id = p.id
    WHERE c.archived_at IS NULL AND p.archived_at IS NOT NULL
      AND c.is_ephemeral = 0
      AND COALESCE(c.origin,'') != 'automation_run'
      AND json_valid(c.metadata)
      AND json_extract(c.metadata,'$.workspace.mode') = 'inherit_parent'
      AND json_type(c.metadata,'$.workspace.orphaned') IS NOT 'true'
      AND NOT EXISTS (SELECT 1 FROM task_environments e WHERE e.task_id = c.id)
    ORDER BY c.created_at, c.id
  `
  rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query))
  if err != nil {
    return nil, err
  }
  defer func() { _ = rows.Close() }()
  var out []models.OrphanRepairCandidate
  for rows.Next() {
    var taskID, parentID string
    var workspaceJSON []byte
    if err := rows.Scan(&taskID, &parentID, &workspaceJSON); err != nil {
      return nil, err
    }
    var workspace map[string]interface{}
    if len(workspaceJSON) > 0 {
      if err := json.Unmarshal(workspaceJSON, &workspace); err != nil {
        return nil, err
      }
    }
    out = append(out, models.OrphanRepairCandidate{TaskID: taskID, ParentID: parentID, Workspace: workspace})
  }
  return out, rows.Err()
}
DTO and boot payload projectionapps/backend/internal/task/dto/dto.go ↗
func FromTaskWithSessionInfo(task *models.Task, ...) TaskDTO
Click for details →

The backend derives workspace_orphaned at serialization time and carries it through boot and events.

DTO field
type TaskDTO struct {
  ID                          string                   `json:"id"`
  WorkspaceID                 string                   `json:"workspace_id"`
  WorkflowID                  string                   `json:"workflow_id"`
  WorkflowStepID              string                   `json:"workflow_step_id"`
  Title                       string                   `json:"title"`
  Description                 string                   `json:"description"`
  State                       v1.TaskState             `json:"state"`
  Interrupted bool `json:"interrupted,omitempty"`
  AutoStartFailed bool `json:"auto_start_failed,omitempty"`
  WorkspaceOrphaned bool `json:"workspace_orphaned,omitempty"`
  Blocked bool `json:"blocked,omitempty"`
  BlockedReason string `json:"blocked_reason,omitempty"`
}
Derivation and boot map
return TaskDTO{
  ID:                          task.ID,
  WorkspaceID:                 task.WorkspaceID,
  WorkflowID:                  task.WorkflowID,
  WorkflowStepID:              task.WorkflowStepID,
  Title:                       task.Title,
  Description:                 task.Description,
  State:                       task.State,
  Metadata:                    models.PublicTaskMetadata(task.Metadata),
  Interrupted:                 task.Metadata[models.MetaKeyInterruptedAt] != nil,
  AutoStartFailed:             task.Metadata[models.MetaKeyAutoStartFailed] != nil,
  WorkspaceOrphaned:           models.WorkspaceOrphaned(task.Metadata),
}

func mapKanbanTaskState(task taskdto.TaskDTO) map[string]any {
  return map[string]any{
    "id":                          task.ID,
    "workflowStepId":              task.WorkflowStepID,
    "title":                       task.Title,
    "state":                       task.State,
    "interrupted":                 task.Interrupted,
    "autoStartFailed":             task.AutoStartFailed,
    "workspaceOrphaned":           task.WorkspaceOrphaned,
    "statusSummary":               task.StatusSummary,
  }
}
Board icon and live mergeapps/web/lib/ui/state-icons.tsx ↗
func getTaskStateIcon(state?: TaskState, className?: string, options: TaskStateIconOptions)
Click for details →

The kanban card and graph node show a muted folder-off icon when workspace_orphaned is true, with live WS merge.

Icon definition
const TASK_WORKSPACE_ORPHANED_ICON: IconConfig = {
  Icon: IconFolderOff,
  className: STYLE_DISABLED,
};

export function WorkspaceOrphanedTaskIcon({ className }: { className?: string }) {
  const { t } = useTranslation();
  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <span
          aria-label={t("common:workspaceOrphaned")}
          tabIndex={0}
          className="flex shrink-0 rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 focus-visible:ring-offset-1"
        >
          <IconFolderOff
            aria-hidden="true"
            data-testid="task-state-workspace-orphaned"
            className={cn(STYLE_DISABLED, className)}
          />
        </span>
      </TooltipTrigger>
      <TooltipContent side="right">{t("common:workspaceOrphaned")}</TooltipContent>
    </Tooltip>
  );
}
Precedence and card wiring
function getMarkerIconOverride(
  state: TaskState | undefined,
  interrupted: boolean,
  autoStartFailed: boolean,
  workspaceOrphaned: boolean,
): IconConfig | null {
  if (TERMINAL_TASK_STATES.has(state)) return null;
  if (interrupted) return TASK_INTERRUPTED_ICON;
  if (autoStartFailed) return TASK_AUTO_START_FAILED_ICON;
  if (workspaceOrphaned) return TASK_WORKSPACE_ORPHANED_ICON;
  return null;
}

export function renderTaskStatusIcon(
  task: Task,
  showRunningSpinner: boolean,
  hasPendingClarification: boolean,
  hasPendingPermission: boolean,
) {
  const flags: StatusMaskFlags = {
    needsMe:
      shouldUseQuestionTaskIcon(task.state, hasPendingClarification) ||
      shouldUsePermissionTaskIcon(hasPendingPermission),
    showInterrupted: !!task.interrupted,
    showAutoStartFailed: !!task.autoStartFailed,
    parkedOnBackgroundWork: !!task.parkedOnBackgroundWork,
    showWorkspaceOrphaned: !!task.workspaceOrphaned,
  };
  const hasActivity =
    task.foregroundActivity === "generating" || task.foregroundActivity === "background";
  if (hasNoStatusAffordance(showRunningSpinner, hasActivity, flags)) return null;
  const foregroundActivity = resolveForegroundActivity(task, showRunningSpinner, flags);
  return getTaskStateIcon(task.state, "h-4 w-4", {
    hasPendingClarification,
    foregroundActivity,
    hasPendingPermission,
    interrupted: flags.showInterrupted,
    autoStartFailed: flags.showAutoStartFailed,
    parkedOnBackgroundWork: flags.parkedOnBackgroundWork,
    workspaceOrphaned: flags.showWorkspaceOrphaned,
  });
}
WS merge
export function mergeTaskUpdate(
  existing: KanbanTask | undefined,
  nextTask: KanbanTask,
  payload: TaskEventPayload,
): KanbanTask {
  if (!existing) return nextTask;
  const merged = {
    ...nextTask,
    ...mergeTaskRepositoryFields(existing, nextTask),
  };
  preserveOmittedField(existing, merged, payload, nextTask, {
    payloadKey: "workspace_orphaned",
    taskField: "workspaceOrphaned",
  });
  return merged;
}
Read the changes as a list

Single derivation for orphaned state

apps/backend/internal/task/models/workspace_orphan.go

One function decides if a task is orphaned, so DTO, events, and repair agree.

Derivation
func WorkspaceOrphaned(metadata map[string]interface{}) bool {
  ws, ok := metadata[workspaceMetadataKey].(map[string]interface{})
  if !ok {
    return false
  }
  orphaned, _ := ws[WorkspaceOrphanedKey].(bool)
  mode, _ := ws[WorkspaceModeKey].(string)
  return orphaned && mode == WorkspaceModeInheritParent
}
Guard shape
type OrphanWriteGuard struct {
  ExpectedOrphanedParentID string
  ExpectedMode             string
  RequireParentArchivedID   string
  RequireParentUnarchivedID string
  RequireParentID string
  RequireNoOwnEnvironment bool
  RequireTaskNotArchived bool
}

func ObservedWorkspaceGuard(workspace map[string]interface{}) OrphanWriteGuard {
  claim, _ := workspace["orphaned_parent_id"].(string)
  mode, _ := workspace[WorkspaceModeKey].(string)
  return OrphanWriteGuard{ExpectedOrphanedParentID: claim, ExpectedMode: mode}
}

Guarded atomic workspace write

apps/backend/internal/task/repository/sqlite/workspace_orphan_guard.go

The check and the write run in one SQL statement, so concurrent archive and unarchive cannot mix keys.

Guarded UPDATE
func (r *Repository) SetTaskWorkspaceMetadataIfUnchanged(
  ctx context.Context, taskID string,
  guard models.OrphanWriteGuard, value map[string]interface{},
) (bool, error) {
  payload, err := json.Marshal(value)
  if err != nil {
    return false, err
  }
  isPostgres := dialect.IsPostgres(r.db.DriverName())
  var b strings.Builder
  args := make([]interface{}, 0, 12)
  if isPostgres {
    const m = `CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}'::jsonb ELSE metadata::jsonb END`
    b.WriteString(`UPDATE tasks SET metadata = jsonb_set(` + m + `, ARRAY['workspace'], ?::jsonb, true)::text, updated_at = ? WHERE id = ?`)
    args = append(args, string(payload), time.Now().UTC(), taskID)
    b.WriteString(` AND COALESCE(CASE WHEN jsonb_typeof(` + m + ` -> 'workspace' -> 'orphaned_parent_id') = 'string' THEN ` + m + ` #>> ARRAY['workspace','orphaned_parent_id'] END, '') = ?`)
    args = append(args, guard.ExpectedOrphanedParentID)
    b.WriteString(` AND COALESCE(CASE WHEN jsonb_typeof(` + m + ` -> 'workspace' -> 'mode') = 'string' THEN ` + m + ` #>> ARRAY['workspace','mode'] END, '') = ?`)
    args = append(args, guard.ExpectedMode)
  } else {
    b.WriteString(`UPDATE tasks SET metadata = json_set(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}' ELSE metadata END, '$.workspace', json(?)), updated_at = ? WHERE id = ?`)
    args = append(args, string(payload), time.Now().UTC(), taskID)
    b.WriteString(` AND COALESCE(CASE WHEN json_valid(metadata) AND json_type(metadata,'$.workspace.orphaned_parent_id') = 'text' THEN json_extract(metadata,'$.workspace.orphaned_parent_id') END,'') = ?`)
    args = append(args, guard.ExpectedOrphanedParentID)
    b.WriteString(` AND COALESCE(CASE WHEN json_valid(metadata) AND json_type(metadata,'$.workspace.mode') = 'text' THEN json_extract(metadata,'$.workspace.mode') END,'') = ?`)
    args = append(args, guard.ExpectedMode)
  }
  if guard.RequireParentID != "" {
    b.WriteString(` AND parent_id = ?`)
    args = append(args, guard.RequireParentID)
  }
  if guard.RequireParentArchivedID != "" {
    b.WriteString(` AND EXISTS (SELECT 1 FROM tasks p WHERE p.id = ? AND p.archived_at IS NOT NULL AND p.workspace_id = (SELECT workspace_id FROM tasks t WHERE t.id = ?))`)
    args = append(args, guard.RequireParentArchivedID, taskID)
  }
  if guard.RequireParentUnarchivedID != "" {
    b.WriteString(` AND EXISTS (SELECT 1 FROM tasks p WHERE p.id = ? AND p.archived_at IS NULL AND p.workspace_id = (SELECT workspace_id FROM tasks t WHERE t.id = ?))`)
    args = append(args, guard.RequireParentUnarchivedID, taskID)
  }
  if guard.RequireNoOwnEnvironment {
    b.WriteString(` AND NOT EXISTS (SELECT 1 FROM task_environments e WHERE e.task_id = ?)`)
    args = append(args, taskID)
  }
  if guard.RequireTaskNotArchived {
    b.WriteString(` AND archived_at IS NULL`)
  }
  result, err := r.db.ExecContext(ctx, r.db.Rebind(b.String()), args...)
  if err != nil {
    return false, err
  }
  rows, err := result.RowsAffected()
  if err != nil {
    return false, err
  }
  return rows > 0, nil
}

Stamp and clear on archive and unarchive

apps/backend/internal/task/service/handoff_workspace_orphan.go

Archive marks direct inherit_parent children without their own environment. Unarchive clears only the matching parent claim.

Stamp helper
func stampOrphanedWorkspaceMetadata(workspace map[string]interface{}, parentID string) {
  workspace[orphanedWorkspaceKey] = true
  workspace[orphanedReasonWorkspaceKey] = orphanedReasonParentArchived
  workspace[orphanedParentIDKey] = parentID
  workspace[orphanedAtKey] = time.Now().UTC().Format(time.RFC3339)
}

func clearOrphanedWorkspaceMetadata(workspace map[string]interface{}) bool {
  changed := false
  for _, key := range []string{orphanedWorkspaceKey, orphanedReasonWorkspaceKey, orphanedParentIDKey, orphanedAtKey} {
    if _, ok := workspace[key]; ok {
      delete(workspace, key)
      changed = true
    }
  }
  return changed
}
Mark one child
func (s *HandoffService) markOrphanedInheritParentChild(
  ctx context.Context,
  envRepo workspaceEnvironmentRepository,
  archived, child *models.Task,
) {
  if child == nil || taskWorkspaceMode(child.Metadata) != workspaceModeInheritParent {
    return
  }
  if envRepo != nil {
    ownEnv, err := envRepo.GetTaskEnvironmentByTaskID(ctx, child.ID)
    if err != nil {
      s.logf().Warn("check child task environment for orphan marking failed",
        zap.String("task_id", child.ID), zap.String("parent_task_id", archived.ID), zap.Error(err))
      return
    }
    if ownEnv != nil {
      return
    }
  }
  workspace, _ := child.Metadata["workspace"].(map[string]interface{})
  guard := models.ObservedWorkspaceGuard(workspace)
  guard.RequireParentArchivedID = archived.ID
  guard.RequireParentID = archived.ID
  guard.RequireTaskNotArchived = true
  stampOrphanedWorkspaceMetadata(workspace, archived.ID)
  landed, err := s.updateWorkspaceMetadata(ctx, child, guard)
  if err != nil {
    s.logf().Warn("mark orphaned inherit_parent child failed",
      zap.String("task_id", child.ID), zap.String("parent_task_id", archived.ID), zap.Error(err))
    return
  }
  if !landed {
    s.logf().Debug("mark orphaned inherit_parent child lost its guard",
      zap.String("task_id", child.ID), zap.String("parent_task_id", archived.ID))
    return
  }
  if s.eventPublisher != nil {
    s.eventPublisher.PublishTaskUpdated(ctx, child)
  }
  s.logf().Info("marked inherit_parent child orphaned by parent archive",
    zap.String("task_id", child.ID), zap.String("parent_task_id", archived.ID))
}

Startup repair for historical tasks

apps/backend/internal/task/service/handoff_workspace_orphan_repair.go

Every boot stamps unmarked orphans and clears stale claims, so old boards converge without a reload.

Repair entry
func (s *HandoffService) RepairOrphanedWorkspaceMarkers(ctx context.Context) {
  repo, ok := s.tasks.(workspaceOrphanRepairRepository)
  if !ok {
    s.logf().Info("task repository does not support the orphan marker repair; skipping")
    return
  }
  if count, err := repo.CountMalformedTaskMetadata(ctx); err != nil {
    s.logf().Warn("count malformed task metadata for orphan marker repair failed", zap.Error(err))
  } else if count > 0 {
    s.logf().Warn("tasks with malformed metadata excluded from orphan marker repair",
      zap.Int("count", count))
  }
  stamped := s.repairStampOrphanMarkers(ctx, repo)
  cleared := s.repairClearStaleOrphanMarkers(ctx, repo)
  s.logf().Info("orphan marker repair pass complete",
    zap.Int("stamped", stamped), zap.Int("cleared", cleared))
}
SQL selection
func (r *Repository) ListOrphanRepairCandidates(ctx context.Context) ([]models.OrphanRepairCandidate, error) {
  query := `
    SELECT c.id, p.id AS parent_id,
           json_extract(c.metadata,'$.workspace') AS workspace
    FROM tasks c JOIN tasks p ON c.parent_id = p.id
    WHERE c.archived_at IS NULL AND p.archived_at IS NOT NULL
      AND c.is_ephemeral = 0
      AND COALESCE(c.origin,'') != 'automation_run'
      AND json_valid(c.metadata)
      AND json_extract(c.metadata,'$.workspace.mode') = 'inherit_parent'
      AND json_type(c.metadata,'$.workspace.orphaned') IS NOT 'true'
      AND NOT EXISTS (SELECT 1 FROM task_environments e WHERE e.task_id = c.id)
    ORDER BY c.created_at, c.id
  `
  rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query))
  if err != nil {
    return nil, err
  }
  defer func() { _ = rows.Close() }()
  var out []models.OrphanRepairCandidate
  for rows.Next() {
    var taskID, parentID string
    var workspaceJSON []byte
    if err := rows.Scan(&taskID, &parentID, &workspaceJSON); err != nil {
      return nil, err
    }
    var workspace map[string]interface{}
    if len(workspaceJSON) > 0 {
      if err := json.Unmarshal(workspaceJSON, &workspace); err != nil {
        return nil, err
      }
    }
    out = append(out, models.OrphanRepairCandidate{TaskID: taskID, ParentID: parentID, Workspace: workspace})
  }
  return out, rows.Err()
}

DTO and boot payload projection

apps/backend/internal/task/dto/dto.go

The backend derives workspace_orphaned at serialization time and carries it through boot and events.

DTO field
type TaskDTO struct {
  ID                          string                   `json:"id"`
  WorkspaceID                 string                   `json:"workspace_id"`
  WorkflowID                  string                   `json:"workflow_id"`
  WorkflowStepID              string                   `json:"workflow_step_id"`
  Title                       string                   `json:"title"`
  Description                 string                   `json:"description"`
  State                       v1.TaskState             `json:"state"`
  Interrupted bool `json:"interrupted,omitempty"`
  AutoStartFailed bool `json:"auto_start_failed,omitempty"`
  WorkspaceOrphaned bool `json:"workspace_orphaned,omitempty"`
  Blocked bool `json:"blocked,omitempty"`
  BlockedReason string `json:"blocked_reason,omitempty"`
}
Derivation and boot map
return TaskDTO{
  ID:                          task.ID,
  WorkspaceID:                 task.WorkspaceID,
  WorkflowID:                  task.WorkflowID,
  WorkflowStepID:              task.WorkflowStepID,
  Title:                       task.Title,
  Description:                 task.Description,
  State:                       task.State,
  Metadata:                    models.PublicTaskMetadata(task.Metadata),
  Interrupted:                 task.Metadata[models.MetaKeyInterruptedAt] != nil,
  AutoStartFailed:             task.Metadata[models.MetaKeyAutoStartFailed] != nil,
  WorkspaceOrphaned:           models.WorkspaceOrphaned(task.Metadata),
}

func mapKanbanTaskState(task taskdto.TaskDTO) map[string]any {
  return map[string]any{
    "id":                          task.ID,
    "workflowStepId":              task.WorkflowStepID,
    "title":                       task.Title,
    "state":                       task.State,
    "interrupted":                 task.Interrupted,
    "autoStartFailed":             task.AutoStartFailed,
    "workspaceOrphaned":           task.WorkspaceOrphaned,
    "statusSummary":               task.StatusSummary,
  }
}

Board icon and live merge

apps/web/lib/ui/state-icons.tsx

The kanban card and graph node show a muted folder-off icon when workspace_orphaned is true, with live WS merge.

Icon definition
const TASK_WORKSPACE_ORPHANED_ICON: IconConfig = {
  Icon: IconFolderOff,
  className: STYLE_DISABLED,
};

export function WorkspaceOrphanedTaskIcon({ className }: { className?: string }) {
  const { t } = useTranslation();
  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <span
          aria-label={t("common:workspaceOrphaned")}
          tabIndex={0}
          className="flex shrink-0 rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 focus-visible:ring-offset-1"
        >
          <IconFolderOff
            aria-hidden="true"
            data-testid="task-state-workspace-orphaned"
            className={cn(STYLE_DISABLED, className)}
          />
        </span>
      </TooltipTrigger>
      <TooltipContent side="right">{t("common:workspaceOrphaned")}</TooltipContent>
    </Tooltip>
  );
}
Precedence and card wiring
function getMarkerIconOverride(
  state: TaskState | undefined,
  interrupted: boolean,
  autoStartFailed: boolean,
  workspaceOrphaned: boolean,
): IconConfig | null {
  if (TERMINAL_TASK_STATES.has(state)) return null;
  if (interrupted) return TASK_INTERRUPTED_ICON;
  if (autoStartFailed) return TASK_AUTO_START_FAILED_ICON;
  if (workspaceOrphaned) return TASK_WORKSPACE_ORPHANED_ICON;
  return null;
}

export function renderTaskStatusIcon(
  task: Task,
  showRunningSpinner: boolean,
  hasPendingClarification: boolean,
  hasPendingPermission: boolean,
) {
  const flags: StatusMaskFlags = {
    needsMe:
      shouldUseQuestionTaskIcon(task.state, hasPendingClarification) ||
      shouldUsePermissionTaskIcon(hasPendingPermission),
    showInterrupted: !!task.interrupted,
    showAutoStartFailed: !!task.autoStartFailed,
    parkedOnBackgroundWork: !!task.parkedOnBackgroundWork,
    showWorkspaceOrphaned: !!task.workspaceOrphaned,
  };
  const hasActivity =
    task.foregroundActivity === "generating" || task.foregroundActivity === "background";
  if (hasNoStatusAffordance(showRunningSpinner, hasActivity, flags)) return null;
  const foregroundActivity = resolveForegroundActivity(task, showRunningSpinner, flags);
  return getTaskStateIcon(task.state, "h-4 w-4", {
    hasPendingClarification,
    foregroundActivity,
    hasPendingPermission,
    interrupted: flags.showInterrupted,
    autoStartFailed: flags.showAutoStartFailed,
    parkedOnBackgroundWork: flags.parkedOnBackgroundWork,
    workspaceOrphaned: flags.showWorkspaceOrphaned,
  });
}
WS merge
export function mergeTaskUpdate(
  existing: KanbanTask | undefined,
  nextTask: KanbanTask,
  payload: TaskEventPayload,
): KanbanTask {
  if (!existing) return nextTask;
  const merged = {
    ...nextTask,
    ...mergeTaskRepositoryFields(existing, nextTask),
  };
  preserveOmittedField(existing, merged, payload, nextTask, {
    payloadKey: "workspace_orphaned",
    taskField: "workspaceOrphaned",
  });
  return merged;
}

Data and storage

The marker lives in tasks.metadata.workspace. The boolean is derived, not stored.

FieldTypeNotes
workspace.modestringinherit_parent, new_workspace, or shared_group
workspace.orphanedbooleantrue only when mode is inherit_parent
workspace.orphaned_reasonstringparent_archived
workspace.orphaned_parent_idstringarchived parent task id
workspace.orphaned_atstringRFC3339 UTC timestamp
workspace_orphanedbooleanderived DTO field, omitempty on HTTP, explicit on events

Risk

5 / 10 Medium
1 low5 medium10 high

Why this score

  • Guarded SQL touches every archive and unarchive, with workspace_id correlation to block cross-workspace claims.
  • Startup repair scans tasks on every boot and publishes task.updated, but it is idempotent and warn-and-continue.
  • Board change is visual only and keeps blocked and dependency chips, so no launch gate changes.

Trade-offs and review notes

Where to look first

  1. Check WorkspaceOrphaned derivation matches the SQL predicate in ListOrphanRepairCandidates and ListStaleOrphanMarkers.
  2. Verify SetTaskWorkspaceMetadataIfUnchanged always compares ExpectedOrphanedParentID and ExpectedMode, even when empty.
  3. Confirm mark path skips children with their own task_environments row and clear path checks orphaned_parent_id.
  4. Review startup repair re-reads after write and publishes task.updated, and handles malformed metadata with one warning.