COURSE APPENDIX

A5 — The toolbox: patterns and edge cases the modules skipped

Eleven modules follow one program, AgentRun, through one storyline. That is why they work as a course and also why they have holes: a handful of things a working Temporal engineer is expected to recognise never came up, or went past in a single clause. This page is the toolbox. Each entry says when you reach for the tool and names the edge case someone will probe — in a design review, in an incident, or across a table.

1. Workflow ID vs Run ID, and what happens when you start agent-42 twice

Module 1 froze the distinction — a Workflow ID is "unique to an Open Workflow Execution within a Namespace", a Run ID is "a globally unique, platform-level identifier for a Workflow Execution" (upstream's words) — and module 6 showed many Run IDs under agent-42. What the modules never asked: what does the Service do when a second client starts agent-42? Two policies answer, and confusing them is the probe.

Two questions, two policies, and the clean way to hold them is by which one the previous execution is in:

Workflow ID Reuse Policy governs a Closed previous execution: Allow Duplicate (the default), Allow Duplicate Failed Only, Reject Duplicate. Upstream is explicit that "it is not possible for a new Workflow Execution to spawn with the same Workflow Id as another Open Workflow Execution, regardless of the Workflow Id Reuse Policy" — these are checked only against Closed executions still inside the Retention Period, 30 days by default, so Reject Duplicate is not a permanent lock but a 30-day one.

Workflow ID Conflict Policy governs the Open case: Fail (the default, returning a Workflow execution already started error), Use Existing (returns the running execution's Run ID), Terminate Existing.

One enum member breaks that symmetry, and you will meet it in older code: WorkflowIDReusePolicy.TERMINATE_IF_RUNNING sits in the reuse family but acts on an open execution. The SDK marks it deprecated in as many words — "Instead, set WorkflowIdReusePolicy to ALLOW_DUPLICATE and WorkflowIdConflictPolicy to TERMINATE_EXISTING" — and it may not be combined with a conflict policy at all, which must be left unspecified when it is used (temporalio 1.32.0; conflict policy needs Server 1.24.0 or later). Read it as legacy spelling for the pair, not as a fourth reuse policy.

For "one agent per user": id=f"agent-{user_id}", id_conflict_policy=WorkflowIDConflictPolicy.USE_EXISTING so a second "start my agent" click attaches to the live run, and id_reuse_policy=WorkflowIDReusePolicy.ALLOW_DUPLICATE so yesterday's finished run does not block today's (names verified against temporalio 1.32.0, as of 2026-09; see upstream page). The edge case: neither policy makes starting idempotent across a retention boundary.

2. Parent Close Policy, and start versus execute for children

Module 6 gave the three values — Abandon (the child "is not affected"), Request Cancel, Terminate (the default) — and Lab 6's stretch sets ABANDON. The default is the one that bites: a parent that finishes, fails, times out, or continues-as-new terminates every child it did not await, and children never carry over across continue-as-new.

The edge case is the asynchronous child. execute_child_workflow() is "a helper function for start_child_workflow() plus await handle"; you reach for the two-step form when you want the handle — to Signal a ResearchAgent, or to run several in parallel. Underneath it is a hard requirement: "The ChildWorkflowExecutionStarted Event must be logged to the Event History before the Parent Workflow completes to ensure the Child Workflow has started." In Python, awaiting either call internally waits for that Event. The trap upstream names explicitly: if you start a child from a non-main coroutine — a pause Signal handler, a change_goal Update handler — make sure AgentRun does not complete before that call resolves. A child started in a handler the main method races past may never exist, with no error anywhere.

Second probe: an ABANDON child outlives its parent and is unreachable through it, so if the parent never needs the result it is a sibling, not a child — start it from an Activity with the Client.

3. Signal-With-Start: signal it, creating it if absent

Module 5 sends inject_context and pause to a running AgentRun. Signal-With-Start is the version for when you do not know whether it is running: "Temporal's Signal with Start API atomically starts a Workflow (if not running) and delivers a Signal in a single operation."

In Python it is not a separate method but the start call with a signal attached:

await client.start_workflow(
    AgentRun.run, AgentInput(goal=goal),
    id=f"agent-{user_id}", task_queue="agent-runs",
    start_signal="inject_context",
    start_signal_args=[InjectContext(text=note, command_id=command_id)],
)

Reach for it when the Workflow is an entity rather than a job: a per-user agent that should exist only once there is something to say to it, or a queue consumer bridging events into AgentRun. Upstream recommends ALLOW_DUPLICATE_FAILED_ONLY as the reuse policy for entity Workflows.

This is the concrete reason module 5's one line about @workflow.init matters. Handlers run before the first execution of the main method, and Signal-With-Start is the first scenario upstream lists for that: if AgentRun.__init__ has not built self.processed_command_ids, the very Signal that created the run reads an uninitialised attribute. @workflow.init gives __init__ the same arguments as @workflow.run, so state derived from the goal exists before any handler runs. You still cannot block in the constructor; if a handler must wait for setup, have it wait on a flag.

One edge case beyond module 5's dedupe rule: a Signal arriving while the Workflow is completing via continue-as-new is not lost — upstream says "the Workflow rewinds to process the Signal first".

4. Local Activities, and when not to use one

A Local Activity "is an Activity Execution that executes in the same Worker process as the Workflow Execution that schedules it" — no Activity Task Queue, no Service round trip, one MarkerRecorded Event on completion instead of three. In Python: workflow.execute_local_activity(fn, arg, schedule_to_close_timeout=...).

Upstream's framing is the one to carry: Local Activities "help with performance optimization and are not a replacement for regular Activities", and "For most production workloads, regular Activities remain the recommended default." The narrow case is a short, idempotent, in-binary operation you do thousands of times — a small computation, an in-memory cache read.

Why it is the wrong default, in the three ways that will be probed:

  • No Activity heartbeat. Local Activities "do not support Activity heartbeats"; what exists instead is Workflow Task heartbeating, which at roughly 80% of the Workflow Task Timeout (10 seconds by default) completes the current Workflow Task and requests a new one. Each heartbeat adds Events, Signals are not processed until the Local Activities finish, and Commands are not sent until the Local Activity completes or the next heartbeat occurs. Upstream's rule: "If your operation regularly approaches the Workflow Task timeout, it is usually better implemented as a regular Activity."
  • Durability is late. The result becomes durable only when the enclosing Workflow Task completes and writes the marker; before that it lives in Worker memory, and a Worker crash re-runs it. Semantics are at-least-once, so it must be idempotent.
  • Replay. Once the marker is in history, replay uses the recorded result rather than re-executing — which is why long retry intervals are wasteful.

Nothing in AgentRun's loop qualifies: call_llm, execute_tool and evaluate are network calls, need routing and rate limiting, and can run for minutes. Do not use a Local Activity for a model call, a tool call, or anything that touches a network.

5. Asynchronous Activity completion

Module 4 introduced this for the 45-minute GPU job. The mechanism: the Activity hands the external system a Task Token — "a unique identifier for an Activity Task Execution" — or an Activity ID plus Workflow ID, then returns in a way that marks it incomplete, and a Temporal Client anywhere finishes it.

token = activity.info().task_token       # inside execute_tool
activity.raise_complete_async()          # the function returns; the Activity does not

handle = client.get_async_activity_handle(task_token=token)   # any process, hours later
await handle.heartbeat("job finished")
await handle.complete(ToolResult(...))   # or .fail(...) / .report_cancellation()

Lab 4's complete_async.py is exactly this, run from a process that knows nothing about the Worker.

The failure mode is quiet, and it is the probe. Nothing heartbeats while the Activity waits, so nothing notices that the completer is gone — a crashed scheduler, a dropped webhook, a lost token. The Activity stays open until Start-To-Close fires, and upstream's worked example makes the cost concrete: with a one-week Start-To-Close, an Activity that dies after notifying the external system "won't be retried for a week". Two mitigations, both upstream's: have the external system heartbeat, so a heartbeat timeout converts a dead completer into a retryable failure in seconds instead of days; and prefer Activity ID plus Workflow ID over the Task Token, which is per attempt — an Activity that fails after handing out its token leaves the remote service holding an invalid one. Where the system is reliable and needs neither heartbeat nor cancellation, upstream's simpler alternative is to complete the Activity at once and have the system Signal the result back.

6. Failure taxonomy: retryable, non-retryable, timed out, cancelled

Module 4 built the timeouts and the retry policy; this is the vocabulary on top of them. Upstream sorts failures into transient (a one-off), intermittent (recurs but resolves over time — rate limiting), and permanent (recurs until the input or the code changes). Defaults handle the first, a longer initial_interval and backoff_coefficient the second; the third must be surfaced, not retried.

Two places to say so, and they are not equivalent:

raise ApplicationError("tool rejected the arguments", type="BadToolArguments", non_retryable=True)

RetryPolicy(non_retryable_error_types=["BadToolArguments"])   # matched against the ApplicationError's `type`

non_retryable=True is the Activity implementer's judgement; non_retryable_error_types is the caller's. Both are matched against the type field of the Application Failure, so the string is an interface: rename the type and the policy silently stops matching.

The three outcomes differ in what AgentRun sees, all arriving as an ActivityError with a different cause: ApplicationError (your code failed it, or retries were exhausted), TimeoutError (one of the four timeouts, .type says which), CancelledError (an Activity that does not heartbeat cannot receive one; a server-side timeout surfaces as a Cancelled Failure with message: 'TIMED_OUT').

Lab 8's forensic history ends on the distinction: event 13 carries retryState: RETRY_STATE_NON_RETRYABLE_FAILURE, which is why there is no attempt 4 even though the policy allowed three. Had the error been retryable, the same history would have closed with RETRY_STATE_MAXIMUM_ATTEMPTS_REACHED. That field is how you tell "the policy gave up" from "the code said stop".

One boundary the modules only implied: a Temporal failure thrown in Workflow code fails the Workflow Execution permanently, while any other error fails the Workflow Task, which retries and preserves state until you fix the bug.

7. Workflow reset as an operational tool

Reset is the operator's undo, and the course used it only in passing: module 9 mentions Reset-with-Move for recovering pinned executions after a bad deploy.

Upstream: "Resetting a Workflow Execution terminates the current Workflow Execution and starts a new Workflow Execution from a point you specify in its Event History." History up to that point is copied into the new execution, the Workflow resumes with the current code, and "any progress made after the reset point will be discarded".

temporal workflow reset --workflow-id agent-42 --event-id 11 --reason "fixed non-deterministic plan()"

Reach for a reset when the state before the bad point is worth keeping — a 40-step agent run that stalled on a non-determinism error at step 39, where starting over means paying for 39 model calls again. Reach for a new run when there is no such state, or when side effects already performed make replaying the prefix wrong.

The phrase that matters is "with the current code". A reset replays the copied prefix against whatever is deployed now, so everything module 9 taught applies: if today's AgentRun would emit different Commands for those events, the reset execution fails the same way the original did. That is the payoff of module 8's histories fixture — the corpus is what tells you a reset will land before you order one.

Edge cases: a reset creates a new Run ID and changes the chain's first_execution_run_id while preserving original_execution_run_id; batch resets are limited to FirstWorkflowTask, LastWorkflowTask, or BuildId; --reapply-exclude All skips re-applying Signals and Updates, usually what you want for a clean restart; and resetting a closed Workflow is not idempotent — re-running the command "will reset the same closed Workflows again, terminating each previous reset attempt and starting another new run".

8. Schedules versus sleeping inside a Workflow

A Schedule "contains instructions for starting a Workflow Execution at specific times" and, unlike a Cron Job, "has an identity and is independent of a Workflow Execution". In Python: client.create_schedule(id, Schedule(action=ScheduleActionStartWorkflow(...), spec=ScheduleSpec(...))).

When should a recurring job be a Schedule rather than a long-lived Workflow with while True: await asyncio.sleep(...)? Module 6's argument settles half of it: a timer loop is one execution whose history grows forever, so you are back to is_continue_as_new_suggested() and a snapshot you must design. A Schedule starts a fresh execution per firing, each with a bounded history and its own entry in the UI.

The other half is what the Schedule gives you that the loop cannot:

  • Catch-up. The Catchup Window decides which missed Actions are taken when the Service returns from an outage — one year by default, ten seconds minimum. A sleeping Workflow simply wakes late.
  • Overlap Policy. Skip (the default), BufferOne, BufferAll, CancelOther, TerminateOther, AllowAll. This is "the 2 a.m. sweep is still running at 3 a.m., now what?", answered by configuration instead of a lock you wrote.
  • Pause, trigger, backfill. Pause stops future Actions without touching running executions; temporal schedule trigger forces one; Backfill runs a past window's Actions. Pause-on-failure pauses the Schedule after a failed or timed-out run.
  • Provenance. Scheduled executions get the TemporalScheduledStartTime and TemporalScheduledById Search Attributes that A3's queries filter on.

AgentRun itself is not a scheduled job — it is an entity with a user's ID on it, which is why the course never needed one; a nightly eval sweep over the agent's traces is. Two edge cases: for a single future start use Start Delay, not a Schedule; and a Paused Workflow Execution is still open, so it counts as the running execution when the Schedule evaluates its Overlap Policy — pause the Schedule too, or the sweeps quietly stop firing while you investigate.

Where this is used

Nothing here changes the capstone architecture, which is why it sits apart: module 11 asks you to derive AgentRun from the requirements and defend every box, and each tool above is a box you may be asked why you did not draw. Reuse and conflict policy belong in the defense's paragraph on "one agent per user"; Parent Close Policy in the ResearchAgent paragraph; the failure taxonomy in the retry paragraph; reset and Schedules in the operations paragraph, as the two things an operator does that the Workflow code never sees. Appendix A6 turns these into drill questions.

Sources and license

This page adapts material from Temporal's MIT-licensed documentation and samples (© Temporal Technologies Inc.; © Uber Technologies, Inc.). Adapted text is rewritten for this course; the upstream pages are the reference of record and may have changed since the commit linked here. Temporal is a trademark of Temporal Technologies; this course is independent and not endorsed by Temporal.