Lab 6: Continue-As-New and Child Workflows
Objective
Why: Keep a long-running agent small and delegate independent work.
By the end: Trace a continue-as-new chain and a Child Workflow through their separate histories and Worker failures.
Before you run
- Mechanism: Continue-As-New closes one Run and starts another under the same Workflow ID with an explicit state snapshot. A Child Workflow has its own ID, history, and lifecycle.
- Predict: Predict what state would disappear if it were omitted from the continue-as-new snapshot.
- Look for: Trace the Run ID chain and the parent/child histories separately. Restart the child Worker and see which execution resumes.
- Terminal route: From this lab directory, run
source ../.venv/bin/activatebefore copying anypython ...command below. The browser action cards select the Python environment for you.
Same program as lab 5 plus two things in workflows.py: the continue-as-new check at the top of
the loop, with AgentState as the checkpoint, and a research step that plan() hands to
ResearchAgent. Reading page: "Continue-As-New and Child Workflows".
Open this lab in the browser workspace
Complete the one-time setup in Lab 1, then open
Lab 06 on your local lab server (use the port
lab_server.py printed if it is not 3000). The Temporal server, Agent Runtime, and lab
server can stay running between labs.
Click Prepare this lab, then use this lab's action cards to start Workers and
clients, follow Output, and crash or stop processes. The page selects the lab directory, Python
environment, and Task Queue for you. For a variation from the exercise, paste one
python ... command into the command field. The instructions below also describe the
terminal route if you prefer it.
The workspace adds a short suffix to this lab's Task Queue, and in some labs to its Workflow ID
(lab-05-3f2a and agent-42-3f2a rather than lab-05 and
agent-42), so a Worker or run left over from another tab cannot interfere. Read the
names in the text below with that suffix; the Output panel shows the real ones.
Goal: keep AgentRun's history bounded without losing its identity, and delegate a research task to a Child Workflow that has a history — and a Worker — of its own.
Setup
cd 06-continue-as-new-and-child-workflows # from the temporal-course-labs folder
export TASK_QUEUE=lab-06
python worker.py --role agent # terminal 1: AgentRun + call_llm on lab-06
python worker.py --role research # terminal 2: ResearchAgent + fetch_source on lab-06-research
python starter.py # terminal 3: starts agent-42, sends one note, prints the run chain
python worker.py with no --role runs both in one process, which is fine for 6.1. The child
runs on its own Task Queue only so that "the child's Worker" is a process you can kill in 6.2 —
by default a child inherits its parent's queue, and splitting queues on purpose is module 10.
Use --id/WORKFLOW_ID if agent-42 is taken on the shared server. A full run is 20 steps and
takes about 30 s including the child.
6.1 Continue-as-new: one Workflow ID, several Run IDs
STEPS_PER_RUN = 4 is a lab knob so the transition happens in a 20-step run; the check reads
workflow.info().is_continue_as_new_suggested() or self.steps_this_run >= STEPS_PER_RUN, and the
suggestion is the rule your production code keeps. Run the starter and read its output:
started agent-42 first run=01a08fac-...; open http://localhost:8233/namespaces/default/workflows/agent-42
inject_context sent; it landed in run=01a08fac-... at step 1
result:
[model answer to: Step 0 toward: summarize the Temporal docs]
[model answer to: Step 1 toward: summarize the Temporal docs]
Operator: Prefer sources after 2025
...
3 sources on sources for: summarize the Temporal docs
...
[model answer to: Step 19 toward: summarize the Temporal docs]
final status(): {..., 'run_id': 'f268e931-...', 'status': 'done', 'step': 20, 'steps_this_run': 4, 'processed_command_ids': ['cmd-7f3a']}
run chain (one Workflow ID, several Run IDs):
run 1: 01a08fac-... CONTINUED_AS_NEW 53 events last=WORKFLOW_EXECUTION_CONTINUED_AS_NEW
run 2: fd3e9a8d-... CONTINUED_AS_NEW 52 events last=WORKFLOW_EXECUTION_CONTINUED_AS_NEW
run 3: a60cb664-... CONTINUED_AS_NEW 49 events last=WORKFLOW_EXECUTION_CONTINUED_AS_NEW
run 4: e14331bb-... CONTINUED_AS_NEW 49 events last=WORKFLOW_EXECUTION_CONTINUED_AS_NEW
run 5: f268e931-... COMPLETED 49 events last=WORKFLOW_EXECUTION_COMPLETED
What to check, in the UI and in this output:
- Open
agent-42in the UI. The Workflow ID page lists five runs; four are Continued-As-New, one Completed. Each history is about 50 Events, not 250.client.list_workflows('WorkflowId = "agent-42"')returns the same five. - Open run 1. Its last Event is
WorkflowExecutionContinuedAsNew, and its attributes carry the next Run ID and theAgentStatepayload — open it:step: 4, the goal, the summary so far, theprocessed_command_ids. Open run 2: Event 1WorkflowExecutionStartedhascontinued_execution_run_id= run 1, and its input is that sameAgentState. Nothing else crossed the boundary — no timers, no pending Activities, no local variables. - The starter waited on one handle and got the final result:
handle.result()follows the chain by default, andstatus()on the same handle answered from whichever run was current (therun_idinfinal status()is run 5's). - The operator note. The starter sent
inject_contextone second in — it landed in run 1 at step 1 — and the note is in the result that run 5 returned, once. It got there becausesnapshot()serializescontext_summary + pending_contextandprocessed_command_idsintoAgentState. Delete+ self.pending_contextfromsnapshot(), restart the agent Worker, and re-run withpython starter.py --id agent-42-lost --inject-after 3.8: the note now lands in the last step before the first transition (the starter printslanded … at step 4), the handler accepts it, and it is gone from the result — and nothing in history complains. At the default one second the note is folded in long before a transition, which is why the bug hides. - The drain.
await workflow.wait_condition(workflow.all_handlers_finished)sits right beforecontinue_as_new. Our handlers are alldef, so it returns at once; makeinject_contextanasync defthat awaits a 2 s timer, take the drain out, and send the note late enough to still be running at the transition (--inject-after 2.5). The Worker warnsUnfinishedSignalHandlersWarning: [TMPRL1102] Workflow finished while signal handlers are still running, and the note is lost; the caller of an Update would getNOT_FOUND.
6.1b What is still unbounded?
Continue-as-new bounded the history. Look at what it did not bound. Every run passes AgentState
to the next one, and two of its fields only ever grow:
history bounded by continue-as-new ✅
context_summary appended to, run after run ❌
processed_command_ids one entry per command ❌
The payload of the next run is an input like any other: it is stored in that run's
WorkflowExecutionStarted Event, replayed on every Workflow Task, and counted against the same size
limits module 2 measured. An agent that runs for a month ships its whole past into every new run.
- Run
python starter.py --id agent-42-long --steps 60(about 80 s, fifteen runs) and read theAgentStateinput on the last run's Event 1. Compare its size with run 1's. Extrapolate to a month. - Say what a production
AgentRunwould carry instead. The shapes that work: a rolling or hierarchical summary that is rewritten rather than appended to; a window of recent command IDs with the older ones moved to an external dedupe table keyed by(workflow_id, command_id); blob references instead of transcripts. The rule: what crosses a continue-as-new boundary should be the state the next run needs to decide, not the record of how it got here.
6.2 ResearchAgent: kill the child's Worker
plan() returns research at step 6 (run 2). AgentRun starts ResearchAgent with the ID
agent-42/research/1 and awaits it; the child reads three sources with a 3 s durable timer after
each, so it lives for about 10 s. With both Workers running:
- Start a fresh run. When
agent-42/research/1appears in the UI as Running (about 6 s in),kill -9the research Worker (terminal 2). Leave the agent Worker alone. - Both executions stay Running. The parent has nothing to do — it is waiting on a single
pending Child Workflow — and its history does not move. The child's timer fires with no Worker
to act on it. Query the child's
progressnow (client.get_workflow_handle("agent-42/research/1").query("progress")): it fails after about half a minute withRPCError('no poller seen for task queue recently, worker may be down')— nobody can answer. -
Start the research Worker again. The child finishes on the new process. Its history, from the run we recorded (the first Worker was pid 3791118, the second 3791435):
11 TIMER_STARTED 12 TIMER_FIRED 08:54:02 13 WORKFLOW_TASK_SCHEDULED 14 WORKFLOW_TASK_TIMED_OUT 08:54:12 15 WORKFLOW_TASK_SCHEDULED 16 WORKFLOW_TASK_STARTED identity=3791435@... 17 WORKFLOW_TASK_COMPLETED 18 ACTIVITY_TASK_SCHEDULED fetch_sourceThe timer fired (12) while no Worker existed; the Workflow Task (13) was first offered to the dead Worker's sticky queue and timed out there (14, ten seconds later), then was rescheduled (15) and picked up by the new identity (16). The child replayed its own 15 Events and continued with source 2.
-
Now the parent. In run 2 of
agent-42, the child is three Events:27 START_CHILD_WORKFLOW_EXECUTION_INITIATED agent-42/research/1 28 CHILD_WORKFLOW_EXECUTION_STARTED 32 CHILD_WORKFLOW_EXECUTION_COMPLETED (later by however long the Worker was down)No
fetch_source, no child timers, nothing about the crash. The child's 40 Events are the child's. That is what earns this delegation a Child Workflow rather than one Activity: not length — an Activity can be long, heartbeat and retry too — but a durable lifecycle of its own, with its own history, its own state and its own messaging surface, reported to the parent as one recorded result. -
Make the child fail:
python starter.py --goal "summarize the unreliable Temporal docs".ResearchAgent.runraises a non-retryableApplicationErrorfor that goal. The child closes Failed; the parent's run 2 recordsCHILD_WORKFLOW_EXECUTION_FAILED source registry rejected the task; theexcept ChildWorkflowErrorbranch addsresearch 1 failed: source registry rejected the taskto the summary, bumpsattempts, and the run finishes all 20 steps — the failure was a value the parent decided about, exactly as anActivityErrorwould be.
Stretch: make the child outlive the parent. Changing parent_close_policy alone will not do it —
await workflow.execute_child_workflow(...) means the parent cannot finish before the child by
construction. You need the asynchronous form as well:
handle = await workflow.start_child_workflow( # returns once the child has STARTED
ResearchAgent.run,
args=[...],
id=f"{workflow.info().workflow_id}/research/{self.state.research_n}",
parent_close_policy=ParentClosePolicy.ABANDON,
)
# do NOT await handle.result(): let the parent go on and finish
start_child_workflow returns when the child is started, not when it is done, and awaiting that
start matters: the child must reach ChildWorkflowExecutionStarted before the parent closes, or
there is nothing for the policy to abandon. Put the research step near the end (step 19) so the
parent finishes first, then watch the child keep running with its parent closed — and under
TERMINATE, the default in this lab, watch the same child die with it.
6.3 Activity or Child Workflow?
For each delegated task an agent might take, write Activity or Child Workflow and one line
of justification. Then compare with "Six delegations, sorted" on the reading page — and with the
two choices this lab already made (call_llm, ResearchAgent).
| Task | Activity or Child? | Why (one line) |
|---|---|---|
| Fetch a URL | ||
| Summarize a document with the model | ||
| Run a test suite in a container (5 min, progress available) | ||
| Research a company across 30 sources with a human review in the middle | ||
| Monitor a deployment for a week after the agent that started it is done | ||
| Send a notification |
Two follow-ups: which of the six needs a Parent Close Policy other than the default, and why is the answer to "the code is getting long, let's split it into a child" still Activity?
The recurring question
If every Worker process dies right now: the current run's history survives and so do the closed
runs'; the AgentState you passed is the new run's first Event, so a returning Worker replays only
the current run. A Signal that races a continue-as-new is not lost: Temporal guarantees it is
processed either by the run that is closing or by its continuation. Do not write code that depends on
which — the mechanism (buffering it forward, or rewinding the Workflow Task that tried to continue so
the current run handles the Signal first) is Temporal's business and has changed before.
ResearchAgent's history is its own execution: parent and child replay independently, on whichever
Workers come back, and the parent's replay stops at "waiting for child" until the child reports.
Check yourself
python -m pytest tests/ -q — five runs with short histories and run 2 naming run 1 as its origin,
the note and the dedupe set travelling in AgentState, status() answering on one handle across
runs, the child's steps in the child's history and one Initiated/Started/Completed triple in the
parent's, and a failing child surfacing as CHILD_WORKFLOW_EXECUTION_FAILED that the parent
catches and keeps going after.
What you learned
- Continue-as-new closes one Run and starts another under the same Workflow ID with an explicit state snapshot.
- A Child Workflow has its own history and can run on a separate Task Queue and Worker.
- The parent records the child's lifecycle; it does not absorb the child's internal Events.
Questions to answer
- Which fields must
AgentStatecarry into the next Run to preserve behavior and deduplication? - How can one Workflow ID have several Run IDs without one unbounded Event History?
- What changes in the parent history when the Research Worker dies?
- Why does recovering the parent not require replaying the child's history inside the parent?
Files
Every file in this lab, as committed. Copy or clone; nothing here is hidden.
06-continue-as-new-and-child-workflows/starter.py
"""Start AgentRun, send one operator note mid-run, wait for the result, then print the run chain.""" import argparse import asyncio import os import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from common import TASK_QUEUE, connect, start_workflow # noqa: E402 from temporalio.api.enums.v1 import EventType # noqa: E402 from temporalio.client import Client # noqa: E402 from workflows import AgentRun, AgentState # noqa: E402 async def run_chain(client: Client, workflow_id: str, first_run_id: str) -> list[dict]: """Follow the continue-as-new chain from the first run: each run's last Event names the next Run ID.""" chain, run_id = [], first_run_id while run_id: handle = client.get_workflow_handle(workflow_id, run_id=run_id) desc = await handle.describe() events = [ev async for ev in handle.fetch_history_events()] last = events[-1] nxt = None if last.HasField("workflow_execution_continued_as_new_event_attributes"): nxt = last.workflow_execution_continued_as_new_event_attributes.new_execution_run_id chain.append({"run_id": run_id, "status": desc.status.name, "events": len(events), "last_event": EventType.Name(last.event_type).removeprefix("EVENT_TYPE_")}) run_id = nxt return chain async def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--id", default=os.environ.get("WORKFLOW_ID", "agent-42")) ap.add_argument("--goal", default="summarize the Temporal docs", help='a goal containing "unreliable" makes the ResearchAgent child fail (6.2)') ap.add_argument("--steps", type=int, default=0, help="total steps before the agent finishes (default 20; 6.1b uses 60)") ap.add_argument("--inject-after", type=float, default=1.0, help="seconds after start to send one inject_context Signal (0 = none)") args = ap.parse_args() client = await connect() # --steps starts the chain from an explicit step-0 checkpoint that carries the length; every # continue-as-new passes it on in AgentState. Without it, run 1 starts with no state at all. first = [args.goal, AgentState(step=0, goal=args.goal, max_steps=args.steps)] if args.steps else [args.goal] handle = await start_workflow(client, AgentRun.run, args=first, id=args.id, task_queue=TASK_QUEUE) first_run_id = handle.result_run_id print(f"started {handle.id} first run={first_run_id}; open http://localhost:8233/namespaces/default/workflows/{handle.id}") if args.inject_after > 0: await asyncio.sleep(args.inject_after) await handle.signal(AgentRun.inject_context, args=["Prefer sources after 2025", "cmd-7f3a"]) status = await handle.query(AgentRun.status) print(f"inject_context sent; it landed in run={status['run_id']} at step {status['step']}") result = await handle.result() # follows the chain: the handle is the Workflow ID, not a run print("result:") print(result) print(f"\nfinal status(): {await handle.query(AgentRun.status)}") print("\nrun chain (one Workflow ID, several Run IDs):") for i, run in enumerate(await run_chain(client, handle.id, first_run_id), 1): print(f" run {i}: {run['run_id']} {run['status']:17s} {run['events']:3d} events last={run['last_event']}") if __name__ == "__main__": asyncio.run(main())
06-continue-as-new-and-child-workflows/tests/test_lab.py
"""Lab 6 self-check against the time-skipping test server: python -m pytest tests/ -q""" from __future__ import annotations import asyncio import os import sys import uuid HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(HERE, "..")) sys.path.insert(0, os.path.join(HERE, "..", "..")) from temporalio.api.enums.v1 import EventType # noqa: E402 from temporalio.client import Client # noqa: E402 from temporalio.testing import WorkflowEnvironment # noqa: E402 from temporalio.worker import Worker # noqa: E402 from workflows import ( # noqa: E402 MAX_STEPS, RESEARCH_TASK_QUEUE, STEPS_PER_RUN, AgentRun, ResearchAgent, call_llm, fetch_source, ) async def _events(client: Client, workflow_id: str, run_id: str) -> list: return [ev async for ev in client.get_workflow_handle(workflow_id, run_id=run_id).fetch_history_events()] async def _run_chain(client: Client, workflow_id: str, first_run_id: str) -> list[list]: """Histories of every run, following WorkflowExecutionContinuedAsNew.new_execution_run_id.""" chain, run_id = [], first_run_id while run_id: events = await _events(client, workflow_id, run_id) chain.append(events) last = events[-1] run_id = (last.workflow_execution_continued_as_new_event_attributes.new_execution_run_id if last.HasField("workflow_execution_continued_as_new_event_attributes") else None) return chain def _names(events) -> list[str]: return [EventType.Name(ev.event_type).removeprefix("EVENT_TYPE_") for ev in events] async def _with_workers(env: WorkflowEnvironment, goal: str, inject: bool): tq = f"lab-06-test-{uuid.uuid4()}" agent = Worker(env.client, task_queue=tq, workflows=[AgentRun], activities=[call_llm]) research = Worker(env.client, task_queue=RESEARCH_TASK_QUEUE, workflows=[ResearchAgent], activities=[fetch_source]) async with agent, research: handle = await env.client.start_workflow(AgentRun.run, goal, id=f"lab06-test-{uuid.uuid4()}", task_queue=tq) if inject: await handle.signal(AgentRun.inject_context, args=["Prefer sources after 2025", "cmd-7f3a"]) first = await handle.query(AgentRun.status) # answered by run 1 assert first["run_id"] == handle.result_run_id and first["processed_command_ids"] == ["cmd-7f3a"] await env.sleep(6) # skip ahead: 4 steps of 0.5 s per run -> run 3 or so later = await handle.query(AgentRun.status) # same handle, a later run answers assert later["run_id"] != first["run_id"] and later["step"] > first["step"] assert later["processed_command_ids"] == ["cmd-7f3a"] # the dedupe set travelled in AgentState result = await handle.result() # follows the chain; time-skipping runs every timer return handle, result def test_one_workflow_id_several_run_ids_each_history_short() -> None: async def go() -> None: async with await WorkflowEnvironment.start_time_skipping() as env: handle, result = await _with_workers(env, "summarize the Temporal docs", inject=True) chain = await _run_chain(env.client, handle.id, handle.result_run_id) assert len(chain) == MAX_STEPS // STEPS_PER_RUN == 5 for events in chain[:-1]: assert _names(events)[-1] == "WORKFLOW_EXECUTION_CONTINUED_AS_NEW" assert len(events) < 80 # each run's history is short; the state travels as AgentState assert _names(chain[-1])[-1] == "WORKFLOW_EXECUTION_COMPLETED" started = chain[1][0].workflow_execution_started_event_attributes assert started.continued_execution_run_id == handle.result_run_id # run 2 names run 1 as its origin # the operator note was signalled to run 1 and is in the final result: AgentState carried it assert result.count("Operator: Prefer sources after 2025") == 1 assert (await env.client.get_workflow_handle(handle.id, run_id=handle.result_run_id).describe()).status.name == "CONTINUED_AS_NEW" asyncio.run(go()) def test_research_child_has_its_own_history_and_one_event_pair_in_the_parent() -> None: async def go() -> None: async with await WorkflowEnvironment.start_time_skipping() as env: handle, result = await _with_workers(env, "summarize the Temporal docs", inject=False) assert "3 sources on sources for: summarize the Temporal docs" in result child_id = f"{handle.id}/research/1" child = env.client.get_workflow_handle(child_id) child_names = _names([ev async for ev in child.fetch_history_events()]) assert child_names.count("ACTIVITY_TASK_COMPLETED") == 3 # the child's steps live in the child's history assert (await child.describe()).status.name == "COMPLETED" parent_names = [n for run in await _run_chain(env.client, handle.id, handle.result_run_id) for n in _names(run)] assert parent_names.count("START_CHILD_WORKFLOW_EXECUTION_INITIATED") == 1 assert parent_names.count("CHILD_WORKFLOW_EXECUTION_STARTED") == 1 assert parent_names.count("CHILD_WORKFLOW_EXECUTION_COMPLETED") == 1 assert "fetch_source" not in str(parent_names) asyncio.run(go()) def test_child_failure_reaches_the_parent_as_a_catchable_error() -> None: async def go() -> None: async with await WorkflowEnvironment.start_time_skipping() as env: handle, result = await _with_workers(env, "summarize the unreliable Temporal docs", inject=False) assert "research 1 failed: source registry rejected the task" in result assert result.count("[model answer") == MAX_STEPS - 1 # every other step still ran parent_names = [n for run in await _run_chain(env.client, handle.id, handle.result_run_id) for n in _names(run)] assert parent_names.count("CHILD_WORKFLOW_EXECUTION_FAILED") == 1 child = env.client.get_workflow_handle(f"{handle.id}/research/1") assert (await child.describe()).status.name == "FAILED" asyncio.run(go())
06-continue-as-new-and-child-workflows/worker.py
"""Worker for lab 6. python worker.py # AgentRun + ResearchAgent, both queues (one process) python worker.py --role agent # AgentRun and call_llm only, on TASK_QUEUE python worker.py --role research # ResearchAgent and fetch_source only, on RESEARCH_TASK_QUEUE 6.2 wants the child on a Worker you can kill without touching the parent: run one of each role. """ import argparse import asyncio import os import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from common import TASK_QUEUE, connect # noqa: E402 from temporalio.worker import Worker # noqa: E402 from workflows import RESEARCH_TASK_QUEUE, AgentRun, ResearchAgent, call_llm, fetch_source # noqa: E402 async def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--role", choices=["both", "agent", "research"], default="both") args = ap.parse_args() client = await connect() workers = [] if args.role in ("both", "agent"): workers.append(Worker(client, task_queue=TASK_QUEUE, workflows=[AgentRun], activities=[call_llm])) if args.role in ("both", "research"): workers.append(Worker(client, task_queue=RESEARCH_TASK_QUEUE, workflows=[ResearchAgent], activities=[fetch_source])) queues = [w.task_queue for w in workers] print(f"worker pid={os.getpid()} role={args.role} polling {queues}; kill -9 {os.getpid()} to crash it") await asyncio.gather(*(w.run() for w in workers)) if __name__ == "__main__": asyncio.run(main())
06-continue-as-new-and-child-workflows/workflows.py
"""Lab 6: AgentRun, stage 6 — continue-as-new and the ResearchAgent child. Two additions to stage 5. (1) The loop checks, at the top of every step, whether it should checkpoint into a fresh run: `workflow.info().is_continue_as_new_suggested()` is the rule; `STEPS_PER_RUN` is a lab knob so the transition happens in a 20-step run instead of a 20,000-step one. The checkpoint is `AgentState`, passed as the next run's `run()` arguments. (2) `plan()` can now return a `research` step, which AgentRun delegates to `ResearchAgent` — a Child Workflow with its own history, its own timers and its own Worker. """ from __future__ import annotations import asyncio import hashlib import os import sys from dataclasses import dataclass, field from datetime import timedelta from temporalio import activity, workflow from temporalio.exceptions import ApplicationError, ChildWorkflowError from temporalio.workflow import ParentClosePolicy sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from common import TASK_QUEUE # noqa: E402 # The child runs on its own queue only so that "the child's Worker" is a separate process you # can kill in 6.2 (`python worker.py --role research`). By default a child inherits its parent's # Task Queue; splitting queues on purpose is module 10. RESEARCH_TASK_QUEUE = f"{TASK_QUEUE}-research" MAX_STEPS = 20 # the loop finishes on its own after this many steps STEPS_PER_RUN = 4 # lab knob: continue-as-new after this many steps in one run -> 5 runs STEP_SECONDS = 0.5 # durable timer between steps RESEARCH_STEP = 6 # plan() delegates to ResearchAgent at this step (run 2 of 5) SOURCES = 3 # ResearchAgent reads this many sources ... SOURCE_SECONDS = 3 # ... with a durable timer after each: ~10 s to kill its Worker @dataclass class AgentState: """The checkpoint: everything the next run needs to decide. A summary, never the transcript.""" step: int goal: str context_summary: str = "" attempts: int = 0 processed_command_ids: list[str] = field(default_factory=list) # module 5's dedupe set rides along research_n: int = 0 max_steps: int = MAX_STEPS # the run's length, set once at start (6.1b) @dataclass class Step: kind: str # "llm" | "research" | "finish" prompt: str = "" task: str = "" @dataclass class Report: task: str summary: str sources: int @activity.defn async def call_llm(prompt: str, key: str) -> str: activity.logger.info("call_llm key=%s", key) return f"[model answer to: {prompt[:48]}]" @activity.defn async def fetch_source(task: str, n: int) -> str: activity.logger.info("fetch_source %s #%d", task, n) return f"source {n}: notes on {task}" @workflow.defn class ResearchAgent: """A delegated unit with a lifecycle: several steps, timers, a Query of its own, and its own history in the UI. As one Activity this would be one giant timeout and all-or-nothing retry.""" def __init__(self) -> None: self.notes: list[str] = [] @workflow.run async def run(self, task: str) -> Report: if "unreliable" in task: # 6.2, second half: a child that fails raise ApplicationError("source registry rejected the task", non_retryable=True) for n in range(1, SOURCES + 1): note = await workflow.execute_activity( fetch_source, args=[task, n], start_to_close_timeout=timedelta(seconds=10) ) self.notes.append(note) await asyncio.sleep(SOURCE_SECONDS) # a durable timer per source: kill this Worker here return Report(task=task, summary=f"{len(self.notes)} sources on {task}", sources=len(self.notes)) @workflow.query def progress(self) -> int: return len(self.notes) @workflow.defn class AgentRun: def __init__(self) -> None: self.step = 0 self.status = "running" self.goal = "" self.context_summary = "" self.attempts = 0 self.paused = False self.processed_command_ids: set[str] = set() self.pending_context = "" # accepted by a handler, not yet folded in: part of the snapshot self.research_n = 0 self.steps_this_run = 0 # not in AgentState on purpose: it is about this run self.max_steps = MAX_STEPS def restore(self, state: AgentState) -> None: self.step = state.step self.goal = state.goal self.context_summary = state.context_summary self.attempts = state.attempts self.processed_command_ids = set(state.processed_command_ids) self.research_n = state.research_n self.max_steps = state.max_steps def snapshot(self) -> AgentState: return AgentState( step=self.step, goal=self.goal, context_summary=self.context_summary + self.pending_context, # accepted, unprocessed: carry it attempts=self.attempts, processed_command_ids=sorted(self.processed_command_ids), research_n=self.research_n, max_steps=self.max_steps, ) def plan(self) -> Step: """Workflow code: a pure function of state.""" if self.step >= self.max_steps: return Step("finish") if self.step == RESEARCH_STEP: return Step("research", task=f"sources for: {self.goal}") return Step("llm", prompt=f"Step {self.step} toward: {self.goal}") @workflow.run async def run(self, goal: str, state: AgentState | None = None) -> str: self.goal = goal if state: self.restore(state) # a new run picks up where the old one left off while self.status == "running": if self.step < self.max_steps and ( workflow.info().is_continue_as_new_suggested() or self.steps_this_run >= STEPS_PER_RUN ): await workflow.wait_condition(workflow.all_handlers_finished) # drain Signals/Updates first workflow.continue_as_new(args=[self.goal, self.snapshot()]) # nothing after this line runs await workflow.wait_condition(lambda: not self.paused or self.status != "running") if self.status != "running": break step = self.plan() if step.kind == "finish": self.status = "done" break if step.kind == "research": self.research_n += 1 try: report = await workflow.execute_child_workflow( ResearchAgent.run, step.task, id=f"{workflow.info().workflow_id}/research/{self.research_n}", task_queue=RESEARCH_TASK_QUEUE, parent_close_policy=ParentClosePolicy.TERMINATE, ) self.context_summary += f"\n{report.summary}" except ChildWorkflowError as e: # the child failed: decide, as with an ActivityError self.attempts += 1 self.context_summary += f"\nresearch {self.research_n} failed: {e.cause}" else: key = f"{workflow.info().workflow_id}:{self.step}:{hashlib.sha256(step.prompt.encode()).hexdigest()[:12]}" answer = await workflow.execute_activity( call_llm, args=[step.prompt, key], start_to_close_timeout=timedelta(seconds=10) ) self.context_summary = answer if not self.context_summary else f"{self.context_summary}\n{answer}" if self.pending_context: # fold operator notes at a step boundary self.context_summary += self.pending_context self.pending_context = "" self.step += 1 self.steps_this_run += 1 await asyncio.sleep(STEP_SECONDS) await workflow.wait_condition(workflow.all_handlers_finished) return self.context_summary @workflow.signal def pause(self) -> None: self.paused = True @workflow.signal def resume(self) -> None: self.paused = False @workflow.signal def cancel_run(self) -> None: self.status = "cancelled" @workflow.signal def inject_context(self, text: str, command_id: str) -> None: if command_id in self.processed_command_ids: return self.processed_command_ids.add(command_id) self.pending_context += f"\nOperator: {text}" @workflow.query def status(self) -> dict: return { "run_id": workflow.info().run_id, # changes at every continue-as-new; the handle does not "step": self.step, "steps_this_run": self.steps_this_run, "status": self.status, "paused": self.paused, "goal": self.goal, "research_n": self.research_n, "attempts": self.attempts, "processed_command_ids": sorted(self.processed_command_ids), }
Lab source: labs/06-continue-as-new-and-child-workflows in the course repository. Verified against temporal server start-dev with the Python SDK 1.32 on 2026-09-11.