AgentStack
SKILL verified MIT Self-run

General Temporal

skill-kid-sid-claude-spellbook-general-temporal · by kid-sid

Use when building or debugging standalone Temporal workers in Python outside of Agentex — structuring workflows and activities, enforcing determinism, handling retries and timeouts, managing state across replays, or diagnosing workflow failures. For Temporal-based Agentex agents, use temporal.

No reviews yet
0 installs
14 views
0.0% view→install

Install

$ agentstack add skill-kid-sid-claude-spellbook-general-temporal

✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.

Security review

✓ Passed

No issues found. Passed automated security review. · v0.1.0 How review works →

  • Prompt-injection patterns
  • Secret / credential exfiltration
  • Dangerous shell & filesystem operations
  • Untrusted network calls
  • Known-malicious package signatures

What it can access

  • Network access Used
  • Filesystem access Used
  • Shell / process execution No
  • Environment & secrets No
  • Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

Are you the author of General Temporal? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

About

Temporal Workflows — Python Patterns

Temporal is a durable execution engine. Every workflow step is recorded as an immutable event; if the worker crashes, Temporal replays history to resume exactly where it left off.

When to Activate

  • Structuring a new Temporal workflow and its activities
  • Debugging non-determinism errors, replay failures, or signal issues
  • Adding retries, timeouts, or error handling to activities
  • Managing state across workflow turns without losing it on crash
  • Implementing human-in-the-loop or long-running multi-step pipelines
  • Writing or wiring a Temporal worker

Core Concepts

Event Sourcing / Replay

Temporal records every decision as an immutable event before executing it:

Event 1: WorkflowStarted
Event 2: ActivityScheduled  (fetch_data, url)
Event 3: ActivityCompleted  (fetch_data, url) → "result"
Event 4: SignalReceived      (approve)
Event 5: ActivityScheduled  (process_data, ...)
          ← worker crashes here

On restart, Temporal replays events 1–4. For completed activities it returns the recorded result — no real I/O. Execution resumes at Event 5 for real.

Determinism rule: Workflow code must produce the same decisions on every replay.

  • ✅ Call activities for all I/O, random values, current time
  • ❌ Never use random, time.time(), datetime.now(), httpx, or file reads in workflow code
  • ❌ Never import I/O libraries at module level in workflow files

Workflow vs Activity

| | Workflow | Activity | |---|---|---| | Purpose | Orchestration, decisions, state | Real I/O — HTTP, DB, LLM, file | | I/O allowed | ❌ Must be deterministic | ✅ Unrestricted | | Retried by Temporal | Workflow tasks retry on exception | Yes, via RetryPolicy | | Current time | workflow.now() only | datetime.now() fine |


Minimal Workflow

# workflow.py
from datetime import timedelta
from temporalio import workflow
from temporalio.common import RetryPolicy

from activities import fetch_data, process_data  # imported for type reference only

@workflow.defn
class MyWorkflow:

    @workflow.run
    async def run(self, url: str) -> str:
        # All I/O goes through execute_activity — never call directly
        raw = await workflow.execute_activity(
            fetch_data,
            url,
            start_to_close_timeout=timedelta(minutes=2),
            retry_policy=RetryPolicy(maximum_attempts=3),
        )

        result = await workflow.execute_activity(
            process_data,
            raw,
            start_to_close_timeout=timedelta(minutes=5),
        )

        return result

Activities

Activities are the only place with real I/O. Keep them focused — one network call or DB operation per activity.

# activities.py
import httpx
from temporalio import activity

@activity.defn
async def fetch_data(url: str) -> str:
    async with httpx.AsyncClient(timeout=30) as client:
        response = await client.get(url)
        response.raise_for_status()   # non-2xx → exception → Temporal retries
    return response.text

@activity.defn
async def process_data(raw: str) -> str:
    # CPU-bound or DB work here
    return raw.strip().upper()

Activity design rules:

  • Accept and return JSON-serializable types (str, int, dict, list, Pydantic models)
  • Raise exceptions freely — Temporal catches and retries per RetryPolicy
  • Make activities idempotent — they may run more than once on retry
  • Keep activities short — long-running ones need heartbeats

Worker

# run_worker.py
import asyncio
from temporalio.client import Client
from temporalio.worker import Worker

from workflow import MyWorkflow
from activities import fetch_data, process_data

async def main():
    client = await Client.connect("localhost:7233")

    worker = Worker(
        client,
        task_queue="my-task-queue",
        workflows=[MyWorkflow],
        activities=[fetch_data, process_data],
    )

    print("Worker started")
    await worker.run()

if __name__ == "__main__":
    asyncio.run(main())

Starting a Workflow

# client.py
import asyncio
from temporalio.client import Client
from workflow import MyWorkflow

async def main():
    client = await Client.connect("localhost:7233")

    # Start and wait for result
    result = await client.execute_workflow(
        MyWorkflow.run,
        "https://example.com/data",
        id="my-workflow-id-001",      # unique per workflow instance
        task_queue="my-task-queue",
    )
    print(result)

    # Start without waiting (fire and forget)
    handle = await client.start_workflow(
        MyWorkflow.run,
        "https://example.com/data",
        id="my-workflow-id-002",
        task_queue="my-task-queue",
    )
    # Later: result = await handle.result()

asyncio.run(main())

Retries and Timeouts

from datetime import timedelta
from temporalio.common import RetryPolicy

# Full retry config
result = await workflow.execute_activity(
    fetch_data,
    url,
    # How long one attempt can run
    start_to_close_timeout=timedelta(minutes=2),

    # How long all attempts combined can run
    schedule_to_close_timeout=timedelta(minutes=10),

    retry_policy=RetryPolicy(
        initial_interval=timedelta(seconds=1),   # first retry after 1s
        backoff_coefficient=2.0,                  # doubles each retry
        maximum_interval=timedelta(seconds=30),   # cap at 30s
        maximum_attempts=5,                       # 5 total attempts, then raise
        non_retryable_error_types=["ValueError"], # don't retry these
    ),
)

| Timeout | Scope | Use for | |---|---|---| | start_to_close_timeout | Single attempt | Normal activity duration limit | | schedule_to_close_timeout | All attempts | Hard deadline across all retries | | schedule_to_start_timeout | Queue wait time | Detect stuck workers |


Signals and Queries

@workflow.defn
class ApprovalWorkflow:

    def __init__(self):
        self._approved = False
        self._status = "pending"

    @workflow.run
    async def run(self, item_id: str) -> str:
        # Block until approved (or timeout)
        await workflow.wait_condition(
            lambda: self._approved,
            timeout=timedelta(hours=24),   # give up after 24h
        )
        return await workflow.execute_activity(
            process_item, item_id,
            start_to_close_timeout=timedelta(minutes=5),
        )

    @workflow.signal
    async def approve(self) -> None:
        self._approved = True
        self._status = "approved"

    @workflow.signal
    async def reject(self, reason: str) -> None:
        self._status = f"rejected: {reason}"
        raise Exception(f"Rejected: {reason}")

    @workflow.query
    def status(self) -> str:
        return self._status

# Send a signal from a client
handle = client.get_workflow_handle("approval-workflow-id")
await handle.signal(ApprovalWorkflow.approve)

# Query current state without interrupting
status = await handle.query(ApprovalWorkflow.status)

State Management

Workflows are stateful by design — instance variables persist across signals and replay.

@workflow.defn
class BatchWorkflow:

    def __init__(self):
        self._results: list[str] = []
        self._errors: list[str] = []

    @workflow.run
    async def run(self, urls: list[str]) -> dict:
        for url in urls:
            try:
                result = await workflow.execute_activity(
                    fetch_data, url,
                    start_to_close_timeout=timedelta(minutes=2),
                    retry_policy=RetryPolicy(maximum_attempts=2),
                )
                self._results.append(result)
            except Exception as e:
                self._errors.append(f"{url}: {e}")

        return {"results": self._results, "errors": self._errors}

For state that must survive worker replacement (long-running workflows across deployments), persist it in an external store (Postgres, Redis) via an activity and reload it on startup.

@workflow.run
async def run(self, workflow_id: str) -> str:
    # Load persisted state at the start of each run
    state = await workflow.execute_activity(
        load_state, workflow_id,
        start_to_close_timeout=timedelta(seconds=10),
    )
    # ... do work, update state via save_state activity ...

Long-Running Activities (Heartbeats)

Activities that take longer than start_to_close_timeout must send heartbeats — otherwise Temporal assumes the worker is dead and retries.

@activity.defn
async def process_large_file(file_path: str) -> str:
    lines = open(file_path).readlines()
    results = []

    for i, line in enumerate(lines):
        result = expensive_operation(line)
        results.append(result)

        # Heartbeat every 100 lines — keeps the activity alive
        if i % 100 == 0:
            activity.heartbeat(f"processed {i}/{len(lines)} lines")

    return "\n".join(results)

# In workflow — set heartbeat_timeout shorter than start_to_close_timeout
await workflow.execute_activity(
    process_large_file,
    file_path,
    start_to_close_timeout=timedelta(hours=1),
    heartbeat_timeout=timedelta(seconds=30),   # fail if no heartbeat in 30s
)

Child Workflows

from temporalio.workflow import ChildWorkflowHandle

@workflow.defn
class ParentWorkflow:

    @workflow.run
    async def run(self, items: list[str]) -> list[str]:
        # Launch child workflows concurrently
        handles: list[ChildWorkflowHandle] = []
        for item in items:
            handle = await workflow.start_child_workflow(
                ChildWorkflow.run,
                item,
                id=f"child-{item}",
                task_queue="my-task-queue",
            )
            handles.append(handle)

        # Wait for all to complete
        return list(await asyncio.gather(*[h.result() for h in handles]))

Testing

# test_workflow.py
import pytest
from temporalio.testing import WorkflowEnvironment
from temporalio.worker import Worker

from workflow import MyWorkflow
from activities import fetch_data, process_data

@pytest.mark.asyncio
async def test_my_workflow():
    async with await WorkflowEnvironment.start_time_skipping() as env:
        async with Worker(
            env.client,
            task_queue="test-queue",
            workflows=[MyWorkflow],
            activities=[fetch_data, process_data],
        ):
            result = await env.client.execute_workflow(
                MyWorkflow.run,
                "https://example.com",
                id="test-workflow-1",
                task_queue="test-queue",
            )
            assert result == "EXPECTED OUTPUT"

# Mock activities for unit testing the workflow logic
from unittest.mock import AsyncMock

@pytest.mark.asyncio
async def test_workflow_with_mocked_activities():
    async with await WorkflowEnvironment.start_time_skipping() as env:
        mock_fetch = AsyncMock(return_value="raw data")
        mock_process = AsyncMock(return_value="processed")

        async with Worker(
            env.client,
            task_queue="test-queue",
            workflows=[MyWorkflow],
            activities=[mock_fetch, mock_process],
        ):
            result = await env.client.execute_workflow(
                MyWorkflow.run, "https://example.com",
                id="test-2", task_queue="test-queue",
            )
            assert result == "processed"

Common Errors

| Error | Cause | Fix | |---|---|---| | workflow.NondeterminismError | Workflow code changed after workflows started | Never change the order/type of execute_activity calls; version with workflow.patched() | | ActivityError / ApplicationError | Activity raised after exhausting retries | Catch in workflow, notify user, continue or abort | | Signal dropped | Workflow already completed when signal arrived | Send signals before the workflow finishes, or use update instead of signal | | schedule_to_start_timeout exceeded | No workers polling the task queue | Start a worker on the same task queue | | Activity runs twice | Worker crashed after activity completed but before Temporal recorded it | Make activities idempotent |


Versioning (Safe Code Changes)

# Use workflow.patched() to change workflow logic without breaking running workflows
@workflow.run
async def run(self, url: str) -> str:
    if workflow.patched("use-v2-processor"):
        # New code path — for workflows started after this deploy
        result = await workflow.execute_activity(
            process_data_v2, url,
            start_to_close_timeout=timedelta(minutes=5),
        )
    else:
        # Old code path — for workflows already in flight
        result = await workflow.execute_activity(
            process_data, url,
            start_to_close_timeout=timedelta(minutes=5),
        )
    return result

Once all pre-patch workflows complete, remove the else branch and the patched() call.


Red Flags

  • I/O directly in workflow codehttpx, database queries, or open() calls in a workflow function break determinism; on replay the call fires again and may return a different result, causing NondeterminismError; all I/O must live in activities
  • random, time.time(), or datetime.now() in a workflow — these return different values on every replay; use workflow.now() for timestamps and route all randomness through activity return values
  • Activities that are not idempotent — Temporal may run an activity more than once (crash between execution and recording); an activity that charges a card or sends an email twice on retry is dangerous; use idempotency keys or check-before-act patterns
  • Missing start_to_close_timeout — omitting a timeout lets a hung activity block the workflow forever; always set both start_to_close_timeout and a RetryPolicy
  • Long-running activities without heartbeats — Temporal assumes a silent activity is dead after heartbeat_timeout; any activity that runs longer than a few minutes must call activity.heartbeat() periodically
  • Changing activity call order after workflows are in flight — adding, removing, or reordering execute_activity calls in a running workflow causes NondeterminismError on replay; use workflow.patched() to safely introduce new code paths
  • Using asyncio.create_task inside a workflow — spawning raw tasks in workflow code bypasses Temporal's scheduler and breaks determinism; use child workflows or signals for concurrent branching

Checklist

  • [ ] All HTTP, DB, and I/O calls are in activities — zero I/O in workflow functions
  • [ ] No random, time.time(), datetime.now(), or I/O imports at module level in workflow files
  • [ ] Every execute_activity call has start_to_close_timeout and RetryPolicy
  • [ ] Activities are idempotent — safe to run more than once
  • [ ] Long-running activities call activity.heartbeat() and have heartbeat_timeout set
  • [ ] workflow.execute_activity wrapped in try/except to handle exhausted retries gracefully
  • [ ] Workflow ID is unique and deterministic per business entity (e.g. f"order-{order_id}")
  • [ ] Code changes to running workflows use workflow.patched() for safe versioning
  • [ ] Tests use WorkflowEnvironment.start_time_skipping() to run timers instantly
  • [ ] Worker registers all activity functions and workflow classes on the correct task queue

Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet — be the first.

Versions

  • v0.1.0 Imported from the upstream source.