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;
}