Lab 11: Capstone: a durable agent runtime
Objective
Why: Bring together the course’s durability patterns in one agent run.
By the end: Run the capstone through multiple lanes, bounded histories, and failure cases; explain what each mechanism protects.
Before you run
- Mechanism: The capstone combines one agent loop with durable history, idempotent Activities, bounded Runs, message handlers, compensation, versioning, and separate Worker lanes.
- Predict: Before running the failure suite, predict the evidence each injected failure should leave and which component is responsible for recovery.
- Look for: Keep a short evidence ledger: history event or Run ID, external effect key, queue/poller state, and the mechanism that explains the outcome.
- 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.
Everything is the same names as L01–L10. Nothing here is new; the exam is that it is all in one place.
| File | What it is |
|---|---|
agentrun/workflows.py |
AgentRun (plan(), the loop, Signals, Query, Update, continue-as-new, cancellation, compensations) and ResearchAgent |
agentrun/activities.py |
call_llm, execute_tool, evaluate, compensate — fakes that keep in-process ledgers by idempotency key (LLM_CACHE, TOOL_LEDGER, TOOL_SIDE_EFFECTS, COMPENSATED); a production ledger must be durable across Worker hosts and reconcile unknown provider outcomes |
agentrun/models.py |
Step, ToolCall, ToolResult, Verdict, AgentState (the continue-as-new snapshot) |
agentrun/lanes.py, agentrun/search_attributes.py |
agent-workflows / cpu-tools / gpu-tools; AgentStatus / CurrentStep / Owner |
worker_workflows.py, worker_cpu.py, worker_gpu.py |
one process per lane, as in lab 10 |
starter.py, control.py |
start a run; drive it (status, pause, resume, cancel-run, cancel, inject, goal) |
setup_search_attributes.py |
same as lab 10; run once per dev server |
run_200_steps.py |
11.2: the long run, the Run ID chain, the history bound |
tests/test_failure_injection.py |
11.3: crash, timeout, duplicate, both cancels, control surface, continue-as-new bound |
DEFENSE.md, DEFENSE_ANSWERS.md |
11.4: the template you fill, and the answers |
console/ |
11.5: the evaluation console, a product on a Workflow (EvalRun, SQLite projection, FastAPI + SSE) |
docker compose up -d && cd 11-capstone-durable-agent-runtime # from the temporal-course-labs folder
export LAB_PREFIX=lab11- # shared dev server only
python setup_search_attributes.py
Open this lab in the browser workspace
Complete the one-time setup in Lab 1, then open
Lab 11 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: assemble the AgentRun you built one capability at a time into one runnable program on separate lanes; run it for 200 steps and prove no run's history grew; break it every way the requirements say it will be broken and watch it recover; then defend every box in writing. Reading page: "Capstone — A Durable Agent Runtime" (module 11 of the course).
11.1 Assemble it, run it, kill it
Read agentrun/workflows.py top to bottom before running anything. Check that you can point at
each of these without searching: the decision (plan()), the three effects and the lane each names,
the child, the four Signals, the Query, the Update and its validator, the snapshot, the
continue-as-new condition, the two cancellation shapes, the compensation list.
Three pools, one terminal each, then a run:
python worker_workflows.py # workflow worker pid=… polling 'lab11-agent-workflows': AgentRun, ResearchAgent; kill -9 … to crash it
python worker_cpu.py # cpu worker pid=… polling 'lab11-cpu-tools': call_llm, execute_tool, evaluate, compensate
python worker_gpu.py # gpu worker pid=… polling 'lab11-gpu-tools' with 1 slot(s)
python starter.py --id agent-42 --tier enterprise --max-steps 8
plan() cycles llm → search (cpu) → embed (gpu) → research-or-llm; every fourth cycle the fourth
step is a ResearchAgent child. Observed for 8 steps: status done, steps 8, children_this_run
['lab11-agent-42/research/3'], Search Attributes {'AgentStatus': 'done', 'CurrentStep': 8, 'Owner': 'acct_7f3a'},
and in the history one START_CHILD_WORKFLOW_EXECUTION_INITIATED / CHILD_WORKFLOW_EXECUTION_STARTED /
CHILD_WORKFLOW_EXECUTION_COMPLETED triple. Open the child in the UI: its id is the parent's id plus
/research/3, and it has a history of its own.
Drive it. Start a longer one and use control.py while it runs. At full speed eight steps take
about three seconds, too fast to pause by hand, so first slow the fake tools down. They run in the
Activity Workers, so that is where the setting goes: Ctrl-C worker_cpu.py and worker_gpu.py and
restart both with it.
AGENTRUN_TOOL_SECONDS=1.5 python worker_cpu.py # in the CPU terminal
AGENTRUN_TOOL_SECONDS=1.5 python worker_gpu.py # in the GPU terminal
python starter.py --id agent-42 --max-steps 8 --no-wait
python control.py pause; python control.py status # paused True, status paused, step N
python control.py goal "" --command-id c0 # WorkflowUpdateFailedError: rejected by the validator, never in history
python control.py goal "write the defense document" --command-id c1 # update result: goal changed from … to …
python control.py goal "write the defense document" --command-id c1 # update result: ignored: command c1 already applied
python control.py inject "the user is in a hurry" --command-id c2 # twice: the second is a no-op
python control.py status # goal …, processed_command_ids ['c1', 'c2']
python control.py resume
All of the above is what was observed. In the history: WORKFLOW_EXECUTION_SIGNALED ×4 (pause,
inject, inject, resume), WORKFLOW_EXECUTION_UPDATE_ACCEPTED ×2 and …_COMPLETED ×2 — for c1
twice; the rejected c0 left nothing, and the second c1 was accepted (the validator does not see
the dedupe set; the handler does) and returned ignored.
Kill it. kill -9 the workflow worker while a run is mid-way, start a new one, and read the
handoff off the history:
python starter.py --id agent-kill --max-steps 12 --no-wait
python control.py status --id agent-kill # step 6
kill -9 <workflow worker pid>; python worker_workflows.py
Observed, summarised from the history's Workflow Task events (the Web UI, or fetch_history in
Python; dev server 1.31.2):
first WFT by wf-3821487@lab11-agent-workflows at event 3 09:21:04.190
73 WORKFLOW_TASK_TIMED_OUT timeout_type=SCHEDULE_TO_START
first WFT by wf-3821651@lab11-agent-workflows at event 75 09:21:17.704
WFT scheduled 09:21:17.681 (event 74) → started by the new worker +0.0s
result: steps=12 status=done
The first Workflow Task after the crash went to the dead Worker's sticky queue (the one it used
to keep the Workflow cached), sat there for the sticky Schedule-to-Start timeout (~10 s here), timed
out — that is the WORKFLOW_TASK_TIMED_OUT you see — and was rescheduled to the shared
agent-workflows queue, where the new Worker took it 23 ms later, replayed 73 events, and continued
from step 6. In this Workflow-Worker-only crash, no completed Activity was executed
again — only Workflow code replayed, and replay does not re-run Activities whose results are
already in history. That is not a general exactly-once guarantee: module 4's at-least-once
semantics are unchanged, and an Activity Worker dying mid-Activity is the case that does re-run it. That ten-second gap is the price of the
cache, and it is the only cost of the crash.
11.2 Two hundred steps, one Workflow ID
Stop the three pools from 11.1 first (Ctrl-C each). The script hosts its own Workflow, CPU and GPU Workers in-process on the same queues, and any of yours still running takes a share of the work: the results come out the same, but slowed tool Workers make the run take twice as long.
python run_200_steps.py --steps 200 --id agent-200 # hosts the three pools in-process; --external-workers to use yours
STEPS_PER_RUN (env AGENTRUN_STEPS_PER_RUN, default 50) is the cadence; is_continue_as_new_suggested()
is the other trigger and stays in the code as the rule — the cadence is the lab's way of making the
event visible in 200 steps. Observed (36.8 s, AGENTRUN_TOOL_SECONDS=0.02):
run run_id events closed as
1 01a08fc4-a309-7a41-b02e-83f518ba7cca 516 CONTINUED_AS_NEW
2 5e477cf4-784c-41e0-bcca-939e44c330bf 519 CONTINUED_AS_NEW
3 198e1b37-91c5-4f33-9c62-cfcfa449ebef 516 CONTINUED_AS_NEW
4 25e2af5a-d4fc-461c-90d7-5fc3bc0c906d 516 COMPLETED
one Workflow ID, 4 Run IDs; longest history 519 events; bound 700
latest run: status=COMPLETED search attributes={'AgentStatus': 'done', 'CurrentStep': 200, 'Owner': 'acct_7f3a'} memo tier=team
ResearchAgent children under lab11-agent-200/research/: 13
The script asserts the bound with fetch_history() on every run it finds by following
WorkflowExecutionContinuedAsNew.new_execution_run_id from the first run. About ten events per
step; 50 steps is ~520 events, comfortably under the bound and two orders of magnitude under the
history limits (as of 2026-09, see upstream — the rule is the suggestion flag, not the number).
Owner (set at start), AgentStatus/CurrentStep (upserted) and the tier/max_steps memo all
carried over each continue-as-new without being passed again: continue_as_new(args=[goal, snapshot])
inherits memo and Search Attributes unless you override them. In the UI, the Workflow ID shows one
execution with four runs; WorkflowId STARTS_WITH 'lab11-agent-200' lists the parent and 13 children.
One design bug shipped and fixed while writing this: the loop used to decide "continue-as-new" before
asking plan() whether it was done, so a 200-step run ended with a fifth run of 7 events whose only
job was to notice it had nothing to do. Plan first; continue only if there is more work.
11.3 The failure-injection suite
python -m pytest tests/ # 7 passed in 13.45s
Runs on the SDK's time-skipping test server (no Docker); run it from this directory, one lab per
pytest invocation (lab 10 and lab 11 both have a flat worker_gpu module; collected together they
import each other's and hang). Each test injects one failure by
registering its own execute_tool under the same Activity name — the Workflow is untouched:
| Test | Injection | What it asserts |
|---|---|---|
test_worker_crash_mid_run_loses_no_work_and_spends_nothing_twice |
all three pools cancelled mid-GPU-step; new pools with new identities | run finishes 6/6; the fake LLM ledger records one bill per key and the fake tool ledger one effect per key; both Worker identities in the history; AgentStatus=done. A real provider may still charge before a gateway records its response. Also: with no Worker alive a Query hits its deadline (Query deadline of 1999 milliseconds exceeded) while Describe answers — a Query is computed by a Worker |
test_stalled_attempt_times_out_on_heartbeat_and_the_retry_resumes |
attempt 1 heartbeats to tick 2, then hangs | attempt 2 reads the delivered heartbeat details and resumes from tick 2; attempts == 1; one effect; no ACTIVITY_TASK_TIMED_OUT event — a retried timeout is not history |
test_completion_lost_after_the_effect_is_not_a_second_effect |
attempt 1 does the work, writes the ledger, raises | one effect; attempt 2 returns duplicate=True; no ACTIVITY_TASK_FAILED event |
test_cancel_run_signal_finishes_the_step_then_stops_cleanly |
cancel_run Signal |
completes (not fails) with status cancelled; nothing to compensate at a decision point |
test_temporal_cancel_during_the_gpu_step_runs_the_compensation |
handle.cancel() while a heartbeating GPU tool runs |
WorkflowFailureError with CancelledError; compensate("release_gpu:…") is the last Activity scheduled; AgentStatus=cancelled; closed CANCELED |
test_pause_update_validator_and_command_dedupe |
the control surface | as in 11.1; UPDATE_ACCEPTED ×2, SIGNALED ×4 |
test_continue_as_new_keeps_every_run_under_the_bound |
20 steps at STEPS_PER_RUN=8 |
runs close CONTINUED_AS_NEW, CONTINUED_AS_NEW, COMPLETED; three Run IDs; every run ≤ 112 events; SAs and memo on the last run |
Two things the suite taught while being written, both now in the code:
- Cancellation has two shapes. If the Workflow is waiting on a timer or condition,
handle.cancel()arrives asasyncio.CancelledError. If it is waiting on an Activity or child, the Activity is asked to cancel first and the Workflow gets anActivityErrorwhose cause is Temporal'sCancelledError. The first version ofrun()caught only the first shape; the second went straight through, and the dev server recordedWORKFLOW_EXECUTION_CANCEL_REQUESTED → ACTIVITY_TASK_CANCEL_REQUESTED → WORKFLOW_EXECUTION_CANCELEDin one Workflow Task — no compensation,AgentStatusstillrunning.temporalio.exceptions.is_cancelled_exception()recognises both; cleanup Activities run fine afterwards because the SDK cleared the task's cancellation when it sent the cancel command. - The test server does not time out a dead Worker's sticky queue. The crash test's first version
hung: after the crash, the next Workflow Task sat in pool A's sticky queue for 30 s+ (on the dev
server it moved after ~10 s, above). The dying pool now runs with
max_cached_workflows=0, so every Workflow Task goes to the shared queue and is a full replay — which is also the more honest demonstration.
Not in the suite: a v2 deploy mid-run (module 9's exercise; the code here is v1 only) and the Nexus paragraph. Both are named in the reading; neither was verified here.
11.4 The defense
Fill in DEFENSE.md — the 12-row requirement→mechanism table, the six-row failure column, the
four-layer boundary, the ten questions, and your own evidence — without opening DEFENSE_ANSWERS.md.
Then diff. A row you could not fill from memory names the module to reread. The evidence section is
not optional: paste your Run ID list, your kill -9 handoff events, and your pytest line.
11.5 From a Workflow to a product: the evaluation console
Appendix A4 ("From a Workflow to a product") argues six decisions. This slice runs them. It is a different product from AgentRun on purpose: a model-evaluation console. A user submits a suite of benchmark cases against a model, watches the run progress live, cancels it, retries only the failed cases, and a reviewer approves the results before they publish. Same skills, new nouns.
| File | What it is |
|---|---|
console/workflows.py |
EvalRun: cases fan out as run_case Activities on the gpu-eval lane; every state change is projected by record_run_event; approve is a validated Update; handle.cancel() is Temporal cancellation |
console/activities.py |
run_case (fake GPU: heartbeating ticks, a seeded benchmark-failure rate that is recorded, a hiccup rate the RetryPolicy absorbs) and record_run_event |
console/projection.py |
the read model in SQLite: runs, run_events(run_id, seq); the idempotent upsert, keyset pagination, SSE framing |
console/api.py |
FastAPI: POST /runs, GET /runs, GET /runs/{id}, GET /runs/{id}/events (SSE), POST .../cancel, .../retry-failed, .../approve |
console/worker.py, console/worker_gpu.py |
the console lane (Workflow + projection) and the gpu-eval lane (run_case, GPU_SLOTS slots) |
console/setup_search_attributes.py |
registers Org, RunStatus, Model, EvalParentRunId |
console/tests/test_console.py |
the contract as six tests (time-skipping server, no Docker) |
Two ownership rules carry the whole design. Temporal owns what is true; SQLite owns what the UI
reads. The console never lists runs from Visibility: Visibility is eventually consistent and
rate-limited, and it is the operator's view, not the product's. And the Workflow owns the even
sequence numbers, the console the odd ones. The Workflow projects every state change as seq
2·n; the console writes its own events, cancel_requested and retry_requested, at the next odd
number, so the two can never collide.
cd 11-capstone-durable-agent-runtime/console # from the temporal-course-labs folder
pip install -r ../../requirements.txt # adds fastapi + uvicorn
export LAB_PREFIX=lab115- # shared dev server only
python setup_search_attributes.py
python worker.py & # console lane
python worker_gpu.py & # gpu-eval lane
uvicorn api:app --port 8115 &
H='-H X-Org:acme -H Content-Type:application/json'
curl -s $H -X POST localhost:8115/runs -d '{"run_id":"r1","cases":12,"fail_fraction":0.25}'
curl -sN $H localhost:8115/runs/lab115-r1/events # SSE; Ctrl-C, then resume:
curl -sN $H 'localhost:8115/runs/lab115-r1/events?after=6' # or send Last-Event-ID: 6
curl -s $H localhost:8115/runs # the list, from SQLite
curl -s $H -X POST localhost:8115/runs/lab115-r1/retry-failed
curl -s $H -X POST localhost:8115/runs/lab115-r1/approve -d '{"reviewer":"alice","command_id":"c1"}'
Do each of these and write down what you see before reading the recorded result.
- Reconnect the stream. Disconnect mid-run and reconnect with
?after=<last id>. Count duplicates and gaps. - Another tenant. Read the run with
X-Org: globex. - The stranger and the double click. Approve as
mallory, then asalice, then asaliceagain. - Retry twice. Press Retry-failed twice in quick succession.
- Cancel during a case. Start a 40-case run with
fail_fraction0, cancel after two seconds, and timecancelling→cancelled. - Kill between a write and its completion. Stop
worker.pywithkill -9while cases are finishing and restart it. Then countcase_donerows per case inrun_events.
Recorded on the course dev server (Temporal CLI dev server, temporalio 1.32.0, 2026-09-11):
| Step | What happened |
|---|---|
| 1 | Resuming after seq 6 delivered seq 8, 10, … 30 and then stayed open at awaiting_review: a run waiting for review is not finished, so the stream ends with event: end only at published, cancelled or failed (approve it and watch the end arrive). Nothing was sent twice and nothing was skipped. Last-Event-ID: 26 works the same way. |
| 2 | 404. Another org's run is indistinguishable from no run. |
| 3 | mallory got 422 approve rejected: 'mallory' is not a reviewer for org 'acme', and the rejection left no Event in history. alice got 200 published by alice (command c1). The second alice got 409 run is already published. |
| 4 | The first press started lab115-r1-retry-1 over the three failed cases with parent_run_id in its Memo and EvalParentRunId set. The second got 409 ... already exists: the Workflow ID is the dedupe, so a race between two clicks still starts one child. |
| 5 | cancelling showed at once from the console's own cancel_requested event. cancelled followed about a second later (1.3 s here) with 8 of 40 cases done — both numbers move with your timing; in-flight cases stopped at their next heartbeat. |
| 6 | Killed at 8 of 24 cases done and restarted 12 s later, the run reached awaiting_review with 24 case_done rows for 24 cases and gapless even sequence numbers. Whether a write lands in the window between INSERT and completion depends on timing, so the test suite forces it: the first attempt of two writes raises after the INSERT, the retries insert nothing, and there is still one row per case. |
python -m pytest tests -q # 7 passed
Where the slice stops. SQLite stands in for Postgres. X-Org stands in for authentication.
The fake GPU's per-process ledger stands in for a results table. The UI is curl. None of that
changes a decision, and each one names the component you would put in its place.
The recurring question, one last time
If every Worker process dies right now: everything AgentRun decided, every recorded result, every
pending timer, Signal and child, every lane's backlog survive in the Service. The next Worker on each
lane replays or resumes; nothing is re-spent, nothing is lost, nothing was waiting in a process.
You have now watched that sentence be true seven ways in a test suite and once with kill -9.
What you learned
- Durable decisions, effects, control messages, children, compensations, and lane routing fit into one AgentRun.
- A bounded sequence of Runs preserves one Workflow ID while each Run keeps a manageable history.
- Recovery depends on recorded history plus your own idempotency ledger for external effects.
Questions to answer
- After every Worker dies, which facts live in Temporal and which live in your effect ledger?
- What evidence shows the 200-step agent avoided one unbounded history?
- Which Worker pool must return for a queued GPU tool, and which can still serve Queries?
- How do the failure-injection tests prove that replay did not repeat a paid or irreversible effect?
Files
Every file in this lab, as committed. Copy or clone; nothing here is hidden.
11-capstone-durable-agent-runtime/DEFENSE.md
# AgentRun — architecture defense (template, lab 11.4) Fill this in from memory, then check against `DEFENSE_ANSWERS.md`. One line per cell. If a cell takes more than one line, you have not found the mechanism yet — you have found the module. Author: ______________ Date: __________ Server version you ran it on: ______________ ## 1. The failure contract (one line per box) Write this before the diagram. For every box, answer eight questions: What survives a Worker death? Can this operation happen twice? Where is its durable identity? Who retries it? Who cancels it? What happens across a deployment? What goes into Event History? How does it Continue-As-New? The table holds the three that fit in a line; the others land in section 2 (who retries it, across a deployment, continue-as-new) and in the ten questions (who cancels it). | Box | A Worker dies here | On retry | What history records | |---|---|---|---| | `plan()` | | | | | `call_llm` (LLM Activity) | | | | | `execute_tool` (Tool Activity) | | | | | `ResearchAgent` (child) | | | | | pause wait (`wait_condition`) | | | | | `continue_as_new` | | | | ## 2. Requirement → mechanism (12 rows) For each requirement: the *one* Temporal mechanism that satisfies it, the name of the box in the diagram (a method, an Activity, a handler, a call), and the lab where you saw it fail and recover. | # | Requirement | Mechanism | Box in AgentRun | Where you saw it | |---|---|---|---|---| | 1 | Runs for up to 72 hours | | | | | 2 | Makes 1,000+ model calls | | | | | 3 | Tool calls can fail | | | | | 4 | Expensive calls must not accidentally duplicate | | | | | 5 | GPUs disappear | | | | | 6 | Workers deploy twice a day; old executions survive | | | | | 7 | User can pause and resume | | | | | 8 | User can change instructions mid-run | | | | | 9 | User can ask what it is doing | | | | | 10 | May delegate to sub-agents | | | | | 11 | History cannot grow forever | | | | | 12 | Operators need exact execution diagnostics | | | | ## 3. Where Temporal ends | Layer | Owns | For AgentRun, concretely | |---|---|---| | Temporal | | | | Kubernetes | | | | Kafka | | | | Postgres | | | ## 4. The ten questions, and one from 11.5 (one sentence each) 1. What exactly survives when a Worker crashes? 2. How does replay reconstruct Workflow state? 3. Why must Workflow code be deterministic? 4. Why can an Activity execute twice? 5. Activity retry vs. Workflow replay? 6. Signal vs. Query vs. Update? 7. Activity vs. Child Workflow? 8. When should you continue-as-new? 9. How do you safely deploy changed Workflow code? 10. Where does Temporal end and Kubernetes / Kafka / Postgres begin? 11. (11.5) What does the user see, and from which store? (product projection, never the visibility list) ## 5. Evidence Paste, for your own run: the Run ID list from `run_200_steps.py`, the `kill -9` handoff events, and `pytest` output from `tests/`. Anything you cannot paste, you have not defended.
11-capstone-durable-agent-runtime/DEFENSE_ANSWERS.md
# AgentRun — architecture defense (answers) Filled in from the course design notes and from what the lab 11 code and tests actually do. Where a claim is about upstream behaviour it carries "as of 2026-09"; where it is about this lab it names the file or test that shows it. ## 1. The failure contract | Box | A Worker dies here | On retry | What history records | |---|---|---|---| | `plan()` | Replayed from history on the next Worker; nothing is lost because nothing happened outside history | Not applicable — it is a decision | Commands only (the Activity it scheduled, the timer it started) | | `call_llm` | The attempt fails at Start-to-Close or heartbeat; the Activity is retried with the same key | The lab fake returns its cached answer. In production, a gateway ledger alone cannot rule out a provider charge that succeeded just before the gateway crashed; use provider-side idempotency or reconcile an ambiguous outcome before another paid call | `ActivityTaskScheduled` / `Started` / `Completed` with the result; the failed attempt is *not* an event | | `execute_tool` | Heartbeat timeout fails the attempt quickly; the next attempt receives the last delivered heartbeat details | Activity code reads those details and decides what to repeat; a completed-but-unreported effect comes back from the ledger as `duplicate=True` | Same three events, plus heartbeat details in Describe while pending | | `ResearchAgent` | The child replays on its own; the parent does not notice | The child's own retry policy | `StartChildWorkflowExecutionInitiated` / `ChildWorkflowExecutionStarted` / `Completed` in the parent | | pause wait | The condition is Workflow state; nothing polls while paused; the next Worker replays into the same wait | Not applicable | `WorkflowExecutionSignaled` (pause, resume); `UpsertWorkflowSearchAttributes` for `paused` | | `continue_as_new` | The new run starts atomically as the old one closes; state arrives as the next run's arguments | Not applicable | `WorkflowExecutionContinuedAsNew` with `new_execution_run_id`; the new run's `WorkflowExecutionStarted` carries the snapshot | Observed on the dev server (server 1.31.2, SDK 1.32.0): a `kill -9` of the workflow Worker at step 6 of 12 produced `WORKFLOW_TASK_TIMED_OUT` (sticky Schedule-to-Start, ~10 s), a rescheduled Workflow Task, and the new Worker's first task 23 ms later; the run finished with every Activity executed once. ## 2. Requirement → mechanism | # | Requirement | Mechanism | Box in AgentRun | Where you saw it | |---|---|---|---|---| | 1 | Runs for up to 72 hours | A Workflow Execution outlives every process. Waiting is a durable timer or `wait_condition`; no Worker is occupied while waiting | `run()` loop; `wait_condition(not paused)` | L01 (`kill -9` during the sleep); 11.1 `kill -9` handoff | | 2 | Makes 1,000+ model calls | Each call is an Activity whose result is recorded once; the loop is ordinary code over recorded results | `call_llm` on `cpu-tools` (production: `premium-models`) | 11.2 — 200 steps, 4 Run IDs, every result in history | | 3 | Tool calls can fail | `RetryPolicy` (bad arguments non-retryable) + Start-to-Close + Schedule-to-Close + heartbeat timeout; a retry is server-side, invisible to the caller | `execute_tool` with `TOOL_RETRY`, `TOOL_START_TO_CLOSE`, `TOOL_HEARTBEAT` | `test_stalled_attempt_times_out_on_heartbeat_and_the_retry_resumes` — attempt 2 resumed from heartbeat tick 2; no timeout event | | 4 | Expensive calls must not accidentally duplicate | Idempotency key `wf_id:step:hash(...)` chosen in Workflow code, so a retry presents the same key; the lab fake keeps a ledger by key. A real provider also needs to honor that key or support result lookup and reconciliation after an ambiguous outcome | `_llm()` / `_tool()` build the key; `LLM_CACHE`, `TOOL_LEDGER` in `activities.py` | `test_completion_lost_after_the_effect_is_not_a_second_effect` — one effect, `duplicate=True` on attempt 2 in the fake ledger | | 5 | GPUs disappear | A `gpu-tools` lane with Schedule-to-Start (nobody took it) and heartbeat (somebody died holding it) timeouts; the L07 `compensations` list to release what a cancelled run holds | `_tool()` for `GPU_TOOL_NAMES`; `compensations`; `_compensate()` | Lab 10.1 stall (`ACTIVITY_TASK_TIMED_OUT ScheduleToStart`); `test_temporal_cancel_during_the_gpu_step_runs_the_compensation` | | 6 | Workers deploy twice a day; old executions survive | Worker Versioning: executions pinned to the version that started them, upgrade at continue-as-new (GA 2026-03-30; upgrade-on-CaN Public Preview, as of 2026-09); `workflow.patched` for in-place changes | L09's `verify` step; not exercised in lab 11 code | L09 (v1 and v2 side by side) | | 7 | User can pause and resume | Signals set a flag; the loop waits on `wait_condition`; a paused Workflow consumes no Worker compute while waiting | `pause` / `resume` / `cancel_run` handlers | `test_pause_update_validator_and_command_dedupe`; lab 10.2 fleet query `AgentStatus = 'paused'` | | 8 | User can change instructions mid-run | Update: a tracked write with a validator (rejected = never in history) and a `command_id` so a retried Update is applied once | `change_goal` + `@change_goal.validator`; `processed_command_ids` | same test — empty goal → `WorkflowUpdateFailedError`; `c1` twice → `ignored`; history has `UPDATE_ACCEPTED ×2`, none for `c0` | | 9 | User can ask what it is doing | Query: a read of Workflow state, answered by a Worker from replayed state, never recorded | `status()` | every lab; `test_worker_crash…` shows a Query needs a Worker (deadline exceeded with none alive) while `describe()` does not | | 10 | May delegate to sub-agents | Child Workflow: its own history, replay and retry; the parent sees one result and a stable id | `ResearchAgent`, id `f"{wf_id}/research/{step}"` | 11.2 — 13 children under `agent-200/research/` | | 11 | History cannot grow forever | `continue_as_new(AgentState)` when `is_continue_as_new_suggested()` or every `STEPS_PER_RUN` steps; memo and Search Attributes carry over | `_should_continue_as_new()`, `snapshot()`, `run(goal, state)` | 11.2 — longest run 519 events for 50 steps; `test_continue_as_new_keeps_every_run_under_the_bound` | | 12 | Operators need exact execution diagnostics | History (what happened), Describe (what Temporal thinks, pending attempts, heartbeat details), Search Attributes for finding, `DescribeTaskQueue` for lanes | `_publish()` → `AgentStatus`/`CurrentStep`; `Owner` at start | Lab 10.2 five questions; `five_questions.py` | ## 3. Where Temporal ends | Layer | Owns | For AgentRun, concretely | |---|---|---| | Temporal | Where execution logically is: the step, the outstanding attempt, the pending timer, the lane a Task waits on | `agent-42` is at step 137, attempt 2 of `embed` is outstanding on `gpu-tools`, paused since Tuesday | | Kubernetes | Where processes run: one Deployment per lane, autoscaled on that lane's backlog age; pod identity in the history | `gpu-tools` pods on the GPU node pool; `Worker(identity=f"{pod}@{lane}")` | | Kafka | Streams between systems — not execution, not history | Outside events reach the run as Signals (a consumer calling Signal-With-Start); the run emits through an Activity that produces | | Postgres | The system of record for results: what the provider charged, what the customer owns, whether the answer was good | The ledgers in `activities.py` are its stand-in: rows keyed by the idempotency keys, written by Activities, outliving retention | The engine that rendered this course draws the same line: `MiniLectureCourseWorkflow` keeps the legs, retries, heartbeats, yields and the review checkpoint in Temporal, and leaves the job store, `agent_state.json`, artifacts, `usage.json` and the verifier database outside it. History answers what execution happened; it does not answer whether the lecture was good. ## 4. The ten questions, and one from 11.5 1. **What survives a Worker crash?** Everything in the Service: the Event History (every decision and every recorded result), pending timers, queued Tasks with their attempt counts, Signals, Search Attributes. Nothing that lived only in the process — and the program was written so nothing does. 2. **How does replay reconstruct state?** A fresh Worker runs the Workflow code from the top, and every time the code asks for something (an Activity result, a timer), the SDK answers from history instead of doing it again; the code arrives at the same state having executed no effect. 3. **Why deterministic?** Because replay must make the same decisions in the same order to match the history; a decision that depends on the clock, randomness or a network call would diverge and the SDK would (rightly) refuse to continue. 4. **Why can an Activity execute twice?** Because the Service cannot tell "the Worker died before starting" from "the Worker did the work and died before reporting"; it retries, so at-least-once is the guarantee and idempotency by key is your job. 5. **Activity retry vs. Workflow replay?** A retry re-executes an *effect* (server-side, invisible in history until the final outcome); a replay re-executes *decisions* with no effects at all. Retries cost; replays are free. 6. **Signal vs. Query vs. Update?** Signal: an asynchronous write, recorded, no reply. Query: a read of current state, answered by a Worker, not recorded. Update: a tracked synchronous write that can return a result; a validator is optional, and an accepted Update is recorded. 7. **Activity vs. Child Workflow?** An Activity is one effect with timeouts and retries and no state of its own; a Child Workflow is its own durable program with its own history, replay, Signals and children — use it when the work has decisions, not just an effect. 8. **When continue-as-new?** When the Service says so (`is_continue_as_new_suggested()`), and on a cadence you choose; carry a snapshot of what `plan()` needs, never the history; drain handlers first. 9. **How to deploy changed code?** Never change a decision an old history already recorded: pin running executions to the version that started them and upgrade at continue-as-new (Worker Versioning), or `patched()` for a change that must apply in place. 10. **Where does Temporal end?** Temporal owns where execution logically is; Kubernetes owns where processes run; Kafka owns streams; Postgres owns the results and the money. The boundary is the row your Activity writes, keyed by the idempotency key. 11. **What does the user see, and from which store?** The product's own read model: the `runs` and `run_events` rows the Workflow's projection Activity writes after every state change, served by the API and its event stream. Never the visibility list — it is eventually consistent, knows nothing about orgs, and is Temporal's operator view, not a product API; the Workflow is the source of truth and the projection is how the product reads it.
11-capstone-durable-agent-runtime/agentrun/__init__.py
"""AgentRun, assembled: the one program this course built, as one runnable package. agentrun.workflows AgentRun, ResearchAgent (Workflow code: decisions) agentrun.activities call_llm, execute_tool, evaluate, compensate (effects, faked, keyed) agentrun.models Step, ToolCall, ToolResult, Verdict, AgentState agentrun.lanes agent-workflows / cpu-tools / gpu-tools agentrun.search_attributes AgentStatus / CurrentStep / Owner """ from .activities import call_llm, compensate, evaluate, execute_tool # noqa: F401 from .lanes import AGENT_WORKFLOWS, CPU_TOOLS, GPU_TOOLS, workflow_id # noqa: F401 from .models import AgentState, Step, ToolCall, ToolResult, Verdict # noqa: F401 from .search_attributes import AGENT_STATUS, CURRENT_STEP, OWNER # noqa: F401 from .workflows import AgentRun, ResearchAgent # noqa: F401
11-capstone-durable-agent-runtime/agentrun/activities.py
"""The effects, faked. Every one is idempotent by key, and every one records what it did in a module-level ledger so a test (or you) can count real work against attempts. In production the ledgers are a table keyed by the idempotency key — the rows Postgres owns (module 10's tenth question). Here they live in the Worker process, which is exactly enough to show the mechanism: a retry with the same key finds the row and does not spend again. """ from __future__ import annotations import asyncio import os from collections import defaultdict from dataclasses import replace from temporalio import activity from .models import ToolCall, ToolResult, Verdict LLM_SECONDS = float(os.environ.get("AGENTRUN_LLM_SECONDS", "0.05")) TOOL_SECONDS = float(os.environ.get("AGENTRUN_TOOL_SECONDS", "0.2")) HEARTBEAT_TICKS = 4 # heartbeats per tool call; details carry the tick reached LLM_CACHE: dict[str, str] = {} # key → answer LLM_CALLS: list[str] = [] # every *billed* call, by key TOOL_LEDGER: dict[str, ToolResult] = {} # key → the result of the one time the effect happened TOOL_SIDE_EFFECTS: dict[str, int] = defaultdict(int) # key → how many times the effect really ran COMPENSATED: list[str] = [] # compensations that ran, in order def reset_ledgers() -> None: LLM_CACHE.clear(); LLM_CALLS.clear(); TOOL_LEDGER.clear(); TOOL_SIDE_EFFECTS.clear(); COMPENSATED.clear() @activity.defn async def call_llm(prompt: str, key: str) -> str: """One bill per key. A retry that presents the same key gets the cached answer.""" if key in LLM_CACHE: activity.logger.info("call_llm cache hit key=%s attempt=%d", key, activity.info().attempt) return LLM_CACHE[key] await asyncio.sleep(LLM_SECONDS) LLM_CALLS.append(key) answer = f"[answer #{len(LLM_CALLS)} to: {prompt[:48]}]" LLM_CACHE[key] = answer return answer @activity.defn async def execute_tool(call: ToolCall) -> ToolResult: """Keyed, heartbeating, resumable. The effect is written to the ledger *last*, so an attempt that dies half-way leaves no row and the retry redoes the work; an attempt that finished the work but whose completion never reached the Service leaves a row and the retry returns it.""" info = activity.info() if call.key in TOOL_LEDGER: activity.logger.info("execute_tool duplicate suppressed key=%s attempt=%d", call.key, info.attempt) return replace(TOOL_LEDGER[call.key], attempt=info.attempt, duplicate=True) start_tick = 0 if info.heartbeat_details: # a previous attempt of this same Activity got this far start_tick = int(info.heartbeat_details[0].get("tick", 0)) for tick in range(start_tick, HEARTBEAT_TICKS): activity.heartbeat({"tick": tick + 1, "of": HEARTBEAT_TICKS}) await asyncio.sleep(TOOL_SECONDS / HEARTBEAT_TICKS) TOOL_SIDE_EFFECTS[call.key] += 1 result = ToolResult(call.key, call.tool, f"[{call.tool} output for {call.args}]", info.attempt, info.task_queue) TOOL_LEDGER[call.key] = result return result @activity.defn async def evaluate(result: ToolResult) -> Verdict: ok = bool(result.output) return Verdict(ok=ok, score=1.0 if ok else 0.0, note=f"{result.tool} from {result.task_queue} attempt {result.attempt}") @activity.defn async def compensate(name: str) -> str: """Undo one saga step (module 7). Idempotent: undoing twice is the same as once.""" if name not in COMPENSATED: COMPENSATED.append(name) return f"compensated {name}"
11-capstone-durable-agent-runtime/agentrun/lanes.py
"""Task Queue lanes (module 10). Spelled once; imported by the Workflow and by every Worker. Course names are the defaults; LAB_PREFIX=lab11- keeps a shared dev server tidy.""" from __future__ import annotations import os LAB_PREFIX = os.environ.get("LAB_PREFIX", "") AGENT_WORKFLOWS = os.environ.get("AGENT_WORKFLOWS_QUEUE", LAB_PREFIX + "agent-workflows") CPU_TOOLS = os.environ.get("CPU_TOOLS_QUEUE", LAB_PREFIX + "cpu-tools") GPU_TOOLS = os.environ.get("GPU_TOOLS_QUEUE", LAB_PREFIX + "gpu-tools") # `premium-models` and `browser-tools` are the same mechanism again; the lab keeps three lanes. CPU_TOOL_NAMES = frozenset({"search", "parse", "fetch"}) GPU_TOOL_NAMES = frozenset({"embed", "vision", "rerank"}) LANE_FOR_TOOL = {**{t: CPU_TOOLS for t in CPU_TOOL_NAMES}, **{t: GPU_TOOLS for t in GPU_TOOL_NAMES}} def workflow_id(base: str) -> str: return LAB_PREFIX + base
11-capstone-durable-agent-runtime/agentrun/models.py
"""The data that crosses the Workflow/Activity boundary. Plain dataclasses: the SDK serialises them as JSON, so every field is something a history can hold.""" from __future__ import annotations from dataclasses import dataclass, field @dataclass class Step: kind: str # "llm" | "tool" | "research" | "finish" prompt: str = "" tool: str = "" args: dict = field(default_factory=dict) question: str = "" @dataclass class ToolCall: key: str # idempotency key: wf_id:step:hash(tool, args) tool: str args: dict @dataclass class ToolResult: key: str tool: str output: str attempt: int # which attempt produced it (>1 means a retry happened) task_queue: str # the lane that answered duplicate: bool = False # True when served from the ledger: a retry after the effect happened @dataclass class Verdict: ok: bool score: float note: str @dataclass class AgentState: """The snapshot continue_as_new carries into the next run. Only what plan() needs to go on; never the history.""" step: int goal: str context_summary: str attempts: int processed_command_ids: list[str]
11-capstone-durable-agent-runtime/agentrun/search_attributes.py
"""Registered once per Namespace (setup_search_attributes.py); written by Workflow code.""" from temporalio.common import SearchAttributeKey AGENT_STATUS = SearchAttributeKey.for_keyword("AgentStatus") CURRENT_STEP = SearchAttributeKey.for_int("CurrentStep") OWNER = SearchAttributeKey.for_keyword("Owner")
11-capstone-durable-agent-runtime/agentrun/workflows.py
"""AgentRun, the full program. Every box of the capstone diagram is a method or a call here. plan() decision, Workflow code, pure function of state call_llm / execute_tool / Activities on lanes, keyed, timed out, retried, heartbeating evaluate ResearchAgent Child Workflow, id = f"{wf_id}/research/{step}" pause/resume/cancel_run/ Signals (async writes) inject_context change_goal Update with validator (tracked write); command_id dedupe status Query (read) continue_as_new(AgentState) when the Service suggests it, or every STEPS_PER_RUN steps AgentStatus/CurrentStep Search Attributes, upserted where state changes compensations the module 7 saga list, run in reverse on cancel """ from __future__ import annotations import asyncio import hashlib import os from datetime import timedelta from temporalio import workflow from temporalio.common import Priority, RetryPolicy from temporalio.exceptions import ActivityError, ApplicationError, TimeoutError, TimeoutType, is_cancelled_exception from .activities import call_llm, compensate, evaluate, execute_tool from .lanes import AGENT_WORKFLOWS, CPU_TOOLS, GPU_TOOL_NAMES, LANE_FOR_TOOL from .models import AgentState, Step, ToolCall, ToolResult, Verdict from .search_attributes import AGENT_STATUS, CURRENT_STEP, OWNER # Knobs. Course values in comments; the lab shrinks them so a 200-step run takes seconds. STEPS_PER_RUN = int(os.environ.get("AGENTRUN_STEPS_PER_RUN", "50")) # continue-as-new cadence RESEARCH_EVERY = int(os.environ.get("AGENTRUN_RESEARCH_EVERY", "4")) # a ResearchAgent child every N cycles DEFAULT_MAX_STEPS = 8 TOOL_START_TO_CLOSE = timedelta(seconds=float(os.environ.get("AGENTRUN_TOOL_START_TO_CLOSE_SECONDS", "60"))) # course: 30 min TOOL_HEARTBEAT = timedelta(seconds=float(os.environ.get("AGENTRUN_TOOL_HEARTBEAT_SECONDS", "5"))) # course: 30 s GPU_SCHEDULE_TO_START = timedelta(seconds=float(os.environ.get("AGENTRUN_GPU_SCHEDULE_TO_START_SECONDS", "120"))) # course: 5 min LLM_RETRY = RetryPolicy(initial_interval=timedelta(milliseconds=200), backoff_coefficient=2.0, maximum_interval=timedelta(seconds=5), maximum_attempts=5, non_retryable_error_types=["BadArguments"]) TOOL_RETRY = RetryPolicy(initial_interval=timedelta(milliseconds=200), backoff_coefficient=2.0, maximum_interval=timedelta(seconds=5), maximum_attempts=5, non_retryable_error_types=["BadArguments"]) PRIORITY_FOR_TIER = {"enterprise": 1, "team": 3, "batch": 5} # One weight per fairness key, and it does not move. Upstream is explicit: "There should only be # one fairness weight assigned to each fairness key within a Task Queue. Having multiple fairness # weights on a fairness key will result in unspecific behavior." The key here is the tenant, so the # weight has to belong to the tenant -- deriving it from the tier breaks that rule the moment one # tenant runs at two tiers. The tier is already expressed, once, as the priority key: priority for # the SLA tier, fairness for equity between tenants inside it. If weights genuinely must differ by # tier, make the key f"{tier}:{owner}" rather than moving the weight. # Empty means every tenant is equal. In production these are set without a deploy: # `temporal task-queue config set --fairness-key-weight <tenant>=<weight>`. FAIRNESS_WEIGHT_FOR_OWNER = {} DEFAULT_FAIRNESS_WEIGHT = 1.0 def _digest(*parts: str) -> str: return hashlib.sha256(":".join(parts).encode()).hexdigest()[:12] @workflow.defn class ResearchAgent: """The sub-agent (module 6). Its own history, its own replay; the parent sees one result.""" @workflow.run async def run(self, question: str) -> str: key = f"{workflow.info().workflow_id}:0:{_digest(question)}" return await workflow.execute_activity( call_llm, args=[f"research: {question}", key], task_queue=CPU_TOOLS, schedule_to_close_timeout=timedelta(minutes=2), start_to_close_timeout=timedelta(seconds=30), retry_policy=LLM_RETRY) @workflow.defn class AgentRun: def __init__(self) -> None: self.step = 0 self.status = "starting" self.goal = "" self.context_summary = "" self.attempts = 0 # retries that happened (sum of attempt-1 over results) self.paused = False self.cancel_requested = False self.processed_command_ids: set[str] = set() self.compensations: list[str] = [] self.owner = "" self.tier = "batch" self.max_steps = DEFAULT_MAX_STEPS self.run_started_at_step = 0 self.duplicates_suppressed = 0 self.children: list[str] = [] # ------------------------------------------------------------------ decisions def plan(self) -> Step: """Pure function of state. Never calls a model (module 3).""" if self.step >= self.max_steps: return Step("finish") phase = self.step % 4 if phase == 0: return Step("llm", prompt=f"step {self.step}: plan the next move toward: {self.goal}") if phase == 1: return Step("tool", tool="search", args={"q": self.goal, "step": self.step}) if phase == 2: return Step("tool", tool="embed", args={"text": self.context_summary[:40], "step": self.step}) if (self.step // 4) % RESEARCH_EVERY == 0: return Step("research", question=f"what is still missing for: {self.goal}") return Step("llm", prompt=f"step {self.step}: reflect on: {self.context_summary[:40]}") def snapshot(self) -> AgentState: return AgentState(self.step, self.goal, self.context_summary, self.attempts, sorted(self.processed_command_ids)) def _publish(self, status: str) -> None: self.status = status workflow.upsert_search_attributes([AGENT_STATUS.value_set(status), CURRENT_STEP.value_set(self.step)]) def _should_continue_as_new(self) -> bool: if self.step == self.run_started_at_step: return False # never on the first step of a run: that would loop forever return workflow.info().is_continue_as_new_suggested() or (self.step - self.run_started_at_step) >= STEPS_PER_RUN # ------------------------------------------------------------------ control surface @workflow.signal def pause(self) -> None: self.paused = True self._publish("paused") @workflow.signal def resume(self) -> None: self.paused = False self._publish("running") @workflow.signal def cancel_run(self) -> None: """Cooperative cancel: the loop notices at its next decision, compensates, returns cleanly. (handle.cancel() is the other kind — Temporal's — handled in run() below.)""" self.cancel_requested = True self.paused = False @workflow.signal def inject_context(self, text: str, command_id: str) -> None: if command_id in self.processed_command_ids: return # a retried Signal is delivered again; the command id makes the second delivery a no-op self.processed_command_ids.add(command_id) self.context_summary = f"{self.context_summary}\n[injected: {text}]" @workflow.query def status(self) -> dict: return {"step": self.step, "status": self.status, "goal": self.goal, "paused": self.paused, "attempts": self.attempts, "duplicates_suppressed": self.duplicates_suppressed, "compensations": list(self.compensations), "children": list(self.children), "processed_command_ids": sorted(self.processed_command_ids), "max_steps": self.max_steps, "run_id": workflow.info().run_id, "run_started_at_step": self.run_started_at_step, "history_length": workflow.info().get_current_history_length()} @workflow.update def change_goal(self, goal: str, command_id: str) -> str: if command_id in self.processed_command_ids: return f"ignored: command {command_id} already applied" self.processed_command_ids.add(command_id) old, self.goal = self.goal, goal self.context_summary = f"{self.context_summary}\n[goal changed: {goal}]" return f"goal changed from {old!r} to {goal!r}" @change_goal.validator def _validate_change_goal(self, goal: str, command_id: str) -> None: # Rejected here = never in history. Accepted = a tracked write the client sees complete. if not goal.strip(): raise ValueError("goal must not be empty") if len(goal) > 500: raise ValueError("goal longer than 500 characters") if not command_id: raise ValueError("command_id is required") # ------------------------------------------------------------------ effects async def _llm(self, step: Step) -> str: key = f"{workflow.info().workflow_id}:{self.step}:{_digest(step.prompt)}" return await workflow.execute_activity( call_llm, args=[step.prompt, key], task_queue=CPU_TOOLS, # production: premium-models schedule_to_close_timeout=timedelta(minutes=2), start_to_close_timeout=timedelta(seconds=30), retry_policy=LLM_RETRY) async def _tool(self, step: Step) -> ToolResult: call = ToolCall(f"{workflow.info().workflow_id}:{self.step}:{_digest(step.tool, repr(sorted(step.args.items())))}", step.tool, step.args) gpu = step.tool in GPU_TOOL_NAMES if gpu: # Module 7's saga in one line: what must be undone if this run is cancelled holding a GPU. self.compensations.append(f"release_gpu:{call.key}") result: ToolResult = await workflow.execute_activity( execute_tool, call, task_queue=LANE_FOR_TOOL[step.tool], schedule_to_start_timeout=GPU_SCHEDULE_TO_START if gpu else timedelta(minutes=2), start_to_close_timeout=TOOL_START_TO_CLOSE, heartbeat_timeout=TOOL_HEARTBEAT, retry_policy=TOOL_RETRY, priority=Priority(priority_key=PRIORITY_FOR_TIER[self.tier], fairness_key=self.owner or None, fairness_weight=FAIRNESS_WEIGHT_FOR_OWNER.get(self.owner, DEFAULT_FAIRNESS_WEIGHT)) if gpu else Priority()) if gpu: self.compensations.remove(f"release_gpu:{call.key}") self.attempts += result.attempt - 1 if result.duplicate: self.duplicates_suppressed += 1 verdict: Verdict = await workflow.execute_activity( evaluate, result, task_queue=CPU_TOOLS, start_to_close_timeout=timedelta(seconds=30), retry_policy=TOOL_RETRY) self.context_summary = f"{result.output} ({verdict.note})" return result async def _research(self, step: Step) -> str: child_id = f"{workflow.info().workflow_id}/research/{self.step}" self.children.append(child_id) answer = await workflow.execute_child_workflow( ResearchAgent.run, step.question, id=child_id, task_queue=AGENT_WORKFLOWS, execution_timeout=timedelta(minutes=10)) self.context_summary = f"{self.context_summary}\n[research: {answer}]" return answer async def _compensate(self) -> None: for name in reversed(self.compensations): await workflow.execute_activity(compensate, name, task_queue=CPU_TOOLS, start_to_close_timeout=timedelta(seconds=30), retry_policy=TOOL_RETRY) self.compensations.clear() async def _execute(self, step: Step) -> None: try: if step.kind == "llm": self.context_summary = await self._llm(step) elif step.kind == "tool": await self._tool(step) elif step.kind == "research": await self._research(step) except ActivityError as err: if isinstance(err.cause, TimeoutError) and err.cause.type == TimeoutType.SCHEDULE_TO_START: self._publish("stalled") raise ApplicationError(f"lane stalled: nobody took {step.tool} in time", type="LaneStalled", non_retryable=True) from err raise # ------------------------------------------------------------------ the loop @workflow.run async def run(self, goal: str, state: AgentState | None = None) -> dict: info = workflow.info() self.owner = info.typed_search_attributes.get(OWNER) or "" self.tier = workflow.memo_value("tier", "batch", type_hint=str) self.max_steps = workflow.memo_value("max_steps", DEFAULT_MAX_STEPS, type_hint=int) if state is None: self.goal = goal else: # a continued run: the snapshot is the whole past self.step, self.goal, self.context_summary, self.attempts = state.step, state.goal, state.context_summary, state.attempts self.processed_command_ids = set(state.processed_command_ids) self.run_started_at_step = self.step self._publish("running") try: while True: await workflow.wait_condition(lambda: not self.paused or self.cancel_requested) if self.cancel_requested: await self._compensate() self._publish("cancelled") return self._summary() step = self.plan() if step.kind == "finish": self._publish("done") return self._summary() if self._should_continue_as_new(): # more work to do, and this run has done its share await workflow.wait_condition(workflow.all_handlers_finished) # never drop an Update mid-flight self._publish("continuing") workflow.continue_as_new(args=[self.goal, self.snapshot()]) await self._execute(step) self.step += 1 self._publish("paused" if self.paused else "running") except BaseException as err: # Temporal cancellation (handle.cancel()) arrives in one of two shapes: asyncio.CancelledError if we were # waiting on a timer/condition, or an ActivityError/ChildWorkflowError whose cause is Temporal's # CancelledError if we were waiting on an Activity or child (it was told to cancel first). # `is_cancelled_exception` recognises both. Cleanup Activities still run here: the SDK cleared the task's # cancellation when it sent the cancel command, so the Workflow is not yet complete. if not is_cancelled_exception(err): raise await self._compensate() self._publish("cancelled") raise def _summary(self) -> dict: return {"status": self.status, "steps": self.step, "attempts": self.attempts, "duplicates_suppressed": self.duplicates_suppressed, "children_this_run": list(self.children), "context_tail": self.context_summary[-160:]}
11-capstone-durable-agent-runtime/console/activities.py
"""The two Activities. `run_case` is the fake GPU (gpu-eval lane); `record_run_event` is the projection (console lane) — the only code that writes SQLite from inside a Workflow's life. Both are idempotent by key: `run_case` by `run_id:case_id` (a ledger in the Worker process, the rows Postgres would own in production), `record_run_event` by `(run_id, seq)` (the SQLite primary key). """ from __future__ import annotations import asyncio import random from dataclasses import replace from typing import Any from temporalio import activity from temporalio.exceptions import ApplicationError import projection from config import CASE_SECONDS, CASE_TICKS, DB_PATH, HICCUP_FRACTION from models import CaseResult CASE_LEDGER: dict[str, CaseResult] = {} # key → the one time the case really ran CASE_EFFECTS: dict[str, int] = {} # key → how many times the GPU work really happened CANCELLED_AT_TICK: dict[str, int] = {} # key → tick at which a cancel was observed def reset_ledgers() -> None: CASE_LEDGER.clear(); CASE_EFFECTS.clear(); CANCELLED_AT_TICK.clear() @activity.defn async def run_case(run_id: str, case_id: str, fail_fraction: float = 0.25) -> CaseResult: """Evaluate one benchmark case on the (fake) GPU. - sleeps CASE_SECONDS in CASE_TICKS heartbeating ticks; a cancel is observed at the next heartbeat - a *benchmark failure* (fraction `fail_fraction`, seeded by run_id:case_id so a run is reproducible and a retry run — new run_id — rolls again) is `ApplicationError(type="CaseFailed", non_retryable=True)`: the Workflow records it, the RetryPolicy does not fight it - a *transient GPU hiccup* (HICCUP_FRACTION, seeded per attempt) is a plain retryable error: the RetryPolicy retries it and the history shows the attempt count, never a failed case - the ledger makes a retry after a lost completion return the recorded result (`duplicate=True`) """ info = activity.info() key = f"{run_id}:{case_id}" if key in CASE_LEDGER: activity.logger.info("run_case duplicate suppressed key=%s attempt=%d", key, info.attempt) return replace(CASE_LEDGER[key], attempt=info.attempt, duplicate=True) rng = random.Random(f"{key}:attempt{info.attempt}") if rng.random() < HICCUP_FRACTION: raise ApplicationError(f"GPU hiccup on {key} attempt {info.attempt}", type="GpuHiccup") start_tick = int(info.heartbeat_details[0].get("tick", 0)) if info.heartbeat_details else 0 try: for tick in range(start_tick, CASE_TICKS): activity.heartbeat({"tick": tick + 1, "of": CASE_TICKS}) await asyncio.sleep(CASE_SECONDS / CASE_TICKS) except asyncio.CancelledError: CANCELLED_AT_TICK[key] = tick + 1 activity.logger.info("run_case cancelled key=%s at tick %d/%d", key, tick + 1, CASE_TICKS) raise CASE_EFFECTS[key] = CASE_EFFECTS.get(key, 0) + 1 ok = random.Random(key).random() >= fail_fraction result = CaseResult(run_id, case_id, ok, round(random.Random(key + ":score").uniform(0.5, 1.0), 3) if ok else 0.0, info.attempt) CASE_LEDGER[key] = result if not ok: raise ApplicationError(f"case {case_id} failed the benchmark", type="CaseFailed", non_retryable=True) return result @activity.defn async def record_run_event(run_id: str, seq: int, kind: str, payload: dict[str, Any]) -> bool: """Project one Workflow state change into SQLite. Returns True if the event was new, False if this (run_id, seq) had already been recorded — a retried execution changes nothing.""" conn = projection.connect(DB_PATH) try: return projection.apply_event(conn, run_id, int(seq), kind, payload) finally: conn.close()
11-capstone-durable-agent-runtime/console/api.py
"""The evaluation console's HTTP surface. Reads come from SQLite (the projection); writes go to Temporal (start, cancel, Update). Never Temporal visibility for a list — the projection is the product's read model, visibility is the operator's. POST /runs start an EvalRun (202); Workflow ID == run_id GET /runs?org=&status=&model=&cursor=&limit= keyset pagination on (created_at, run_id) GET /runs/{id} row + last events GET /runs/{id}/events?after= SSE, resumes from the cursor; polls SQLite every 0.5 s POST /runs/{id}/cancel handle.cancel() + an immediate `cancel_requested` projection event POST /runs/{id}/retry-failed new EvalRun over the failed case ids; id `{id}-retry-{n}`; memo parent_run_id POST /runs/{id}/approve execute_update(EvalRun.approve) Every request carries `X-Org`. A run that belongs to another org does not exist (404). """ from __future__ import annotations import asyncio import uuid from contextlib import asynccontextmanager from typing import Any from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request from fastapi.responses import JSONResponse, StreamingResponse from pydantic import BaseModel, Field from temporalio.api.operatorservice.v1 import ListSearchAttributesRequest from temporalio.client import Client, WorkflowUpdateFailedError from temporalio.common import SearchAttributePair, TypedSearchAttributes from temporalio.exceptions import ApplicationError from temporalio.service import RPCError, RPCStatusCode import projection from config import (ALL_STATUSES, CONSOLE_QUEUE, DB_PATH, DEFAULT_FAIL_FRACTION, DEFAULT_REVIEWERS, NAMESPACE, TEMPORAL_ADDRESS, TERMINAL_STATUSES, workflow_id) from models import EvalRunInput from workflows import MODEL, ORG, PARENT_RUN_ID, RUN_STATUS, EvalRun SSE_POLL_SECONDS = 0.5 class StartRun(BaseModel): run_id: str | None = None model: str = "fake-model-7b" suite: str = "smoke" case_ids: list[str] | None = None cases: int = Field(12, ge=1, le=500) fail_fraction: float = Field(DEFAULT_FAIL_FRACTION, ge=0.0, le=1.0) reviewers: list[str] = Field(default_factory=lambda: list(DEFAULT_REVIEWERS)) class Approve(BaseModel): reviewer: str command_id: str # ------------------------------------------------------------------ pure helpers (tested directly) def retry_failed_input(parent: dict[str, Any], state: dict[str, Any], n: int, *, reviewers: list[str] | None = None, fail_fraction: float = DEFAULT_FAIL_FRACTION) -> EvalRunInput: """The retry run: only the failed case ids, id `{parent}-retry-{n}`, same org/model/suite.""" failed = list(state.get("failed_case_ids") or []) if not failed: raise ValueError("nothing to retry: the run has no failed cases") return EvalRunInput(run_id=f"{parent['run_id']}-retry-{n}", model=parent["model"], suite=parent["suite"], case_ids=failed, org=parent["org"], fail_fraction=fail_fraction, reviewers=reviewers or list(DEFAULT_REVIEWERS)) def start_search_attributes(inp: EvalRunInput, parent_run_id: str | None, parent_sa_registered: bool) -> TypedSearchAttributes: pairs = [SearchAttributePair(ORG, inp.org), SearchAttributePair(MODEL, inp.model), SearchAttributePair(RUN_STATUS, "queued")] if parent_run_id and parent_sa_registered: pairs.append(SearchAttributePair(PARENT_RUN_ID, parent_run_id)) return TypedSearchAttributes(pairs) # ------------------------------------------------------------------ app @asynccontextmanager async def lifespan(app: FastAPI): app.state.client = await Client.connect(TEMPORAL_ADDRESS, namespace=NAMESPACE) listed = await app.state.client.operator_service.list_search_attributes(ListSearchAttributesRequest(namespace=NAMESPACE)) app.state.parent_sa_registered = "EvalParentRunId" in listed.custom_attributes app.state.db = projection.connect(DB_PATH) print(f"console api: temporal={TEMPORAL_ADDRESS} db={DB_PATH} EvalParentRunId registered={app.state.parent_sa_registered}") yield app.state.db.close() app = FastAPI(title="EvalRun console", lifespan=lifespan) def org_of(x_org: str | None = Header(default=None, alias="X-Org")) -> str: if not x_org: raise HTTPException(400, "X-Org header is required") return x_org def owned_run(request: Request, run_id: str, org: str) -> dict[str, Any]: row = projection.get_run(request.app.state.db, run_id) if row is None or row["org"] != org: raise HTTPException(404, f"run {run_id!r} not found") # another org's run is indistinguishable from none return row async def _start(request: Request, inp: EvalRunInput, parent_run_id: str | None) -> dict[str, Any]: client: Client = request.app.state.client try: handle = await client.start_workflow( EvalRun.run, inp, id=workflow_id(inp.run_id), task_queue=CONSOLE_QUEUE, search_attributes=start_search_attributes(inp, parent_run_id, request.app.state.parent_sa_registered), memo={"parent_run_id": parent_run_id or ""}) except Exception as err: # WorkflowAlreadyStartedError is the one you will see if type(err).__name__ == "WorkflowAlreadyStartedError": raise HTTPException(409, f"run {inp.run_id!r} already exists") raise projection.insert_queued_run(request.app.state.db, run_id=inp.run_id, org=inp.org, model=inp.model, suite=inp.suite, total=len(inp.case_ids), parent_run_id=parent_run_id) return {"run_id": inp.run_id, "workflow_id": handle.id, "run_id_temporal": handle.result_run_id, "status": "queued", "total": len(inp.case_ids), "parent_run_id": parent_run_id} @app.post("/runs", status_code=202) async def create_run(body: StartRun, request: Request, org: str = Depends(org_of)): run_id = workflow_id(body.run_id or f"run-{uuid.uuid4().hex[:8]}") case_ids = body.case_ids or [f"case-{i:03d}" for i in range(1, body.cases + 1)] inp = EvalRunInput(run_id=run_id, model=body.model, suite=body.suite, case_ids=case_ids, org=org, fail_fraction=body.fail_fraction, reviewers=body.reviewers) return JSONResponse(await _start(request, inp, None), status_code=202) @app.get("/runs") def list_runs(request: Request, org: str = Depends(org_of), status: str | None = None, model: str | None = None, cursor: str | None = None, limit: int = Query(20, ge=1, le=200)): if status and status not in ALL_STATUSES: raise HTTPException(400, f"status must be one of {ALL_STATUSES}") try: page, next_cursor = projection.list_runs(request.app.state.db, org=org, status=status, model=model, cursor=cursor, limit=limit) except ValueError as err: raise HTTPException(400, str(err)) return {"runs": page, "next_cursor": next_cursor} @app.get("/runs/{run_id}") def get_run(run_id: str, request: Request, org: str = Depends(org_of)): row = owned_run(request, run_id, org) events = projection.last_events(request.app.state.db, run_id, 10) return {**row, "state": projection.latest_state(request.app.state.db, run_id), "last_events": [e.as_dict() for e in events], "last_seq": events[-1].seq if events else 0} @app.get("/runs/{run_id}/events") async def run_events(run_id: str, request: Request, org: str = Depends(org_of), after: int = Query(0, ge=0), last_event_id: str | None = Header(default=None, alias="Last-Event-ID")): """SSE. The cursor is `?after=<seq>` or the browser's automatic `Last-Event-ID`; whichever is larger wins. Frames carry `id: <seq>` so nothing with seq <= cursor is ever sent, across any number of reconnects.""" owned_run(request, run_id, org) cursor = max(after, int(last_event_id) if last_event_id and last_event_id.isdigit() else 0) conn = projection.connect(DB_PATH) # this stream's own connection; the app one is shared by other requests async def gen(): try: yield f": resuming after seq {cursor}\n\n" pos = cursor while not await request.is_disconnected(): frames, pos, terminal = projection.poll_frames(conn, run_id, pos, TERMINAL_STATUSES) for frame in frames: yield frame if terminal: return if not frames: await asyncio.sleep(SSE_POLL_SECONDS) finally: conn.close() return StreamingResponse(gen(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) @app.post("/runs/{run_id}/cancel", status_code=202) async def cancel_run(run_id: str, request: Request, org: str = Depends(org_of)): """handle.cancel() is a request: the Workflow decides when it is cancelled (cases stop within one heartbeat). The console does not wait for that — it writes `cancel_requested` now, so the UI shows `cancelling` at once.""" row = owned_run(request, run_id, org) if row["status"] in TERMINAL_STATUSES: raise HTTPException(409, f"run is already {row['status']}") client: Client = request.app.state.client try: await client.get_workflow_handle(workflow_id(run_id)).cancel() except RPCError as err: if err.status == RPCStatusCode.NOT_FOUND: raise HTTPException(404, f"run {run_id!r} not found") raise ev = projection.console_event(request.app.state.db, run_id, "cancel_requested", {"by": org}, status="cancelling") return {"run_id": run_id, "status": "cancelling", "event": ev.as_dict()} @app.post("/runs/{run_id}/retry-failed", status_code=202) async def retry_failed(run_id: str, request: Request, org: str = Depends(org_of)): db = request.app.state.db row = owned_run(request, run_id, org) if row["status"] not in TERMINAL_STATUSES | {"awaiting_review"}: raise HTTPException(409, f"run is {row['status']}; retry once it has stopped") state = projection.latest_state(db, run_id) n = projection.count_retries(db, run_id) + 1 try: inp = retry_failed_input(row, state, n, fail_fraction=DEFAULT_FAIL_FRACTION) except ValueError as err: raise HTTPException(409, str(err)) started = await _start(request, inp, parent_run_id=run_id) projection.console_event(db, run_id, "retry_requested", {"retry_run_id": inp.run_id, "case_ids": inp.case_ids}) return {**started, "case_ids": inp.case_ids} @app.post("/runs/{run_id}/approve") async def approve_run(run_id: str, body: Approve, request: Request, org: str = Depends(org_of)): row = owned_run(request, run_id, org) if row["status"] in TERMINAL_STATUSES: # a replayed click after publish: the run is closed, not missing raise HTTPException(409, f"run is already {row['status']}") client: Client = request.app.state.client try: result = await client.get_workflow_handle(workflow_id(run_id)).execute_update( EvalRun.approve, args=[body.reviewer, body.command_id]) except WorkflowUpdateFailedError as err: cause = err.cause msg = cause.message if isinstance(cause, ApplicationError) else str(cause) raise HTTPException(422, f"approve rejected: {msg}") except RPCError as err: if err.status == RPCStatusCode.NOT_FOUND: # the row exists, so the Workflow closed under us raise HTTPException(409, f"run {run_id!r} is closed") raise return {"run_id": run_id, "result": result} @app.get("/healthz") def healthz(): return {"ok": True}
11-capstone-durable-agent-runtime/console/config.py
"""Names and knobs for the evaluation console, spelled once. Every process (API, both Workers, the setup script, the tests) imports these; nothing else reads the environment. Shared dev server: LAB_PREFIX (default `lab115-`) prefixes both task queues and every Workflow ID.""" from __future__ import annotations import os TEMPORAL_ADDRESS = os.environ.get("TEMPORAL_ADDRESS", "localhost:7233") NAMESPACE = os.environ.get("TEMPORAL_NAMESPACE", "default") LAB_PREFIX = os.environ.get("LAB_PREFIX", "lab115-") CONSOLE_QUEUE = os.environ.get("CONSOLE_QUEUE", LAB_PREFIX + "console") # EvalRun + record_run_event GPU_EVAL_QUEUE = os.environ.get("GPU_EVAL_QUEUE", LAB_PREFIX + "gpu-eval") # run_case DB_PATH = os.environ.get("EVAL_CONSOLE_DB", os.path.join(os.path.dirname(os.path.abspath(__file__)), "eval_console.sqlite3")) API_HOST = os.environ.get("EVAL_CONSOLE_HOST", "127.0.0.1") API_PORT = int(os.environ.get("EVAL_CONSOLE_PORT", "8115")) # The fake GPU: one case sleeps CASE_SECONDS in CASE_TICKS heartbeating ticks. CASE_SECONDS = float(os.environ.get("EVAL_CASE_SECONDS", "0.8")) CASE_TICKS = int(os.environ.get("EVAL_CASE_TICKS", "4")) DEFAULT_FAIL_FRACTION = float(os.environ.get("EVAL_FAIL_FRACTION", "0.25")) # benchmark failures (recorded, not retried) HICCUP_FRACTION = float(os.environ.get("EVAL_HICCUP_FRACTION", "0.1")) # transient GPU errors (retried by policy) CASE_PARALLELISM = int(os.environ.get("EVAL_CASE_PARALLELISM", "4")) # cases in flight per run GPU_SLOTS = int(os.environ.get("GPU_SLOTS", "4")) # slots on the gpu-eval Worker DEFAULT_REVIEWERS = tuple(x for x in os.environ.get("EVAL_REVIEWERS", "alice,bob").split(",") if x) TERMINAL_STATUSES = frozenset({"published", "cancelled", "failed"}) ALL_STATUSES = ("queued", "running", "awaiting_review", "cancelling", "cancelled", "failed", "published") def workflow_id(run_id: str) -> str: """Workflow ID == run_id; the prefix is applied once, never twice.""" return run_id if run_id.startswith(LAB_PREFIX) else LAB_PREFIX + run_id
11-capstone-durable-agent-runtime/console/models.py
"""Dataclasses crossing the Workflow boundary (Temporal's default JSON converter handles them).""" from __future__ import annotations from dataclasses import dataclass, field @dataclass class EvalRunInput: run_id: str model: str suite: str case_ids: list[str] org: str fail_fraction: float = 0.25 reviewers: list[str] = field(default_factory=lambda: ["alice", "bob"]) @dataclass class CaseResult: run_id: str case_id: str ok: bool score: float attempt: int duplicate: bool = False @dataclass class EvalRunResult: run_id: str status: str done: int total: int failed_case_ids: list[str] approved_by: str | None = None
11-capstone-durable-agent-runtime/console/projection.py
"""The read model. SQLite owns what the UI reads; Temporal owns what is true. runs(run_id, org, model, suite, status, done, total, created_at, parent_run_id) run_events(run_id, seq, kind, payload, ts, PRIMARY KEY (run_id, seq)) `apply_event` is the one write path: it is *idempotent by (run_id, seq)* — the projection Activity that calls it may run twice (a retry after a lost completion), and the second execution must change nothing. The `runs` row is rebuilt from the event's `state` payload, but only when that event is the newest one for the run, so an out-of-order duplicate can never move a run backwards. Sequence numbers: the Workflow owns the EVEN numbers (2, 4, 6, …: `2 * self.seq`). The console itself writes ODD numbers (`cancel_requested`, `retry_requested`) in the gap after the newest event, so a console-originated event can never collide with — or be overwritten by — a Workflow event. """ from __future__ import annotations import json import sqlite3 from dataclasses import dataclass from datetime import datetime, timezone from typing import Any, Iterator SCHEMA = """ CREATE TABLE IF NOT EXISTS runs ( run_id TEXT PRIMARY KEY, org TEXT NOT NULL, model TEXT NOT NULL, suite TEXT NOT NULL, status TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0, total INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, parent_run_id TEXT ); CREATE INDEX IF NOT EXISTS runs_org_created ON runs(org, created_at DESC, run_id DESC); CREATE TABLE IF NOT EXISTS run_events ( run_id TEXT NOT NULL, seq INTEGER NOT NULL, kind TEXT NOT NULL, payload TEXT NOT NULL, ts TEXT NOT NULL, PRIMARY KEY (run_id, seq) ); """ def now_iso() -> str: return datetime.now(timezone.utc).isoformat(timespec="milliseconds") def connect(path: str) -> sqlite3.Connection: conn = sqlite3.connect(path, check_same_thread=False, isolation_level=None, timeout=5.0) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA busy_timeout=5000") conn.executescript(SCHEMA) return conn @dataclass class Event: run_id: str seq: int kind: str payload: dict[str, Any] ts: str def as_dict(self) -> dict[str, Any]: return {"run_id": self.run_id, "seq": self.seq, "kind": self.kind, "payload": self.payload, "ts": self.ts} def _row_event(r: sqlite3.Row) -> Event: return Event(r["run_id"], r["seq"], r["kind"], json.loads(r["payload"]), r["ts"]) # ------------------------------------------------------------------ writes def upsert_event(conn: sqlite3.Connection, run_id: str, seq: int, kind: str, payload: dict[str, Any], ts: str) -> bool: """INSERT the event; a second call with the same (run_id, seq) is a no-op. Returns True if inserted.""" cur = conn.execute("INSERT OR IGNORE INTO run_events(run_id, seq, kind, payload, ts) VALUES (?,?,?,?,?)", (run_id, seq, kind, json.dumps(payload, sort_keys=True), ts)) return cur.rowcount == 1 def upsert_run(conn: sqlite3.Connection, *, run_id: str, org: str, model: str, suite: str, status: str, done: int, total: int, created_at: str, parent_run_id: str | None) -> None: conn.execute("""INSERT INTO runs(run_id, org, model, suite, status, done, total, created_at, parent_run_id) VALUES (?,?,?,?,?,?,?,?,?) ON CONFLICT(run_id) DO UPDATE SET status=excluded.status, done=excluded.done, total=excluded.total""", (run_id, org, model, suite, status, done, total, created_at, parent_run_id or None)) def insert_queued_run(conn: sqlite3.Connection, *, run_id: str, org: str, model: str, suite: str, total: int, parent_run_id: str | None) -> None: """The row the API writes when it starts a run, so GET /runs/{id} and its event stream answer at once instead of 404ing until the Workflow's first projection lands. Insert-or-ignore: if that projection already landed, it is newer, and this must not move the run back to `queued`.""" with conn: conn.execute("""INSERT OR IGNORE INTO runs(run_id, org, model, suite, status, done, total, created_at, parent_run_id) VALUES (?,?,?,?,'queued',0,?,?,?)""", (run_id, org, model, suite, total, now_iso(), parent_run_id or None)) def apply_event(conn: sqlite3.Connection, run_id: str, seq: int, kind: str, payload: dict[str, Any], ts: str | None = None) -> bool: """The projection: record the event, then rebuild the run row from its `state` iff it is the newest. Idempotent: replaying (run_id, seq) inserts nothing and moves nothing. Returns True if the event was new.""" ts = ts or now_iso() with conn: # one transaction inserted = upsert_event(conn, run_id, seq, kind, payload, ts) newest = conn.execute("SELECT MAX(seq) FROM run_events WHERE run_id=?", (run_id,)).fetchone()[0] state = payload.get("state") if inserted and state and seq == newest: row = conn.execute("SELECT * FROM runs WHERE run_id=?", (run_id,)).fetchone() upsert_run(conn, run_id=run_id, org=state.get("org", row["org"] if row else ""), model=state.get("model", row["model"] if row else ""), suite=state.get("suite", row["suite"] if row else ""), status=state["status"], done=int(state.get("done", 0)), total=int(state.get("total", 0)), created_at=row["created_at"] if row else state.get("created_at", ts), parent_run_id=row["parent_run_id"] if row else state.get("parent_run_id")) return inserted def console_event(conn: sqlite3.Connection, run_id: str, kind: str, payload: dict[str, Any], status: str | None = None) -> Event: """A console-originated event (`cancel_requested`, `retry_requested`): takes the next ODD seq after the newest event, so it sorts after what the Workflow has said so far and can never collide with what it will say next (Workflow seqs are even). Optionally moves the run row's status (e.g. `cancelling`).""" ts = now_iso() with conn: newest = conn.execute("SELECT COALESCE(MAX(seq), 0) FROM run_events WHERE run_id=?", (run_id,)).fetchone()[0] seq = newest + 1 if newest % 2 == 0 else newest + 2 payload = dict(payload, source="console") if status: if get_run(conn, run_id) is None: raise KeyError(run_id) payload["state"] = {**_state_of(conn, run_id), "status": status} conn.execute("UPDATE runs SET status=? WHERE run_id=?", (status, run_id)) upsert_event(conn, run_id, seq, kind, payload, ts) return Event(run_id, seq, kind, payload, ts) def _state_of(conn: sqlite3.Connection, run_id: str) -> dict[str, Any]: """The newest `state` payload the Workflow projected, or {}.""" r = conn.execute("SELECT payload FROM run_events WHERE run_id=? AND payload LIKE '%\"state\"%' ORDER BY seq DESC LIMIT 1", (run_id,)).fetchone() return json.loads(r["payload"]).get("state", {}) if r else {} def latest_state(conn: sqlite3.Connection, run_id: str) -> dict[str, Any]: return _state_of(conn, run_id) # ------------------------------------------------------------------ reads def get_run(conn: sqlite3.Connection, run_id: str) -> dict[str, Any] | None: r = conn.execute("SELECT * FROM runs WHERE run_id=?", (run_id,)).fetchone() return dict(r) if r else None def encode_cursor(created_at: str, run_id: str) -> str: return f"{created_at}|{run_id}" def decode_cursor(cursor: str) -> tuple[str, str]: created_at, _, run_id = cursor.partition("|") if not created_at or not run_id: raise ValueError("malformed cursor") return created_at, run_id def list_runs(conn: sqlite3.Connection, *, org: str, status: str | None = None, model: str | None = None, cursor: str | None = None, limit: int = 20) -> tuple[list[dict[str, Any]], str | None]: """Newest first; keyset pagination on (created_at, run_id). Every query is scoped to one org.""" limit = max(1, min(int(limit), 200)) where, args = ["org=?"], [org] if status: where.append("status=?"); args.append(status) if model: where.append("model=?"); args.append(model) if cursor: c_created, c_id = decode_cursor(cursor) where.append("(created_at, run_id) < (?, ?)"); args += [c_created, c_id] rows = conn.execute(f"SELECT * FROM runs WHERE {' AND '.join(where)} ORDER BY created_at DESC, run_id DESC LIMIT ?", (*args, limit + 1)).fetchall() page = [dict(r) for r in rows[:limit]] next_cursor = encode_cursor(page[-1]["created_at"], page[-1]["run_id"]) if len(rows) > limit else None return page, next_cursor def events_after(conn: sqlite3.Connection, run_id: str, after: int, limit: int = 500) -> list[Event]: """Every event with seq > after, ascending. This is the whole SSE cursor contract: a client that reconnects with the last seq it saw gets nothing twice.""" rows = conn.execute("SELECT * FROM run_events WHERE run_id=? AND seq>? ORDER BY seq ASC LIMIT ?", (run_id, int(after), limit)).fetchall() return [_row_event(r) for r in rows] def last_events(conn: sqlite3.Connection, run_id: str, n: int = 10) -> list[Event]: rows = conn.execute("SELECT * FROM run_events WHERE run_id=? ORDER BY seq DESC LIMIT ?", (run_id, n)).fetchall() return [_row_event(r) for r in reversed(rows)] def count_retries(conn: sqlite3.Connection, run_id: str) -> int: return conn.execute("SELECT COUNT(*) FROM runs WHERE run_id LIKE ?", (f"{run_id}-retry-%",)).fetchone()[0] # ------------------------------------------------------------------ SSE framing (pure; tested directly) def sse_frame(event: Event) -> str: """One text/event-stream record. `id:` carries the seq so a browser EventSource resumes with Last-Event-ID.""" return f"id: {event.seq}\nevent: {event.kind}\ndata: {json.dumps(event.as_dict(), sort_keys=True)}\n\n" def poll_frames(conn: sqlite3.Connection, run_id: str, after: int, terminal_statuses=frozenset()) -> tuple[list[str], int, bool]: """One poll: frames for every event with seq > after, the new cursor, and whether a terminal state was reached (after which the stream ends with an `end` frame). Pure; this is what the SSE test drives.""" frames, cursor, terminal = [], int(after), False for ev in events_after(conn, run_id, after): frames.append(sse_frame(ev)) cursor = ev.seq if (ev.payload.get("state") or {}).get("status") in terminal_statuses: frames.append("event: end\ndata: {}\n\n") terminal = True break return frames, cursor, terminal def stream_events(conn: sqlite3.Connection, run_id: str, after: int, *, sleep, poll_seconds: float = 0.5, should_stop=lambda: False, terminal_statuses=frozenset()) -> Iterator[str]: """Poll SQLite every `poll_seconds`, yield frames for seq > cursor, advance the cursor; end after a terminal state. Synchronous, so a test can drive it with a fake `sleep`; `api.py` runs the same `poll_frames` loop with `asyncio.sleep`.""" cursor = int(after) while not should_stop(): frames, cursor, terminal = poll_frames(conn, run_id, cursor, terminal_statuses) yield from frames if terminal: return if not frames: sleep(poll_seconds)
11-capstone-durable-agent-runtime/console/setup_search_attributes.py
"""Register the Search Attributes EvalRun writes — Org, RunStatus, Model (and EvalParentRunId, which the console sets on a retry run; `ParentRunId` is reserved by the system for Child Workflow lineage) — through the Operator Service, once per Namespace. Same call as lab 10: `temporal operator search-attribute create --name RunStatus --type Keyword`.""" import asyncio from temporalio.api.enums.v1 import IndexedValueType from temporalio.api.operatorservice.v1 import AddSearchAttributesRequest, ListSearchAttributesRequest from temporalio.client import Client from config import NAMESPACE, TEMPORAL_ADDRESS WANTED = {name: IndexedValueType.INDEXED_VALUE_TYPE_KEYWORD for name in ("Org", "RunStatus", "Model", "EvalParentRunId")} async def main() -> None: client = await Client.connect(TEMPORAL_ADDRESS, namespace=NAMESPACE) existing = dict((await client.operator_service.list_search_attributes(ListSearchAttributesRequest(namespace=NAMESPACE))).custom_attributes) missing = {k: v for k, v in WANTED.items() if k not in existing} for k in WANTED: if k in existing: print(f"exists {k:12s} {IndexedValueType.Name(existing[k]).removeprefix('INDEXED_VALUE_TYPE_')}") if missing: await client.operator_service.add_search_attributes(AddSearchAttributesRequest(namespace=NAMESPACE, search_attributes=missing)) for k, v in missing.items(): print(f"created {k:12s} {IndexedValueType.Name(v).removeprefix('INDEXED_VALUE_TYPE_')}") listed = await client.operator_service.list_search_attributes(ListSearchAttributesRequest(namespace=NAMESPACE)) assert all(k in listed.custom_attributes for k in WANTED), listed.custom_attributes print("namespace", NAMESPACE, "custom attributes:", sorted(listed.custom_attributes)) if __name__ == "__main__": asyncio.run(main())
11-capstone-durable-agent-runtime/console/tests/conftest.py
"""Knobs go into the environment before `config` is imported: every console module reads them once. Fast fake GPU, a throwaway SQLite file, a test prefix so nothing collides with a live console.""" import os import sys import tempfile CONSOLE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _db = os.path.join(tempfile.mkdtemp(prefix="eval-console-test-"), "console.sqlite3") os.environ["EVAL_CONSOLE_DB"] = _db os.environ["LAB_PREFIX"] = "test115-" os.environ["EVAL_CASE_SECONDS"] = "0.2" os.environ["EVAL_CASE_TICKS"] = "4" os.environ["EVAL_HICCUP_FRACTION"] = "0.1" os.environ["EVAL_CASE_PARALLELISM"] = "4" sys.path.insert(0, CONSOLE_DIR)
11-capstone-durable-agent-runtime/console/tests/test_console.py
"""Lab 11.5 — the evaluation console's contract, as tests. projection a replayed (run_id, seq) changes nothing; an old event never moves a run backwards SSE a client that reconnects with its last seq receives nothing twice; the stream ends at a terminal state publish cases on the gpu-eval lane → awaiting_review → the approve Update publishes; the validator rejects a stranger and a replayed command_id, and neither rejection reaches history lost write the projection Activity writes its row, then the Worker "dies" before completing: the retry finds the row and inserts nothing — still one case_done per case cancel handle.cancel() stops in-flight cases within one heartbeat; the read model ends `cancelled` retry the retry run covers exactly the failed case ids and points at its parent Time-skipping test server, no Docker; the API's HTTP layer is exercised against the live dev server in the README walk-through, its pure helpers here.""" import asyncio import uuid from datetime import timedelta import pytest from temporalio import activity from temporalio.api.enums.v1 import EventType, IndexedValueType from temporalio.api.operatorservice.v1 import AddSearchAttributesRequest from temporalio.client import WorkflowFailureError, WorkflowUpdateFailedError from temporalio.exceptions import CancelledError from temporalio.service import RPCError from temporalio.testing import WorkflowEnvironment from temporalio.worker import Worker import activities as acts import projection from api import retry_failed_input, start_search_attributes from config import CONSOLE_QUEUE, DB_PATH, GPU_EVAL_QUEUE, TERMINAL_STATUSES, workflow_id from models import EvalRunInput from workflows import EvalRun # ------------------------------------------------------------------ harness @pytest.fixture(scope="module") def loop(): loop = asyncio.new_event_loop() yield loop loop.close() @pytest.fixture(scope="module") def env(loop): async def start(): env = await WorkflowEnvironment.start_time_skipping() await env.client.operator_service.add_search_attributes(AddSearchAttributesRequest( namespace="default", search_attributes={ name: IndexedValueType.INDEXED_VALUE_TYPE_KEYWORD for name in ("Org", "RunStatus", "Model", "EvalParentRunId")})) return env env = loop.run_until_complete(start()) yield env loop.run_until_complete(env.shutdown()) @pytest.fixture def db(): conn = projection.connect(DB_PATH) yield conn conn.close() @pytest.fixture(autouse=True) def fresh_ledgers(): acts.reset_ledgers() def workers(env, record=acts.record_run_event): return [Worker(env.client, task_queue=CONSOLE_QUEUE, workflows=[EvalRun], activities=[record]), Worker(env.client, task_queue=GPU_EVAL_QUEUE, activities=[acts.run_case], max_concurrent_activities=4)] def new_input(n=8, fail=0.25, **kw) -> EvalRunInput: return EvalRunInput(run_id=workflow_id(f"run-{uuid.uuid4().hex[:6]}"), model="fake-model-7b", suite="smoke", case_ids=[f"case-{i:03d}" for i in range(1, n + 1)], org=kw.pop("org", "acme"), fail_fraction=fail, **kw) async def start(env, inp): return await env.client.start_workflow(EvalRun.run, inp, id=inp.run_id, task_queue=CONSOLE_QUEUE, search_attributes=start_search_attributes(inp, None, False), memo={"parent_run_id": ""}) async def wait_status(handle, want, timeout=30.0): deadline = asyncio.get_running_loop().time() + timeout while True: st = await handle.query(EvalRun.progress) if st["status"] in want: return st assert asyncio.get_running_loop().time() < deadline, f"stuck at {st['status']}" await asyncio.sleep(0.1) async def event_types(handle): return [EventType.Name(e.event_type).removeprefix("EVENT_TYPE_") async for e in handle.fetch_history_events()] # ------------------------------------------------------------------ projection + SSE (pure SQLite) def test_projection_is_idempotent_and_never_moves_backwards(db): rid = f"proj-{uuid.uuid4().hex[:6]}" st = {"org": "acme", "model": "m", "suite": "s", "total": 3} assert projection.apply_event(db, rid, 2, "run_created", {"state": {**st, "status": "queued", "done": 0}}) assert projection.apply_event(db, rid, 4, "status_changed", {"state": {**st, "status": "running", "done": 0}}) assert projection.apply_event(db, rid, 6, "case_done", {"state": {**st, "status": "running", "done": 1}}) # the retried Activity: same (run_id, seq) → no insert, no change assert not projection.apply_event(db, rid, 6, "case_done", {"state": {**st, "status": "running", "done": 1}}) # a late duplicate of an OLD event cannot pull the row back to `queued` assert not projection.apply_event(db, rid, 2, "run_created", {"state": {**st, "status": "queued", "done": 0}}) row = projection.get_run(db, rid) assert (row["status"], row["done"]) == ("running", 1) assert [e.seq for e in projection.events_after(db, rid, 0)] == [2, 4, 6] # the console's own event takes the next odd seq and cannot collide with the Workflow's next even one ev = projection.console_event(db, rid, "cancel_requested", {"by": "acme"}, status="cancelling") assert ev.seq == 7 and projection.get_run(db, rid)["status"] == "cancelling" assert projection.apply_event(db, rid, 8, "status_changed", {"state": {**st, "status": "cancelled", "done": 1}}) assert projection.get_run(db, rid)["status"] == "cancelled" def test_sse_reconnect_from_last_seq_sends_nothing_twice(db): rid = f"sse-{uuid.uuid4().hex[:6]}" st = {"org": "acme", "model": "m", "suite": "s", "total": 2} for seq, status, done in [(2, "queued", 0), (4, "running", 0), (6, "running", 1)]: projection.apply_event(db, rid, seq, "e", {"state": {**st, "status": status, "done": done}}) frames, cursor, terminal = projection.poll_frames(db, rid, 0, TERMINAL_STATUSES) assert [f.split("\n")[0] for f in frames] == ["id: 2", "id: 4", "id: 6"] and cursor == 6 and not terminal # the connection drops; two more events land while the client is away projection.apply_event(db, rid, 8, "e", {"state": {**st, "status": "running", "done": 2}}) projection.apply_event(db, rid, 10, "e", {"state": {**st, "status": "cancelled", "done": 2}}) frames, cursor, terminal = projection.poll_frames(db, rid, 6, TERMINAL_STATUSES) # Last-Event-ID: 6 assert [f.split("\n")[0] for f in frames] == ["id: 8", "id: 10", "event: end"] and terminal # the stream generator ends by itself after the terminal frame, never sleeping once sleeps = [] out = list(projection.stream_events(db, rid, 0, sleep=sleeps.append, terminal_statuses=TERMINAL_STATUSES)) assert len(out) == 6 and out[-1].startswith("event: end") and sleeps == [] # ------------------------------------------------------------------ the Workflow, end to end def test_run_publishes_through_the_validated_update(env, loop, db): async def body(): inp = new_input(n=8, fail=0.25) async with Worker(env.client, task_queue=CONSOLE_QUEUE, workflows=[EvalRun], activities=[acts.record_run_event]), \ Worker(env.client, task_queue=GPU_EVAL_QUEUE, activities=[acts.run_case], max_concurrent_activities=4): handle = await start(env, inp) st = await wait_status(handle, {"awaiting_review", "failed"}) assert st["status"] == "awaiting_review" and st["done"] == 8 with pytest.raises(WorkflowUpdateFailedError) as rejected: await handle.execute_update(EvalRun.approve, args=["mallory", "cmd-0"]) assert "not a reviewer" in str(rejected.value.cause) # the reason rides on .cause assert await handle.execute_update(EvalRun.approve, args=["alice", "cmd-1"]) == "published by alice (command cmd-1)" result = await handle.result() # the replayed click: publishing closed the run, so there is no open Workflow left to take an # Update. The server refuses it (the API answers 409 from the read model before it gets here) with pytest.raises(RPCError): await handle.execute_update(EvalRun.approve, args=["alice", "cmd-1"]) types = await event_types(handle) assert result.status == "published" and result.approved_by == "alice" # two rejections, one acceptance: only the accepted Update reached history assert types.count("WORKFLOW_EXECUTION_UPDATE_ACCEPTED") == 1 assert types.count("WORKFLOW_EXECUTION_UPDATE_COMPLETED") == 1 row = projection.get_run(db, inp.run_id) assert (row["status"], row["done"], row["total"], row["org"]) == ("published", 8, 8, "acme") seqs = [e.seq for e in projection.events_after(db, inp.run_id, 0)] assert seqs == list(range(2, 2 * len(seqs) + 1, 2)) # every Workflow seq, even, no gaps kinds = [e.kind for e in projection.events_after(db, inp.run_id, 0)] assert kinds.count("case_done") == 8 # every case ran on the GPU exactly once, however many hiccups the RetryPolicy absorbed assert all(acts.CASE_EFFECTS[f"{inp.run_id}:{c}"] == 1 for c in inp.case_ids) loop.run_until_complete(body()) def test_projection_write_survives_a_worker_death_before_completion(env, loop, db): died = {"left": 2} @activity.defn(name="record_run_event") async def record_then_die(run_id, seq, kind, payload): inserted = await acts.record_run_event(run_id, seq, kind, payload) if kind == "case_done" and died["left"] > 0 and activity.info().attempt == 1: died["left"] -= 1 raise RuntimeError(f"worker died after writing seq {seq}, before reporting completion") return inserted async def body(): inp = new_input(n=4, fail=0.0) async with Worker(env.client, task_queue=CONSOLE_QUEUE, workflows=[EvalRun], activities=[record_then_die]), \ Worker(env.client, task_queue=GPU_EVAL_QUEUE, activities=[acts.run_case]): handle = await start(env, inp) await wait_status(handle, {"awaiting_review"}) await handle.execute_update(EvalRun.approve, args=["bob", "cmd-x"]) await handle.result() assert died["left"] == 0 # two writes were re-executed events = projection.events_after(db, inp.run_id, 0) assert [e.kind for e in events].count("case_done") == 4 # …and still one row per case assert len({e.seq for e in events}) == len(events) loop.run_until_complete(body()) def test_projection_commits_in_seq_order_so_the_stream_skips_nothing(env, loop, db): # The SSE cursor jumps to the highest seq it has read. If seq 8 could commit before seq 6, a poll # between the two would move the cursor past 6 and that event would never be sent. So the # Workflow projects one event at a time: here a lower seq's write is made slower than the next # one's, and the commit order must still be the seq order. committed: list[int] = [] @activity.defn(name="record_run_event") async def slow_low_seq(run_id, seq, kind, payload): await asyncio.sleep(0.3 if seq % 4 == 2 else 0.0) inserted = await acts.record_run_event(run_id, seq, kind, payload) committed.append(seq) return inserted async def body(): inp = new_input(n=8, fail=0.0) async with Worker(env.client, task_queue=CONSOLE_QUEUE, workflows=[EvalRun], activities=[slow_low_seq]), \ Worker(env.client, task_queue=GPU_EVAL_QUEUE, activities=[acts.run_case], max_concurrent_activities=4): handle = await start(env, inp) await wait_status(handle, {"awaiting_review"}) await handle.terminate("test done") assert committed == sorted(committed) and len(committed) >= 10 loop.run_until_complete(body()) def test_cancel_stops_cases_within_a_heartbeat(env, loop, db, monkeypatch): # cases long enough to be mid-flight: 4 ticks of 1 s, so each tick's heartbeat clears the SDK's throttle # (0.8 × heartbeat_timeout) and carries the cancel back to the Activity monkeypatch.setattr(acts, "CASE_SECONDS", 4.0) async def body(): inp = new_input(n=16, fail=0.0) async with Worker(env.client, task_queue=CONSOLE_QUEUE, workflows=[EvalRun], activities=[acts.record_run_event]), \ Worker(env.client, task_queue=GPU_EVAL_QUEUE, activities=[acts.run_case], max_concurrent_activities=4): handle = await start(env, inp) await wait_status(handle, {"running"}) await asyncio.sleep(1.5) # four cases are mid-flight projection.console_event(db, inp.run_id, "cancel_requested", {"by": "acme"}, status="cancelling") await handle.cancel() with pytest.raises(WorkflowFailureError) as err: await handle.result() assert isinstance(err.value.cause, CancelledError) types = await event_types(handle) assert "WORKFLOW_EXECUTION_CANCEL_REQUESTED" in types and types[-1] == "WORKFLOW_EXECUTION_CANCELED" assert acts.CANCELLED_AT_TICK, "an in-flight case should have observed the cancel at a heartbeat" assert sum(acts.CASE_EFFECTS.values()) < 16 # the rest never ran row = projection.get_run(db, inp.run_id) assert row["status"] == "cancelled" kinds = [e.kind for e in projection.events_after(db, inp.run_id, 0)] assert "cancel_requested" in kinds and kinds[-1] == "status_changed" loop.run_until_complete(body()) # ------------------------------------------------------------------ the retry run (pure) def test_retry_run_covers_only_the_failed_cases(): parent = {"run_id": "test115-run-abc", "model": "m", "suite": "s", "org": "acme"} inp = retry_failed_input(parent, {"failed_case_ids": ["case-003", "case-007"]}, 1) assert (inp.run_id, inp.case_ids, inp.org) == ("test115-run-abc-retry-1", ["case-003", "case-007"], "acme") with pytest.raises(ValueError): retry_failed_input(parent, {"failed_case_ids": []}, 1) sa = start_search_attributes(inp, parent["run_id"], parent_sa_registered=True) assert {p.key.name: p.value for p in sa.search_attributes}["EvalParentRunId"] == "test115-run-abc"
11-capstone-durable-agent-runtime/console/worker.py
"""The console lane: EvalRun Workflows + the projection Activity, on `lab115-console`.""" import asyncio import os from temporalio.client import Client from temporalio.worker import Worker from activities import record_run_event from config import CONSOLE_QUEUE, NAMESPACE, TEMPORAL_ADDRESS from workflows import EvalRun async def main() -> None: client = await Client.connect(TEMPORAL_ADDRESS, namespace=NAMESPACE) worker = Worker(client, task_queue=CONSOLE_QUEUE, workflows=[EvalRun], activities=[record_run_event], identity=f"console-{os.getpid()}@{CONSOLE_QUEUE}") print(f"console worker pid={os.getpid()} polling {CONSOLE_QUEUE!r}: EvalRun, record_run_event", flush=True) await worker.run() if __name__ == "__main__": asyncio.run(main())
11-capstone-durable-agent-runtime/console/worker_gpu.py
"""The GPU lane: `run_case` only, GPU_SLOTS concurrent, on `lab115-gpu-eval`. Kill it mid-run and watch the cases resume from their heartbeat tick on the next one.""" import asyncio import os from temporalio.client import Client from temporalio.worker import Worker from activities import run_case from config import GPU_EVAL_QUEUE, GPU_SLOTS, NAMESPACE, TEMPORAL_ADDRESS async def main() -> None: client = await Client.connect(TEMPORAL_ADDRESS, namespace=NAMESPACE) worker = Worker(client, task_queue=GPU_EVAL_QUEUE, activities=[run_case], max_concurrent_activities=GPU_SLOTS, identity=f"gpu-eval-{os.getpid()}@{GPU_EVAL_QUEUE}") print(f"gpu-eval worker pid={os.getpid()} polling {GPU_EVAL_QUEUE!r} with {GPU_SLOTS} slot(s)", flush=True) await worker.run() if __name__ == "__main__": asyncio.run(main())
11-capstone-durable-agent-runtime/console/workflows.py
"""EvalRun — a product sitting on a Workflow. Not AgentRun; the same skills. run_case Activity on the gpu-eval lane, keyed run_id:case_id, heartbeating, RetryPolicy record_run_event projection Activity on the console lane, after EVERY state change (seq = 2·n) Org/RunStatus/Model Search Attributes, upserted where the status changes parent_run_id memo, set by the console on a retry run approve validated Update: reviewer allow-list, command_id dedupe, state check handle.cancel() Temporal cancellation: cases stop within one heartbeat, then `cancelled` State machine: queued → running → awaiting_review → published ↘ failed (every case failed: nothing to publish) any ── cancel ──→ cancelling → cancelled (the console shows `cancelling` the moment it asks) """ from __future__ import annotations import asyncio from datetime import timedelta from typing import Any from temporalio import workflow from temporalio.common import RetryPolicy, SearchAttributeKey from temporalio.exceptions import ActivityError, ApplicationError, is_cancelled_exception from temporalio.workflow import ActivityCancellationType with workflow.unsafe.imports_passed_through(): from activities import record_run_event, run_case from config import CASE_PARALLELISM, GPU_EVAL_QUEUE from models import CaseResult, EvalRunInput, EvalRunResult ORG = SearchAttributeKey.for_keyword("Org") RUN_STATUS = SearchAttributeKey.for_keyword("RunStatus") MODEL = SearchAttributeKey.for_keyword("Model") PARENT_RUN_ID = SearchAttributeKey.for_keyword("EvalParentRunId") # set by the console on a retry, if registered. `ParentRunId` itself is reserved by the system (Child Workflow lineage) CASE_RETRY = RetryPolicy(initial_interval=timedelta(milliseconds=200), backoff_coefficient=2.0, maximum_interval=timedelta(seconds=2), maximum_attempts=4, non_retryable_error_types=["CaseFailed"]) PROJECTION_RETRY = RetryPolicy(initial_interval=timedelta(milliseconds=100), maximum_interval=timedelta(seconds=2)) @workflow.defn class EvalRun: def __init__(self) -> None: self.inp: EvalRunInput | None = None self.status = "queued" self.done = 0 self.failed_case_ids: list[str] = [] self.results: dict[str, dict[str, Any]] = {} self.seq = 0 # Workflow event counter; projected as 2·seq # One projection write at a time, so rows commit in seq order. Parallel cases used to take # seq 6 and seq 8 and race their writes: 8 could land first, a stream polling between the # two advanced its cursor past 6, and never sent it. asyncio.Lock is deterministic here. self._emit_lock = asyncio.Lock() self.processed_command_ids: set[str] = set() self.approved_by: str | None = None self.parent_run_id = "" # ------------------------------------------------------------------ state @property def total(self) -> int: return len(self.inp.case_ids) if self.inp else 0 def state(self) -> dict[str, Any]: return {"run_id": self.inp.run_id, "org": self.inp.org, "model": self.inp.model, "suite": self.inp.suite, "status": self.status, "done": self.done, "total": self.total, "failed_case_ids": sorted(self.failed_case_ids), "parent_run_id": self.parent_run_id, "approved_by": self.approved_by, "created_at": self._created_at} async def _emit(self, kind: str, **payload: Any) -> None: """Every state change ends here: seq++, then the projection Activity, one at a time. Awaited, so the event is durable in SQLite before the Workflow moves on; retried until it is.""" async with self._emit_lock: self.seq += 1 await workflow.execute_activity( record_run_event, args=[self.inp.run_id, 2 * self.seq, kind, {**payload, "state": self.state()}], start_to_close_timeout=timedelta(seconds=10), retry_policy=PROJECTION_RETRY) async def _transition(self, status: str, **payload: Any) -> None: self.status = status workflow.upsert_search_attributes([RUN_STATUS.value_set(status)]) await self._emit("status_changed", **payload) # ------------------------------------------------------------------ control surface @workflow.query def progress(self) -> dict[str, Any]: return {**self.state(), "seq": self.seq, "processed_command_ids": sorted(self.processed_command_ids)} @workflow.update async def approve(self, reviewer: str, command_id: str) -> str: """The tracked write: accepted → in history → the run is published before the client sees the result.""" self.processed_command_ids.add(command_id) self.approved_by = reviewer await self._transition("published", reviewer=reviewer, command_id=command_id) return f"published by {reviewer} (command {command_id})" @approve.validator def _validate_approve(self, reviewer: str, command_id: str) -> None: # Rejected here = never in history. The validator may read state; it must not change it. if not command_id: raise ValueError("command_id is required") if reviewer not in self.inp.reviewers: raise ValueError(f"{reviewer!r} is not a reviewer for org {self.inp.org!r}") if command_id in self.processed_command_ids: raise ValueError(f"command {command_id!r} already applied") if self.status != "awaiting_review": raise ValueError(f"run is {self.status}, not awaiting_review") # ------------------------------------------------------------------ the cases async def _case(self, case_id: str, gate: asyncio.Semaphore) -> None: async with gate: try: result: CaseResult = await workflow.execute_activity( run_case, args=[self.inp.run_id, case_id, self.inp.fail_fraction], task_queue=GPU_EVAL_QUEUE, schedule_to_start_timeout=timedelta(minutes=2), start_to_close_timeout=timedelta(seconds=30), heartbeat_timeout=timedelta(seconds=1), retry_policy=CASE_RETRY, cancellation_type=ActivityCancellationType.WAIT_CANCELLATION_COMPLETED) self.results[case_id] = {"ok": True, "score": result.score, "attempt": result.attempt} except ActivityError as err: if isinstance(err.cause, ApplicationError) and err.cause.type == "CaseFailed": self.failed_case_ids.append(case_id) self.results[case_id] = {"ok": False, "score": 0.0} else: raise self.done += 1 await self._emit("case_done", case_id=case_id, **self.results[case_id]) # ------------------------------------------------------------------ the run @workflow.run async def run(self, inp: EvalRunInput) -> EvalRunResult: self.inp = inp self._created_at = workflow.now().isoformat(timespec="milliseconds") self.parent_run_id = workflow.memo_value("parent_run_id", "", type_hint=str) workflow.upsert_search_attributes([ORG.value_set(inp.org), MODEL.value_set(inp.model), RUN_STATUS.value_set("queued")]) await self._emit("run_created", parent_run_id=self.parent_run_id) tasks: list[asyncio.Task] = [] try: await self._transition("running") gate = asyncio.Semaphore(CASE_PARALLELISM) tasks = [asyncio.create_task(self._case(cid, gate)) for cid in inp.case_ids] await asyncio.gather(*tasks) if self.total and len(self.failed_case_ids) == self.total: await self._transition("failed", reason="every case failed") return self._result() await self._transition("awaiting_review") await workflow.wait_condition(lambda: self.status == "published") await workflow.wait_condition(workflow.all_handlers_finished) return self._result() except BaseException as err: if not is_cancelled_exception(err): raise # handle.cancel(): every in-flight run_case was asked to stop (WAIT_CANCELLATION_COMPLETED means each # task below resolves only once its Activity confirmed). Project `cancelling` now, `cancelled` after. await self._transition("cancelling") await asyncio.gather(*tasks, return_exceptions=True) await self._transition("cancelled", stopped_after=self.done) raise def _result(self) -> EvalRunResult: return EvalRunResult(self.inp.run_id, self.status, self.done, self.total, sorted(self.failed_case_ids), self.approved_by)
11-capstone-durable-agent-runtime/control.py
"""Drive a running AgentRun from outside. python control.py status [--id agent-42] python control.py pause | resume | cancel-run (Signals) python control.py cancel (Temporal cancellation: handle.cancel()) python control.py inject "some text" --command-id c1 (Signal with dedupe) python control.py goal "new goal" --command-id c2 (Update with validator; repeat it and watch it be ignored) """ import argparse import asyncio import os import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from common import connect # noqa: E402 from agentrun import AgentRun, workflow_id # noqa: E402 async def main() -> None: p = argparse.ArgumentParser() p.add_argument("command", choices=["status", "pause", "resume", "cancel-run", "cancel", "inject", "goal"]) p.add_argument("text", nargs="?", default="") p.add_argument("--id", default="agent-42") p.add_argument("--command-id", default="cmd-1") a = p.parse_args() handle = (await connect()).get_workflow_handle(workflow_id(a.id)) if a.command == "status": for k, v in (await handle.query(AgentRun.status)).items(): print(f" {k:22s} {v}") elif a.command == "pause": await handle.signal(AgentRun.pause); print("pause signalled") elif a.command == "resume": await handle.signal(AgentRun.resume); print("resume signalled") elif a.command == "cancel-run": await handle.signal(AgentRun.cancel_run); print("cancel_run signalled (cooperative)") elif a.command == "cancel": await handle.cancel(); print("cancellation requested (WorkflowExecutionCancelRequested)") elif a.command == "inject": await handle.signal(AgentRun.inject_context, args=[a.text, a.command_id]); print("inject_context signalled") elif a.command == "goal": print("update result:", await handle.execute_update(AgentRun.change_goal, args=[a.text, a.command_id])) if __name__ == "__main__": asyncio.run(main())
11-capstone-durable-agent-runtime/pytest.ini
[pytest] # A bare `pytest` here would also collect console/tests, whose conftest puts console/ first on # sys.path; console/worker_gpu.py then shadows this lab's worker_gpu.py and collection fails. # The console's six tests are their own suite: `cd console && python -m pytest tests`. testpaths = tests
11-capstone-durable-agent-runtime/run_200_steps.py
"""Lab 11.2 — a 200-step run that continues-as-new, and proof that no run's history got long. python run_200_steps.py [--steps 200] [--id agent-200] [--external-workers] [--bound N] By default the three Worker pools run inside this process (so one command does the whole thing); with --external-workers it only starts the run and expects worker_workflows.py / worker_cpu.py / worker_gpu.py to be up. Afterwards it walks the run chain — one Workflow ID, several Run IDs — and asserts every run's event count is under the bound. """ import argparse import asyncio import os import sys import time os.environ.setdefault("AGENTRUN_TOOL_SECONDS", "0.02") # tiny sleeps: the point is the history, not the wait os.environ.setdefault("AGENTRUN_LLM_SECONDS", "0.005") sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from common import connect # noqa: E402 from temporalio.api.enums.v1 import EventType # noqa: E402 from temporalio.common import SearchAttributePair, TypedSearchAttributes # noqa: E402 from temporalio.worker import Worker # noqa: E402 from agentrun import (AGENT_WORKFLOWS, CPU_TOOLS, GPU_TOOLS, OWNER, AgentRun, ResearchAgent, # noqa: E402 call_llm, compensate, evaluate, execute_tool, workflow_id) from agentrun.workflows import STEPS_PER_RUN # noqa: E402 from worker_gpu import execute_tool_on_gpu # noqa: E402 async def walk_runs(client, wf_id: str, first_run_id: str) -> list[dict]: """Follow WorkflowExecutionContinuedAsNew links from the first run to the last.""" runs, run_id = [], first_run_id while run_id: history = await client.get_workflow_handle(wf_id, run_id=run_id).fetch_history() last = history.events[-1] closed_as = EventType.Name(last.event_type).removeprefix("EVENT_TYPE_WORKFLOW_EXECUTION_") nxt = last.workflow_execution_continued_as_new_event_attributes.new_execution_run_id if last.HasField( "workflow_execution_continued_as_new_event_attributes") else "" runs.append({"run_id": run_id, "events": len(history.events), "closed_as": closed_as}) run_id = nxt return runs async def main() -> None: p = argparse.ArgumentParser() p.add_argument("--steps", type=int, default=200) p.add_argument("--id", default="agent-200") p.add_argument("--external-workers", action="store_true") p.add_argument("--bound", type=int, default=STEPS_PER_RUN * 14, help="max events any single run may have") a = p.parse_args() client = await connect() workers = [] if a.external_workers else [ Worker(client, task_queue=AGENT_WORKFLOWS, workflows=[AgentRun, ResearchAgent], identity=f"wf-{os.getpid()}"), Worker(client, task_queue=CPU_TOOLS, activities=[call_llm, execute_tool, evaluate, compensate], identity=f"cpu-{os.getpid()}"), Worker(client, task_queue=GPU_TOOLS, activities=[execute_tool_on_gpu], identity=f"gpu-{os.getpid()}"), ] for w in workers: await w.__aenter__() try: t0 = time.time() handle = await client.start_workflow( AgentRun.run, f"run {a.steps} steps", id=workflow_id(a.id), task_queue=AGENT_WORKFLOWS, search_attributes=TypedSearchAttributes([SearchAttributePair(OWNER, "acct_7f3a")]), memo={"tier": "team", "max_steps": a.steps}) print(f"started {handle.id} first run={handle.result_run_id}; continue-as-new every {STEPS_PER_RUN} steps") result = await handle.result() # follows the continue-as-new chain to the final run print(f"finished in {time.time() - t0:.1f}s: {result['status']} after {result['steps']} steps, " f"attempts={result['attempts']} duplicates_suppressed={result['duplicates_suppressed']}") runs = await walk_runs(client, handle.id, handle.result_run_id) print(f"{'run':>4} {'run_id':36s} {'events':>6} closed as") for i, r in enumerate(runs, 1): print(f"{i:4d} {r['run_id']:36s} {r['events']:6d} {r['closed_as']}") worst = max(r["events"] for r in runs) print(f"one Workflow ID, {len(runs)} Run IDs; longest history {worst} events; bound {a.bound}") assert worst <= a.bound, f"a run's history reached {worst} events, over the bound {a.bound}" assert all(r["closed_as"] == "CONTINUED_AS_NEW" for r in runs[:-1]) and runs[-1]["closed_as"] == "COMPLETED" desc = await handle.describe() # the latest run sa = {k.name: v for k, v in desc.typed_search_attributes if k.name in ("AgentStatus", "CurrentStep", "Owner")} print(f"latest run: status={desc.status.name} search attributes={sa} memo tier={await desc.memo_value('tier')}") assert sa == {"AgentStatus": "done", "CurrentStep": a.steps, "Owner": "acct_7f3a"}, sa n = 0 async for wf in client.list_workflows(f"WorkflowType = 'ResearchAgent' AND WorkflowId STARTS_WITH '{handle.id}/research/'"): n += 1 print(f"ResearchAgent children under {handle.id}/research/: {n}") print("OK: history bounded, search attributes and memo carried across every continue-as-new") finally: for w in workers: await w.__aexit__(None, None, None) if __name__ == "__main__": asyncio.run(main())
11-capstone-durable-agent-runtime/setup_search_attributes.py
"""Register the custom Search Attributes AgentRun writes: AgentStatus, CurrentStep, Owner. A Search Attribute must exist in the Namespace before a Workflow upserts it (an upsert of an unregistered key fails the Workflow Task, forever, until someone registers it). Registration is an operator action, once per Namespace, through the Operator Service — the same call as `temporal operator search-attribute create --name AgentStatus --type Keyword`. Equivalent at server start (dev server only): temporal server start-dev --search-attribute AgentStatus=Keyword --search-attribute CurrentStep=Int --search-attribute Owner=Keyword """ import asyncio import os import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from common import NAMESPACE, connect # noqa: E402 from temporalio.api.enums.v1 import IndexedValueType # noqa: E402 from temporalio.api.operatorservice.v1 import AddSearchAttributesRequest, ListSearchAttributesRequest # noqa: E402 WANTED = { "AgentStatus": IndexedValueType.INDEXED_VALUE_TYPE_KEYWORD, "CurrentStep": IndexedValueType.INDEXED_VALUE_TYPE_INT, "Owner": IndexedValueType.INDEXED_VALUE_TYPE_KEYWORD, } async def main() -> None: client = await connect() listed = await client.operator_service.list_search_attributes(ListSearchAttributesRequest(namespace=NAMESPACE)) existing = dict(listed.custom_attributes) missing = {k: v for k, v in WANTED.items() if k not in existing} for k in WANTED: if k in existing: print(f"exists {k:12s} {IndexedValueType.Name(existing[k]).removeprefix('INDEXED_VALUE_TYPE_')}") if missing: await client.operator_service.add_search_attributes( AddSearchAttributesRequest(namespace=NAMESPACE, search_attributes=missing)) for k, v in missing.items(): print(f"created {k:12s} {IndexedValueType.Name(v).removeprefix('INDEXED_VALUE_TYPE_')}") listed = await client.operator_service.list_search_attributes(ListSearchAttributesRequest(namespace=NAMESPACE)) assert all(k in listed.custom_attributes for k in WANTED), listed.custom_attributes print("namespace", NAMESPACE, "custom attributes:", sorted(listed.custom_attributes)) if __name__ == "__main__": asyncio.run(main())
11-capstone-durable-agent-runtime/starter.py
"""Start one AgentRun: `python starter.py [--id agent-42] [--owner acct_7f3a] [--tier batch] [--max-steps 8] [--no-wait]`.""" import argparse import asyncio import os import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from common import connect, show_history, start_workflow # noqa: E402 from temporalio.common import SearchAttributePair, TypedSearchAttributes # noqa: E402 from agentrun import AGENT_WORKFLOWS, OWNER, AgentRun, workflow_id # noqa: E402 async def main() -> None: p = argparse.ArgumentParser() p.add_argument("--goal", default="summarize the Temporal docs") p.add_argument("--id", default="agent-42") p.add_argument("--owner", default="acct_7f3a") p.add_argument("--tier", default="batch", choices=["enterprise", "team", "batch"]) p.add_argument("--max-steps", type=int, default=8) p.add_argument("--no-wait", action="store_true") a = p.parse_args() client = await connect() handle = await start_workflow(client, AgentRun.run, a.goal, id=workflow_id(a.id), task_queue=AGENT_WORKFLOWS, search_attributes=TypedSearchAttributes([SearchAttributePair(OWNER, a.owner)]), memo={"tier": a.tier, "max_steps": a.max_steps}) print(f"started {handle.id} run={handle.result_run_id} owner={a.owner} tier={a.tier} max_steps={a.max_steps}") print(f"UI: http://localhost:8233/namespaces/default/workflows/{handle.id}/{handle.result_run_id}/history") if a.no_wait: return print("result:", await handle.result()) await show_history(client, handle.id) if __name__ == "__main__": asyncio.run(main())
11-capstone-durable-agent-runtime/tests/conftest.py
"""Knobs go into the environment before `agentrun` is imported: the Worker's sandbox re-imports the package and reads them again. Small timeouts so a failure is seconds, not minutes.""" import os import sys LAB_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) os.environ["LAB_PREFIX"] = "test11-" os.environ["AGENTRUN_STEPS_PER_RUN"] = "8" os.environ["AGENTRUN_TOOL_SECONDS"] = "0.4" os.environ["AGENTRUN_LLM_SECONDS"] = "0.01" os.environ["AGENTRUN_TOOL_HEARTBEAT_SECONDS"] = "1" os.environ["AGENTRUN_TOOL_START_TO_CLOSE_SECONDS"] = "3" os.environ["AGENTRUN_GPU_SCHEDULE_TO_START_SECONDS"] = "2" sys.path.insert(0, LAB_DIR) sys.path.insert(0, os.path.dirname(LAB_DIR))
11-capstone-durable-agent-runtime/tests/test_failure_injection.py
"""Lab 11.3 — the failure-injection suite: crash, timeout, duplicate, cancel (both kinds), plus the control surface and the continue-as-new bound. Time-skipping test server; no Docker; no pytest-asyncio (a module-scoped event loop drives each test's coroutine).""" import asyncio from datetime import timedelta import pytest from temporalio import activity from temporalio.api.enums.v1 import EventType, IndexedValueType from temporalio.api.operatorservice.v1 import AddSearchAttributesRequest from temporalio.client import WorkflowFailureError, WorkflowUpdateFailedError from temporalio.common import SearchAttributePair, TypedSearchAttributes from temporalio.exceptions import CancelledError from temporalio.service import RPCError from temporalio.testing import WorkflowEnvironment from temporalio.worker import Worker from agentrun import (AGENT_WORKFLOWS, CPU_TOOLS, GPU_TOOLS, OWNER, AgentRun, ResearchAgent, ToolCall, ToolResult, call_llm, compensate, evaluate, execute_tool) from agentrun import activities as ledgers from run_200_steps import walk_runs from worker_gpu import execute_tool_on_gpu # ------------------------------------------------------------------ harness @pytest.fixture(scope="module") def loop(): loop = asyncio.new_event_loop() yield loop loop.close() @pytest.fixture(scope="module") def env(loop): async def start(): env = await WorkflowEnvironment.start_time_skipping() await env.client.operator_service.add_search_attributes(AddSearchAttributesRequest( namespace="default", search_attributes={ "AgentStatus": IndexedValueType.INDEXED_VALUE_TYPE_KEYWORD, "CurrentStep": IndexedValueType.INDEXED_VALUE_TYPE_INT, "Owner": IndexedValueType.INDEXED_VALUE_TYPE_KEYWORD})) return env env = loop.run_until_complete(start()) yield env loop.run_until_complete(env.shutdown()) @pytest.fixture(autouse=True) def fresh_ledgers(): ledgers.reset_ledgers() def pools(env, *, cpu=None, gpu=None, identity="A", sticky=True): """The three Worker pools, as one list of context managers. `sticky=False` disables the Workflow cache: every Workflow Task then goes to the shared queue and is a full replay. The crash test needs it because the time-skipping test server never times out a dead Worker's sticky queue (observed: the Workflow Task after the crash sat there for 30 s+); a real server moves it after ~10 s.""" return [ Worker(env.client, task_queue=AGENT_WORKFLOWS, workflows=[AgentRun, ResearchAgent], identity=f"wf-{identity}", max_cached_workflows=0 if not sticky else 1000), Worker(env.client, task_queue=CPU_TOOLS, activities=cpu or [call_llm, execute_tool, evaluate, compensate], identity=f"cpu-{identity}"), Worker(env.client, task_queue=GPU_TOOLS, activities=gpu or [execute_tool_on_gpu], identity=f"gpu-{identity}"), ] async def start(env, wf_id: str, max_steps: int, tier: str = "team"): return await env.client.start_workflow( AgentRun.run, "summarize the Temporal docs", id=wf_id, task_queue=AGENT_WORKFLOWS, search_attributes=TypedSearchAttributes([SearchAttributePair(OWNER, "acct_test")]), memo={"tier": tier, "max_steps": max_steps}) async def wait_for(handle, pred, timeout=20.0): deadline = asyncio.get_event_loop().time() + timeout while True: s = await handle.query(AgentRun.status) if pred(s): return s assert asyncio.get_event_loop().time() < deadline, f"timed out waiting; last status {s}" await asyncio.sleep(0.05) async def search_attrs(handle): d = await handle.describe() return {k.name: v for k, v in d.typed_search_attributes if k.name in ("AgentStatus", "CurrentStep", "Owner")} def events(history, kind): return [ev for ev in history.events if ev.event_type == getattr(EventType, f"EVENT_TYPE_{kind}")] # ------------------------------------------------------------------ 1. worker crash def test_worker_crash_mid_run_loses_no_work_and_spends_nothing_twice(loop, env): """Every pool dies while a tool is running. New pools with new identities finish the run. Nothing already recorded is re-executed; the interrupted attempt is retried from its heartbeat.""" async def body(): pool_a = pools(env, identity="A", sticky=False) tasks = [asyncio.ensure_future(w.run()) for w in pool_a] handle = await start(env, "crash-42", max_steps=6) await wait_for(handle, lambda s: s["step"] >= 2) # step 2 = the GPU tool, in flight for t in tasks: # the crash: no graceful drain t.cancel() await asyncio.gather(*tasks, return_exceptions=True) # With no Worker alive, a Query cannot be answered (a Worker computes it from replayed state) … with pytest.raises(RPCError): await handle.query(AgentRun.status, rpc_timeout=timedelta(seconds=2)) # … but Describe can: the Service owns the execution's state, and it is still RUNNING. mid = await handle.describe() assert mid.status.name == "RUNNING" wf, cpu, gpu = pools(env, identity="B") async with wf, cpu, gpu: result = await handle.result() assert result["status"] == "done" and result["steps"] == 6 # One bill per key: no LLM key was charged twice, no tool effect happened twice. assert len(ledgers.LLM_CALLS) == len(set(ledgers.LLM_CALLS)) assert all(n == 1 for n in ledgers.TOOL_SIDE_EFFECTS.values()), dict(ledgers.TOOL_SIDE_EFFECTS) history = await handle.fetch_history() identities = {ev.workflow_task_started_event_attributes.identity for ev in events(history, "WORKFLOW_TASK_STARTED")} assert {"wf-A", "wf-B"} <= identities, identities # two processes ran one execution assert (await search_attrs(handle)) == {"AgentStatus": "done", "CurrentStep": 6, "Owner": "acct_test"} loop.run_until_complete(body()) # ------------------------------------------------------------------ 2. activity timeout def test_stalled_attempt_times_out_on_heartbeat_and_the_retry_resumes(loop, env): """Attempt 1 heartbeats twice then hangs. The heartbeat timeout fails it; attempt 2 starts from the last heartbeat's tick. One effect, one retry, no timeout *event* — retries are not history.""" @activity.defn(name="execute_tool") async def hangs_once(call: ToolCall) -> ToolResult: if activity.info().attempt == 1: activity.heartbeat({"tick": 2, "of": 4}) await asyncio.sleep(30) # never heartbeats again → heartbeat timeout (1 s in tests) return await execute_tool(call) async def body(): wf, cpu, gpu = pools(env, cpu=[call_llm, hangs_once, evaluate, compensate]) async with wf, cpu, gpu: handle = await start(env, "timeout-42", max_steps=2) # llm, then the `search` tool result = await handle.result() assert result["steps"] == 2 and result["attempts"] == 1, result assert list(ledgers.TOOL_SIDE_EFFECTS.values()) == [1] history = await handle.fetch_history() assert events(history, "ACTIVITY_TASK_TIMED_OUT") == [], "a retried timeout leaves no event; only the final failure would" assert len(events(history, "ACTIVITY_TASK_COMPLETED")) == 3 # call_llm, execute_tool (attempt 2), evaluate loop.run_until_complete(body()) # ------------------------------------------------------------------ 3. duplicate execution def test_completion_lost_after_the_effect_is_not_a_second_effect(loop, env): """Attempt 1 does the work, writes the ledger, then dies before its completion reaches the Service. Attempt 2 presents the same key and gets the row back: duplicate=True, one effect.""" @activity.defn(name="execute_tool") async def loses_completion_once(call: ToolCall) -> ToolResult: result = await execute_tool(call) if activity.info().attempt == 1: raise RuntimeError("worker died after the effect, before reporting") return result async def body(): wf, cpu, gpu = pools(env, cpu=[call_llm, loses_completion_once, evaluate, compensate]) async with wf, cpu, gpu: handle = await start(env, "dup-42", max_steps=2) result = await handle.result() assert result["attempts"] == 1 and result["duplicates_suppressed"] == 1, result assert list(ledgers.TOOL_SIDE_EFFECTS.values()) == [1] history = await handle.fetch_history() assert len(events(history, "ACTIVITY_TASK_FAILED")) == 0, "a retried failure is not a history event either" loop.run_until_complete(body()) # ------------------------------------------------------------------ 4. cooperative cancel (Signal) def test_cancel_run_signal_finishes_the_step_then_stops_cleanly(loop, env): async def body(): wf, cpu, gpu = pools(env) async with wf, cpu, gpu: handle = await start(env, "cancelrun-42", max_steps=8) await wait_for(handle, lambda s: s["step"] >= 1) await handle.signal(AgentRun.cancel_run) result = await handle.result() # completes, does not fail assert result["status"] == "cancelled" and 1 <= result["steps"] < 8 assert ledgers.COMPENSATED == [], "nothing was held at a decision point" assert (await search_attrs(handle))["AgentStatus"] == "cancelled" loop.run_until_complete(body()) # ------------------------------------------------------------------ 5. Temporal cancel (handle.cancel) def test_temporal_cancel_during_the_gpu_step_runs_the_compensation(loop, env): """Cancel while the GPU tool is in flight. The Activity is asked to stop; the Workflow sees the cancellation, undoes what it holds (release_gpu), publishes `cancelled`, and closes as CANCELED.""" @activity.defn(name="execute_tool") async def slow_gpu_tool(call: ToolCall) -> ToolResult: for tick in range(200): # long, but heartbeating → cancellable activity.heartbeat({"tick": tick}) await asyncio.sleep(0.1) return await execute_tool(call) async def body(): wf, cpu, gpu = pools(env, gpu=[slow_gpu_tool]) async with wf, cpu, gpu: handle = await start(env, "cancel-42", max_steps=8) held = await wait_for(handle, lambda s: s["compensations"]) await handle.cancel() with pytest.raises(WorkflowFailureError) as err: await handle.result() assert isinstance(err.value.cause, CancelledError) assert ledgers.COMPENSATED == held["compensations"] == [f"release_gpu:{held['compensations'][0].split(':', 1)[1]}"] desc = await handle.describe() assert desc.status.name == "CANCELED" assert (await search_attrs(handle))["AgentStatus"] == "cancelled" history = await handle.fetch_history() scheduled = [ev.activity_task_scheduled_event_attributes.activity_type.name for ev in events(history, "ACTIVITY_TASK_SCHEDULED")] assert scheduled[-1] == "compensate", scheduled assert len(events(history, "WORKFLOW_EXECUTION_CANCEL_REQUESTED")) == 1 loop.run_until_complete(body()) # ------------------------------------------------------------------ 6. control surface def test_pause_update_validator_and_command_dedupe(loop, env): async def body(): wf, cpu, gpu = pools(env) async with wf, cpu, gpu: handle = await start(env, "control-42", max_steps=8) await handle.signal(AgentRun.pause) await wait_for(handle, lambda s: s["paused"] and s["status"] == "paused") assert (await search_attrs(handle))["AgentStatus"] == "paused" with pytest.raises(WorkflowUpdateFailedError): # rejected by the validator: never in history await handle.execute_update(AgentRun.change_goal, args=["", "c0"]) assert (await handle.execute_update(AgentRun.change_goal, args=["write the defense", "c1"])).startswith("goal changed") assert (await handle.execute_update(AgentRun.change_goal, args=["write the defense", "c1"])).startswith("ignored") await handle.signal(AgentRun.inject_context, args=["hurry", "c2"]) await handle.signal(AgentRun.inject_context, args=["hurry", "c2"]) s = await wait_for(handle, lambda s: "c2" in s["processed_command_ids"]) assert s["processed_command_ids"] == ["c1", "c2"] and s["goal"] == "write the defense" await handle.signal(AgentRun.resume) result = await handle.result() assert result["status"] == "done" and result["steps"] == 8 assert result["context_tail"].count("[injected: hurry]") <= 1 history = await handle.fetch_history() assert len(events(history, "WORKFLOW_EXECUTION_UPDATE_ACCEPTED")) == 2 # c1 twice; the rejected c0 is absent assert len(events(history, "WORKFLOW_EXECUTION_SIGNALED")) == 4 # pause, inject, inject, resume loop.run_until_complete(body()) # ------------------------------------------------------------------ 7. continue-as-new bound def test_continue_as_new_keeps_every_run_under_the_bound(loop, env): async def body(): wf, cpu, gpu = pools(env) async with wf, cpu, gpu: handle = await start(env, "can-42", max_steps=20) # STEPS_PER_RUN=8 → runs of 8, 8, 4 result = await handle.result() assert result["steps"] == 20 runs = await walk_runs(env.client, handle.id, handle.result_run_id) assert [r["closed_as"] for r in runs] == ["CONTINUED_AS_NEW", "CONTINUED_AS_NEW", "COMPLETED"] assert max(r["events"] for r in runs) <= 8 * 14, runs assert len({r["run_id"] for r in runs}) == 3 assert (await search_attrs(handle)) == {"AgentStatus": "done", "CurrentStep": 20, "Owner": "acct_test"} assert await (await handle.describe()).memo_value("tier") == "team" loop.run_until_complete(body())
11-capstone-durable-agent-runtime/worker_cpu.py
"""The `cpu-tools` pool: call_llm, execute_tool (CPU tools), evaluate, compensate.""" import asyncio import os import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from common import connect # noqa: E402 from temporalio.worker import Worker # noqa: E402 from agentrun import CPU_TOOLS, call_llm, compensate, evaluate, execute_tool # noqa: E402 async def main() -> None: client = await connect() worker = Worker(client, task_queue=CPU_TOOLS, activities=[call_llm, execute_tool, evaluate, compensate], identity=f"cpu-{os.getpid()}@{CPU_TOOLS}") print(f"cpu worker pid={os.getpid()} polling {CPU_TOOLS!r}: call_llm, execute_tool, evaluate, compensate") await worker.run() if __name__ == "__main__": asyncio.run(main())
11-capstone-durable-agent-runtime/worker_gpu.py
"""The `gpu-tools` pool: execute_tool for GPU tools only, GPU_SLOTS concurrent (default 1).""" import asyncio import os import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from common import connect # noqa: E402 from temporalio import activity # noqa: E402 from temporalio.exceptions import ApplicationError # noqa: E402 from temporalio.worker import Worker # noqa: E402 from agentrun import GPU_TOOLS, ToolCall, ToolResult, execute_tool # noqa: E402 from agentrun.lanes import GPU_TOOL_NAMES # noqa: E402 GPU_SLOTS = int(os.environ.get("GPU_SLOTS", "1")) @activity.defn(name="execute_tool") async def execute_tool_on_gpu(call: ToolCall) -> ToolResult: if call.tool not in GPU_TOOL_NAMES: raise ApplicationError(f"{call.tool} does not need a GPU", type="BadArguments", non_retryable=True) return await execute_tool(call) async def main() -> None: client = await connect() worker = Worker(client, task_queue=GPU_TOOLS, activities=[execute_tool_on_gpu], max_concurrent_activities=GPU_SLOTS, identity=f"gpu-{os.getpid()}@{GPU_TOOLS}") print(f"gpu worker pid={os.getpid()} polling {GPU_TOOLS!r} with {GPU_SLOTS} slot(s)") await worker.run() if __name__ == "__main__": asyncio.run(main())
11-capstone-durable-agent-runtime/worker_workflows.py
"""The `agent-workflows` pool: AgentRun and ResearchAgent, Workflow code only.""" import asyncio import os import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from common import connect # noqa: E402 from temporalio.worker import Worker # noqa: E402 from agentrun import AGENT_WORKFLOWS, AgentRun, ResearchAgent # noqa: E402 async def main() -> None: client = await connect() worker = Worker(client, task_queue=AGENT_WORKFLOWS, workflows=[AgentRun, ResearchAgent], identity=f"wf-{os.getpid()}@{AGENT_WORKFLOWS}") print(f"workflow worker pid={os.getpid()} polling {AGENT_WORKFLOWS!r}: AgentRun, ResearchAgent; kill -9 {os.getpid()} to crash it") await worker.run() if __name__ == "__main__": asyncio.run(main())
Lab source: labs/11-capstone-durable-agent-runtime in the course repository. Verified against temporal server start-dev with the Python SDK 1.32 on 2026-09-11.