FI
ForgeOrchestrator

CompletionSignal

A one-shot async bridge. Actions create one inside execute() and await it; external code (a view, a callback) calls complete() to resume evaluation.

Class Definition

CompletionSignal is @unchecked Sendable rather than plain Sendable because it holds a mutable CheckedContinuation that is guarded by a LockedState lock — a pattern the compiler cannot verify statically but that is safe at runtime.

CompletionSignal.swift
public final class CompletionSignal: @unchecked Sendable {
    /// Suspend the caller until complete() is called.
    /// Returns immediately if complete() was already called.
    ///
    /// Single-waiter only — calling wait() concurrently from multiple
    /// callers triggers a precondition failure at runtime.
    public func wait() async

    /// Resume the suspended caller. Safe to call from any thread.
    /// Subsequent calls after the first are no-ops.
    public func complete()
}

wait() and complete()

Create the signal inside execute(), hand it off to state or a view, then await signal.wait(). The view calls signal.complete() from any context to resume the orchestrator.

// Inside execute() — create the signal, pass it to UI, then await it
func execute() async {
    let signal = CompletionSignal()
    await MainActor.run {
        TaskFlowState.shared.onboardingSignal = signal
        TaskFlowState.shared.showOnboarding = true
    }
    await signal.wait()
}

// In a SwiftUI view — resume evaluation when the user taps Continue
Button("Continue") {
    signal.complete()
}

Thread Safety

complete() is safe to call from any thread, queue, or Swift actor. LockedState guards the internal continuation and the completion flag, making every read-modify-write atomic.

// complete() is safe to call from any thread or actor context.
// CompletionSignal uses LockedState internally to guard mutable state,
// which is why it is @unchecked Sendable rather than plain Sendable.
Task.detached {
    await performBackgroundWork()
    signal.complete()  // resumes the awaiting evaluate() call
}

Single-Waiter Contract

Each CompletionSignal supports exactly one concurrent waiter. Calling wait() from two places at the same time triggers a precondition failure at runtime. The orchestrator creates a fresh signal per execute() call, so this contract is always satisfied in normal use.

// CompletionSignal supports exactly one concurrent waiter.
// Calling wait() from two places at the same time triggers a precondition:
//   "CompletionSignal.wait() called concurrently — only one waiter is supported"
//
// The orchestrator calls execute() once per action and creates a fresh
// CompletionSignal each time, so this contract is always satisfied in normal use.
// Only violate it by manually sharing a signal across multiple await call sites.

Bridging Async Evaluation with UI

The most common pattern: create the signal in execute(), pass it to an observable state object, present the UI, and let the UI call complete() when the user finishes. Evaluation is suspended for the duration.

import SwiftUI
import ForgeOrchestrator

struct OnboardingAction: SequenceAction {
    let id: ActionID = "onboarding"
    let priority: ActionPriority = .high

    func shouldRun() async -> Bool {
        !UserDefaults.standard.bool(forKey: "onboardingComplete")
    }

    func execute() async {
        let signal = CompletionSignal()
        await MainActor.run {
            TaskFlowState.shared.onboardingSignal = signal
            TaskFlowState.shared.showOnboarding = true
        }
        // Suspend here — resumes when OnboardingView calls signal.complete()
        await signal.wait()
    }
}

// In the onboarding view:
struct OnboardingView: View {
    @Environment(TaskFlowState.self) var state

    var body: some View {
        VStack {
            // ... onboarding content ...
            Button("Get Started") {
                UserDefaults.standard.set(true, forKey: "onboardingComplete")
                state.showOnboarding = false
                state.onboardingSignal?.complete()
            }
        }
    }
}

Fire-and-Forget Actions

For actions that perform background work with no UI gate, skip CompletionSignal entirely. Just return from execute() when the work is done — the orchestrator moves on immediately.

// For actions that don't need to gate evaluation on UI,
// just return from execute() when the work is done — no signal needed.
func execute() async {
    Analytics.shared.initialize()
    await RemoteConfig.shared.prefetch()
    // Returning from execute() automatically unblocks the orchestrator.
}