<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Software Engineering on Shane&apos;s Personal Blog</title><description>Recent content in Software Engineering on Shane&apos;s Personal Blog</description><link>https://shanechang.com/categories/software-engineering/</link><language>en-us</language><lastBuildDate>Mon, 09 Mar 2026 00:00:00 GMT</lastBuildDate><atom:link href="https://shanechang.com/categories/software-engineering/index.xml" rel="self" type="application/rss+xml"/><item><title>Temporal Time-Skipping: The Clock You Didn&apos;t Know Existed</title><link>https://shanechang.com/p/temporal-time-skipping-the-clock-you-didnt-know-existed/</link><guid isPermaLink="true">https://shanechang.com/p/temporal-time-skipping-the-clock-you-didnt-know-existed/</guid><description>&lt;img src=&quot;https://shanechang.com/_astro/cover.D09JL2C2_Z1BbfYs.webp&quot; alt=&quot;Featured image of post Temporal Time-Skipping: The Clock You Didn&apos;t Know Existed&quot; /&gt;&lt;p&gt;I spent hours debugging a test that worked perfectly in production but exploded in the test environment. The workflow ran fine. The activities completed. The signals were correct. But every single test failed with a &lt;code&gt;TimeoutError&lt;/code&gt; — the workflow would just… die, before my test code even had a chance to interact with it.&lt;/p&gt;
&lt;p&gt;The culprit was a clock I didn’t know existed.&lt;/p&gt;
&lt;p&gt;This post is about the mental model that finally made Temporal’s time-skipping test server make sense to me. If you’ve ever been confused by &lt;code&gt;WorkflowEnvironment.start_time_skipping()&lt;/code&gt;, or had tests fail mysteriously with timeouts that don’t happen in production, this is for you.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;what-is-temporal-the-30-second-version&quot;&gt;What is Temporal? (The 30-Second Version)&lt;/h2&gt;
&lt;p&gt;Temporal is a workflow orchestration engine. You define your business logic as a “workflow” — a sequence of steps — and Temporal takes care of running it reliably. If a step fails, Temporal retries it. If your server crashes, Temporal picks up where it left off. It handles all the ugly stuff: retries, timeouts, state persistence, distributed coordination.&lt;/p&gt;
&lt;p&gt;The important thing for this post: Temporal runs your workflow inside its own runtime environment. Your workflow code doesn’t just execute like a normal Python function. It runs &lt;em&gt;inside Temporal&lt;/em&gt;, and Temporal manages when things happen.&lt;/p&gt;
&lt;p&gt;That distinction — “runs inside Temporal” — is the root of everything that follows.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;temporal-is-a-timekeeper&quot;&gt;Temporal is a Timekeeper&lt;/h2&gt;
&lt;p&gt;Here’s the insight that changed everything for me: &lt;strong&gt;Temporal doesn’t skip your code. It only controls its own clock.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I had been thinking about Temporal as some kind of execution engine that speeds up my code. It’s not. Temporal is a &lt;strong&gt;timekeeper&lt;/strong&gt;. Think of it like a kernel — a central dispatcher that keeps a timetable of scheduled events.&lt;/p&gt;
&lt;p&gt;When your workflow calls &lt;code&gt;workflow.sleep(3600)&lt;/code&gt; (sleep for one hour), Temporal doesn’t somehow make your Python code run faster. What it does is add an entry to its internal timetable:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light-default github-dark-default&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#0d1117;color:#1f2328;--shiki-dark:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&quot;Wake up workflow ABC at current_time + 3600 seconds&quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When your workflow starts an activity with a 30-second timeout, Temporal adds:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light-default github-dark-default&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#0d1117;color:#1f2328;--shiki-dark:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&quot;If activity XYZ hasn&apos;t returned by current_time + 30 seconds, fail it&quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When your workflow has an execution timeout of 10 minutes:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light-default github-dark-default&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#0d1117;color:#1f2328;--shiki-dark:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&quot;If workflow ABC isn&apos;t done by current_time + 600 seconds, kill it&quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Sleeps, timeouts, activity deadlines, heartbeat intervals — from Temporal’s perspective, they’re all the same thing. They’re entries in a timetable. Each one says: “fire event X at time T.” The only difference is what event gets fired — resume the workflow, fail an activity, kill the whole thing. But the mechanism is identical: a timestamp and an action.&lt;/p&gt;
&lt;p&gt;This is the foundation for understanding time-skipping.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;time-skipping-fast-forwarding-idle-time&quot;&gt;Time-Skipping: Fast-Forwarding Idle Time&lt;/h2&gt;
&lt;p&gt;Temporal’s Python SDK gives you two test environments:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;WorkflowEnvironment.start_time_skipping()&lt;/code&gt;&lt;/strong&gt; — downloads a lightweight Rust-based test server binary that runs in-process (no Docker needed). This server has a virtual clock that can jump forward.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;WorkflowEnvironment.start_local()&lt;/code&gt;&lt;/strong&gt; — runs a full Temporal server with a real clock, just like production.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The time-skipping server does one clever thing: &lt;strong&gt;when nothing is happening, it fast-forwards its clock to the next scheduled event.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Imagine your workflow does this:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light-default github-dark-default&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#0d1117;color:#1f2328;--shiki-dark:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;await&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt; workflow.sleep(&lt;/span&gt;&lt;span style=&quot;color:#0550AE;--shiki-dark:#79C0FF&quot;&gt;3600&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;)   &lt;/span&gt;&lt;span style=&quot;color:#6E7781;--shiki-dark:#8B949E&quot;&gt;# sleep 1 hour&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;await&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt; do_some_activity()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;await&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt; workflow.sleep(&lt;/span&gt;&lt;span style=&quot;color:#0550AE;--shiki-dark:#79C0FF&quot;&gt;7200&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;)   &lt;/span&gt;&lt;span style=&quot;color:#6E7781;--shiki-dark:#8B949E&quot;&gt;# sleep 2 hours&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In production, this takes 3 hours of wall-clock time (plus however long the activity takes). With time-skipping, here’s what happens:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Workflow calls &lt;code&gt;sleep(3600)&lt;/code&gt;. Temporal adds to its timetable: “resume at now + 3600s.”&lt;/li&gt;
&lt;li&gt;Nothing else is pending. The server jumps its clock forward 3600 seconds instantly.&lt;/li&gt;
&lt;li&gt;Timer fires. Workflow resumes. Activity starts.&lt;/li&gt;
&lt;li&gt;Activity is running — the server &lt;strong&gt;does not skip&lt;/strong&gt;, because it’s waiting for a real result.&lt;/li&gt;
&lt;li&gt;Activity completes. Workflow calls &lt;code&gt;sleep(7200)&lt;/code&gt;. Timetable entry added.&lt;/li&gt;
&lt;li&gt;Nothing pending. Server jumps forward 7200 seconds.&lt;/li&gt;
&lt;li&gt;Timer fires. Workflow finishes.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Total real time: however long the activity took (maybe milliseconds if it’s a mock). The 3 hours of sleeping? Gone. Skipped.&lt;/p&gt;
&lt;p&gt;This leads to a really important corollary that solidified my understanding:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;If your code never sleeps, never waits, and never sets timeouts, time-skipping gives you zero speedup.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Think about it. Time-skipping only fast-forwards idle time on Temporal’s clock. If there’s no idle time — no timers, no sleeps, no timeouts — there’s nothing to skip. Your workflow would run at the exact same speed with time-skipping as without it.&lt;/p&gt;
&lt;p&gt;Of course, that’s a theoretical extreme. Real workflows almost always have timeouts, retry intervals, and sleeps. But the principle is clarifying: time-skipping is not about making your code faster. It’s about eliminating wait time between events on Temporal’s timetable.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-handle-your-remote-control&quot;&gt;The Handle: Your Remote Control&lt;/h2&gt;
&lt;p&gt;When you start a workflow, Temporal gives you back a &lt;strong&gt;handle&lt;/strong&gt;. Think of it like a restaurant ticket — you placed your order (started the workflow), and now you have a ticket to interact with it while it’s being prepared.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light-default github-dark-default&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#0d1117;color:#1f2328;--shiki-dark:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;handle &lt;/span&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt; await&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt; client.start_workflow(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;    MyWorkflow.run,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;    inputs,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#953800;--shiki-dark:#FFA657&quot;&gt;    id&lt;/span&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#0A3069;--shiki-dark:#A5D6FF&quot;&gt;&quot;order-123&quot;&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#953800;--shiki-dark:#FFA657&quot;&gt;    task_queue&lt;/span&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#0A3069;--shiki-dark:#A5D6FF&quot;&gt;&quot;kitchen&quot;&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The workflow is now running independently inside Temporal’s runtime. The handle is your remote control. It has a few buttons:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;handle.result()&lt;/code&gt;&lt;/strong&gt; — “Call me when my food is ready.” You sit on the phone, waiting, until the workflow finishes and gives you the result.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;handle.query()&lt;/code&gt;&lt;/strong&gt; — “Hey, how’s my order coming?” A quick question. You get an answer immediately and hang up.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;handle.signal()&lt;/code&gt;&lt;/strong&gt; — “Actually, add extra cheese.” You send a message to the running workflow. It gets delivered immediately.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;handle.cancel()&lt;/code&gt;&lt;/strong&gt; — “Cancel my order.”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The handle is just &lt;code&gt;temporalio.client.WorkflowHandle&lt;/code&gt; — nothing magical. But understanding what each button &lt;em&gt;does&lt;/em&gt; to the time-skipping server is where it gets interesting.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;result-vs-query--the-trap&quot;&gt;&lt;code&gt;result()&lt;/code&gt; vs &lt;code&gt;query()&lt;/code&gt; — The Trap&lt;/h2&gt;
&lt;p&gt;This is where my intuition was completely backwards, and getting it right is what finally unblocked me.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;handle.result()&lt;/code&gt;&lt;/strong&gt; is a phone call where you say: &lt;em&gt;“Don’t hang up until my food is ready.”&lt;/em&gt; You’re blocking. You’re waiting. And critically, you’re telling the time-skipping server: &lt;strong&gt;“I’m waiting for this workflow to finish.”&lt;/strong&gt; The server hears that and thinks: “They want the result. Let me help by fast-forwarding to when it’s done.”&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;handle.query()&lt;/code&gt;&lt;/strong&gt; is a phone call where you say: &lt;em&gt;“Is my food ready? No? Okay, bye.”&lt;/em&gt; One shot. You get the current state, and then you’re done. The server has no reason to fast-forward anything — you didn’t ask it to wait for anything.&lt;/p&gt;
&lt;p&gt;Here’s the part that tripped me up: &lt;strong&gt;&lt;code&gt;result()&lt;/code&gt; is event-based (wait for the “done” event), and &lt;code&gt;query()&lt;/code&gt; is polling (check the current state and return immediately).&lt;/strong&gt; You’d think the event-based approach is the “better” one — and in normal programming, it usually is. But in time-skipping mode, the polling approach is safer, because it doesn’t give Temporal permission to mess with its clock.&lt;/p&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Operation&lt;/th&gt;&lt;th&gt;What it says to Temporal&lt;/th&gt;&lt;th&gt;Triggers time-skipping?&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;handle.result()&lt;/code&gt;&lt;/td&gt;&lt;td&gt;”Wake me up when it’s done”&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt; — server tries to &lt;em&gt;make&lt;/em&gt; it be done&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;handle.query()&lt;/code&gt;&lt;/td&gt;&lt;td&gt;”What’s the status right now?”&lt;/td&gt;&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt; — server just answers&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;handle.signal()&lt;/code&gt;&lt;/td&gt;&lt;td&gt;”Deliver this message”&lt;/td&gt;&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt; — immediate delivery&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;That middle column is the whole story. &lt;code&gt;result()&lt;/code&gt; gives the server license to fast-forward. &lt;code&gt;query()&lt;/code&gt; doesn’t.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-race-condition-death-by-time-travel&quot;&gt;The Race Condition: Death by Time Travel&lt;/h2&gt;
&lt;p&gt;Now you have all the pieces. Let me show you how they combine to create a very confusing bug.&lt;/p&gt;
&lt;h3 id=&quot;the-setup&quot;&gt;The Setup&lt;/h3&gt;
&lt;p&gt;Our workflow has steps that need user feedback. After a step runs (say, analyzing a document), the workflow pauses and waits for the user to review the output and click “Continue.” In production, this might take minutes or hours — the user is reading, thinking, editing.&lt;/p&gt;
&lt;p&gt;In the code, this waiting looks like:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light-default github-dark-default&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#0d1117;color:#1f2328;--shiki-dark:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6E7781;--shiki-dark:#8B949E&quot;&gt;# backend/src/genai/temporal/workflow_executor.py, line 149&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;await&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt; workflow.wait_condition(&lt;/span&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;lambda&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;: waiter.signal_received)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This tells Temporal: “Pause here until &lt;code&gt;signal_received&lt;/code&gt; becomes &lt;code&gt;True&lt;/code&gt;.” There’s no timeout — it waits as long as it takes. In production, that’s fine. The user eventually clicks Continue, a signal is sent, &lt;code&gt;signal_received&lt;/code&gt; flips to &lt;code&gt;True&lt;/code&gt;, and the workflow resumes.&lt;/p&gt;
&lt;h3 id=&quot;the-exam-analogy&quot;&gt;The Exam Analogy&lt;/h3&gt;
&lt;p&gt;Imagine you’re a teacher proctoring an exam with a 2-hour time limit. You have a clock on the wall.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Production (real clock):&lt;/strong&gt; A student raises their hand. “I need my calculator from my locker.” You wait. Someone brings it. The student finishes the exam. The clock says 45 minutes passed. No problem.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Time-skipping (magic clock):&lt;/strong&gt; A student raises their hand. “I need my calculator from my locker.” You look around the room. Nobody is actively writing. Nothing is happening. So you spin the magic clock forward — 30 minutes, 1 hour, 1.5 hours, 2 hours. “Time’s up! Exam over!” The student fails.&lt;/p&gt;
&lt;p&gt;The person bringing the calculator walks in 0.1 real seconds later. But the clock already says 2 hours. Too late.&lt;/p&gt;
&lt;h3 id=&quot;what-actually-happened&quot;&gt;What Actually Happened&lt;/h3&gt;
&lt;p&gt;Here’s the exact sequence in our tests:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Real time 0.00s&lt;/strong&gt; — Test starts the workflow. Mock activities return instantly (they’re fakes — no real I/O).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Real time ~0.02s&lt;/strong&gt; — All activities are done. Workflow enters &lt;code&gt;wait_condition()&lt;/code&gt; — waiting for a feedback signal. At this moment, Temporal’s timetable has &lt;strong&gt;nothing pending&lt;/strong&gt;: no activities running, no timers set. Just a workflow sitting at a &lt;code&gt;wait_condition&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Real time ~0.02s&lt;/strong&gt; — Test calls &lt;code&gt;handle.result()&lt;/code&gt;: “Tell me when the workflow finishes.”&lt;/p&gt;
&lt;p&gt;The time-skipping server hears this and thinks: &lt;em&gt;“The client wants the result. Let me check what’s pending… No activities. No timers. Nothing to wait for except a &lt;code&gt;wait_condition&lt;/code&gt; I can’t satisfy. But there IS a workflow execution timeout at now + 600 seconds. Let me jump there.”&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Server clock jumps:&lt;/strong&gt; 0s → 600s.&lt;/p&gt;
&lt;p&gt;Workflow execution timeout fires. Workflow dies with &lt;code&gt;TimeoutError&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Real time ~0.03s&lt;/strong&gt; — &lt;code&gt;handle.result()&lt;/code&gt; returns with an error.&lt;/p&gt;
&lt;p&gt;Meanwhile, the test had a polling loop that was &lt;em&gt;supposed&lt;/em&gt; to query for pending feedback steps and send signals. That loop was scheduled to run after &lt;code&gt;asyncio.sleep(0.1)&lt;/code&gt; — at real time 0.1 seconds. But the workflow is already dead. The signals arrive at a corpse.&lt;/p&gt;
&lt;p&gt;The whole thing happened in ~30 milliseconds of real time. The server just… jumped to the end.&lt;/p&gt;
&lt;h3 id=&quot;the-fix&quot;&gt;The Fix&lt;/h3&gt;
&lt;p&gt;The fix is almost anticlimactic once you understand the problem. Don’t call &lt;code&gt;handle.result()&lt;/code&gt; while the workflow is waiting for signals. Instead, use &lt;code&gt;handle.query()&lt;/code&gt; to poll, send signals when needed, and only call &lt;code&gt;result()&lt;/code&gt; after confirming the workflow is already done:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light-default github-dark-default&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#0d1117;color:#1f2328;--shiki-dark:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6E7781;--shiki-dark:#8B949E&quot;&gt;# backend/tests/integration/genai/test_temporal_workflow.py, lines 49-77&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;async&lt;/span&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt; def&lt;/span&gt;&lt;span style=&quot;color:#8250DF;--shiki-dark:#D2A8FF&quot;&gt; _run_workflow_with_feedback&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;(handle):&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;    while&lt;/span&gt;&lt;span style=&quot;color:#0550AE;--shiki-dark:#79C0FF&quot;&gt; True&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;        await&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt; asyncio.sleep(&lt;/span&gt;&lt;span style=&quot;color:#0550AE;--shiki-dark:#79C0FF&quot;&gt;0.1&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;)    &lt;/span&gt;&lt;span style=&quot;color:#6E7781;--shiki-dark:#8B949E&quot;&gt;# real-time sleep in the test process&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6E7781;--shiki-dark:#8B949E&quot;&gt;        # query() doesn&apos;t trigger time-skipping — clock stays frozen&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;        progress &lt;/span&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt; await&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt; handle.query(JurorAnalysisWorkflow.get_progress)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6E7781;--shiki-dark:#8B949E&quot;&gt;        # Send signals for any steps waiting for feedback&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;        for&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt; step_id &lt;/span&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;in&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt; progress.pending_feedback_steps:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;            await&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt; handle.signal(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;                JurorAnalysisWorkflow.submit_step_feedback,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;                StepFeedbackSignal(&lt;/span&gt;&lt;span style=&quot;color:#953800;--shiki-dark:#FFA657&quot;&gt;release_step_id&lt;/span&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;step_id),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;            )&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6E7781;--shiki-dark:#8B949E&quot;&gt;        # Only call result() AFTER the workflow reports it&apos;s done&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;        if&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt; progress.status &lt;/span&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;==&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt; WorkflowStatus.&lt;/span&gt;&lt;span style=&quot;color:#0550AE;--shiki-dark:#79C0FF&quot;&gt;COMPLETED&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;            return&lt;/span&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt; await&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt; handle.result()  &lt;/span&gt;&lt;span style=&quot;color:#6E7781;--shiki-dark:#8B949E&quot;&gt;# returns instantly — nothing to skip&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Why this works:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;query()&lt;/code&gt; doesn’t touch the clock.&lt;/strong&gt; The workflow stays frozen at its &lt;code&gt;wait_condition&lt;/code&gt;. No fast-forwarding.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Our polling loop runs in real time.&lt;/strong&gt; &lt;code&gt;asyncio.sleep(0.1)&lt;/code&gt; is a real sleep in the test process — Temporal doesn’t control it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Signals are delivered immediately&lt;/strong&gt; regardless of what Temporal’s clock says.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;By the time we call &lt;code&gt;result()&lt;/code&gt;, the workflow is already complete.&lt;/strong&gt; There’s nothing to fast-forward to. It returns instantly.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The workflow’s clock never jumps because we never gave the server permission to jump it.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;p&gt;A few rules of thumb I’m keeping in my back pocket:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The mental model:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Temporal is a timekeeper, not an execution engine. It manages a timetable of timers, timeouts, and activity deadlines.&lt;/li&gt;
&lt;li&gt;Time-skipping fast-forwards idle time on Temporal’s clock. It doesn’t speed up your code.&lt;/li&gt;
&lt;li&gt;If there’s nothing on the timetable to skip to, there’s nothing to skip.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;The practical rules:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Never &lt;code&gt;await handle.result()&lt;/code&gt; on a workflow that’s waiting for external signals. It gives Temporal permission to fast-forward, and the workflow will die before your signals arrive.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;handle.query()&lt;/code&gt; to poll workflow state. Queries are passive reads — they don’t affect the clock.&lt;/li&gt;
&lt;li&gt;Signals work immediately in any time mode. They don’t care what the server clock says.&lt;/li&gt;
&lt;li&gt;Only call &lt;code&gt;handle.result()&lt;/code&gt; after you’ve confirmed the workflow is done via &lt;code&gt;query()&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;When to use which test environment:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;start_time_skipping()&lt;/code&gt;&lt;/strong&gt; — great for workflows that are self-contained (just timers and activities, no external interaction). Also works for signal-based workflows &lt;em&gt;if&lt;/em&gt; you use the query-based polling pattern.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;start_local()&lt;/code&gt;&lt;/strong&gt; — safer for workflows that require signals/queries during execution, since it runs in real-time. But slower, because timers actually wait.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We kept &lt;code&gt;start_time_skipping()&lt;/code&gt; because the query-based pattern works correctly, and we get free speedup on any timer-based operations (like activity retry intervals and jitter sleeps).&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Disclaimer: Written by Human, improved using AI where applicable.&lt;/em&gt;&lt;/p&gt;</description><pubDate>Mon, 09 Mar 2026 00:00:00 GMT</pubDate></item><item><title>Cursor Is Brilliant—And Here&apos;s Why I Still Hate Using It</title><link>https://shanechang.com/p/cursor-windsurf-comparison-review/</link><guid isPermaLink="true">https://shanechang.com/p/cursor-windsurf-comparison-review/</guid><description>&lt;img src=&quot;https://shanechang.com/_astro/cover.DGRCuqQK_Z19ffmq.webp&quot; alt=&quot;Featured image of post Cursor Is Brilliant—And Here&apos;s Why I Still Hate Using It&quot; /&gt;&lt;p&gt;Let me tell you about the $600 burnout.&lt;/p&gt;
&lt;p&gt;Two-thirds through the month, I’d already exhausted my Cursor quota. Not because I was being careless—I was just doing my job. Eight hours a day of building features, reviewing code, tweaking implementations. The kind of work that requires a senior engineer who cares about type-strict Python, modular architecture, and maintainable systems.&lt;/p&gt;
&lt;p&gt;I sat there, staring at the “upgrade to pay-as-you-go” prompt, feeling something I hadn’t expected: exhaustion mixed with resentment. Cursor is &lt;em&gt;brilliant&lt;/em&gt;. Its retrieval system finds scattered dependencies across your codebase like magic. Its context understanding makes other tools feel prehistoric. It rarely fails.&lt;/p&gt;
&lt;p&gt;And yet, in that moment, I realized I’d been fighting it for weeks.&lt;/p&gt;
&lt;p&gt;This isn’t a typical tool review. I’m not here to crown a winner or tell you what to buy. I’m a full-stack engineer running an AI startup, with a master’s in ML and strong opinions about code quality. I need tools that respect my judgment, not override it. What I learned using Cursor, Windsurf, and Claude Code for months revealed something more interesting than “which is best”—it exposed two fundamentally different philosophies about what AI-assisted development should be.&lt;/p&gt;
&lt;h2 id=&quot;the-problem-nobody-talks-about-cognitive-predictability&quot;&gt;The Problem Nobody Talks About: Cognitive Predictability&lt;/h2&gt;
&lt;p&gt;Here’s what every review misses about pricing.&lt;/p&gt;
&lt;p&gt;Cursor shows you token burn in real-time. Seems transparent, right? Except I had no idea what it actually &lt;em&gt;meant&lt;/em&gt;. How much does a refactor cost? A feature implementation? A complex architectural change? The numbers were visible but meaningless.&lt;/p&gt;
&lt;p&gt;I’d hesitate before exploring ideas. “Should I ask it to try this approach, or will that burn through my quota?” That hesitation—that’s the real cost. Not the dollars, but the cognitive overhead of constantly calculating whether a thought is “expensive enough” to pursue.&lt;/p&gt;
&lt;p&gt;Then I tried Windsurf again.&lt;/p&gt;
&lt;p&gt;Their pricing model is almost boring in its clarity. Every model has an explicit credit multiplier. Opus uses 3x credits. The million-token Sonnet? 10x credits. You know &lt;em&gt;before&lt;/em&gt; you ask whether something will be expensive. Within a week, I’d internalized the pattern: roughly $10 per day of normal development work.&lt;/p&gt;
&lt;p&gt;I set up auto top-up and stopped thinking about it entirely.&lt;/p&gt;
&lt;p&gt;The psychological difference was striking. Not because Windsurf was cheaper—at similar usage intensity, they cost about the same. But because &lt;em&gt;I could predict it&lt;/em&gt;. My brain could offload that worry and focus on the actual work. Cursor’s opacity created a constant background anxiety that I didn’t even notice until it was gone.&lt;/p&gt;
&lt;p&gt;Opaque pricing doesn’t just affect your budget. It affects your thinking.&lt;/p&gt;
&lt;h2 id=&quot;when-smart-tools-fight-smart-developers&quot;&gt;When Smart Tools Fight Smart Developers&lt;/h2&gt;
&lt;p&gt;I need to be precise about something: Cursor rarely makes mistakes. Its code is usually correct. Its suggestions are often clever. This isn’t about technical failure—it’s about something more subtle.&lt;/p&gt;
&lt;p&gt;Let me show you what I mean.&lt;/p&gt;
&lt;p&gt;My entire codebase uses single-line docstrings. Every function, every class—just a concise description of what it does. I’m strict about type hints at the function signature level, no type decorations cluttering the docstrings. When I use Pydantic models, that’s the pattern everywhere. The codebase is &lt;em&gt;screaming&lt;/em&gt; its conventions at you.&lt;/p&gt;
&lt;p&gt;Windsurf sees this and follows it. New code matches existing patterns. If the codebase uses Pydantic, the new feature uses Pydantic. If docstrings are single-line, they stay single-line. It treats your codebase as the source of truth.&lt;/p&gt;
&lt;p&gt;Cursor sees this and… ignores it.&lt;/p&gt;
&lt;p&gt;Unless I explicitly prompt “use single-line docstrings,” it generates multi-line ones. Even when literally every surrounding function uses single-line format. It wants to write tests. It wants to generate summary documentation. It has &lt;em&gt;opinions&lt;/em&gt; about how code should look, and those opinions override what your project is telling it.&lt;/p&gt;
&lt;p&gt;You can fight this with prompting. Every time I carefully articulate what I want to build, I also have to remember to say: “Follow the existing code style. Use single-line docstrings. Use Pydantic. Match the type hint patterns.” It’s like having a brilliant colleague who needs to be constantly reminded that they’re not working on their own project.&lt;/p&gt;
&lt;p&gt;This creates an exhausting loop. I word my intent carefully, expecting a thoughtful proposal. Instead, Cursor immediately jumps to code editing, applying its global style defaults. I have to stop it, reassert constraints that should be obvious from the codebase, and restart. The tool itself becomes part of my cognitive load.&lt;/p&gt;
&lt;p&gt;Here’s what makes this particularly frustrating: this behavior makes sense for beginners. If you’re learning, opinionated defaults teach good practices. The problem is that Cursor optimizes for the median developer—someone who needs guardrails. For senior engineers with established patterns and strong preferences, those same guardrails become chains.&lt;/p&gt;
&lt;p&gt;I don’t want my tools to have opinions about my code style. I want them to learn mine.&lt;/p&gt;
&lt;h2 id=&quot;the-one-thing-cursor-actually-gets-right&quot;&gt;The One Thing Cursor Actually Gets Right&lt;/h2&gt;
&lt;p&gt;I need to be fair here, because Cursor’s retrieval system is genuinely impressive.&lt;/p&gt;
&lt;p&gt;When you ask Cursor a question about your codebase, it &lt;em&gt;shows&lt;/em&gt; you its search in action. That “searching…” indicator isn’t just UI polish—it’s reflecting a powerful RAG system finding relevant context. It’s explicit, visual, and frequent. You can almost watch it think.&lt;/p&gt;
&lt;p&gt;This makes Cursor exceptional at project-wide planning. Need to implement a feature that touches multiple services? Cursor finds the scattered pieces, understands the dependencies, and gives you a coherent proposal. It’s like having a colleague who’s memorized your entire codebase.&lt;/p&gt;
&lt;p&gt;Windsurf has “Quick Context,” but it’s rarely invoked automatically. You have to explicitly ask for it, and even then, it feels more like keyword search than semantic retrieval. Fast, yes. But less powerful. Sometimes it traces files instead of actually searching for concepts.&lt;/p&gt;
&lt;p&gt;The frustrating part? Cursor’s strength is also its weakness. Because the search is so opinionated and automatic, you can’t always tell when to trust it. I’ve had Cursor confidently delete code because its search didn’t find a feature—even though that feature existed and was important. When I catch this happening, I have to tell it: “Read the whole file, don’t search.” But sometimes it searches anyway.&lt;/p&gt;
&lt;p&gt;This perfectly captures Cursor’s philosophy. The system is smart enough to be useful and opinionated enough to override you. For exploration and architecture planning, that’s valuable. For careful implementation of critical code, it’s dangerous.&lt;/p&gt;
&lt;h2 id=&quot;the-mental-load-you-dont-notice-until-its-gone&quot;&gt;The Mental Load You Don’t Notice Until It’s Gone&lt;/h2&gt;
&lt;p&gt;There’s a specific kind of frustration that’s hard to articulate. Not anger—more like background static.&lt;/p&gt;
&lt;p&gt;With Cursor, I’m always aware I’m using Cursor. Will this prompt be expensive? Did it follow my style? Is it about to apply its defaults? Should I stop it before it generates tests I didn’t ask for?&lt;/p&gt;
&lt;p&gt;With Windsurf, I think about the code.&lt;/p&gt;
&lt;p&gt;That’s it. That’s the difference.&lt;/p&gt;
&lt;p&gt;Windsurf occasionally hangs on tool calls—running multiple commands in parallel sometimes causes the first few to never stop. That’s a real bug, purely in their execution engine. But you know what? It doesn’t frustrate me the same way Cursor’s behavior does.&lt;/p&gt;
&lt;p&gt;Because when Windsurf fails, it’s failing &lt;em&gt;while trying to do what I asked&lt;/em&gt;. When Cursor frustrates me, it’s succeeding &lt;em&gt;while doing something I didn’t ask for&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;There’s a principle here: reliability without respect isn’t helpful. Cursor is more reliable technically—I rarely see it actually crash or error out. But Windsurf respects my intent more deeply. Given the choice between a tool that works perfectly but fights me constantly, and a tool that occasionally stumbles but fundamentally aligns with my goals… I’ll take the latter.&lt;/p&gt;
&lt;h2 id=&quot;what-actually-matters-the-model-not-the-wrapper&quot;&gt;What Actually Matters: The Model, Not the Wrapper&lt;/h2&gt;
&lt;p&gt;Here’s something that took me months to realize: code quality comes from the model, not the IDE.&lt;/p&gt;
&lt;p&gt;Claude Opus generates excellent code. Clear structure, proper types, handles edge cases well. Usually one or two iterations to get exactly what you want—assuming your prompt is clear. (Nothing can save you from bad prompting, by the way.)&lt;/p&gt;
&lt;p&gt;Claude Sonnet is faster but noticeably weaker on complex type systems. It struggles with Python generics. Makes more syntax errors. For planning and proposals, it’s fine. For implementation of type-strict, high-quality code, it’s inadequate.&lt;/p&gt;
&lt;p&gt;The IDE’s job should be simple: stay out of the model’s way. Let Opus be Opus. Let Sonnet be Sonnet. Provide context, handle tooling, then step back.&lt;/p&gt;
&lt;p&gt;Cursor doesn’t step back. It wraps the model in layers of prompt orchestration. Forces conventions, generates extra artifacts, enforces style. Sometimes this improves the output for beginners. For experts, it &lt;em&gt;degrades&lt;/em&gt; what the model could have produced on its own.&lt;/p&gt;
&lt;p&gt;This is why I find myself switching back to Windsurf when I need serious, production-quality code. Not because Windsurf is smarter—it’s not. But because it trusts the model enough to mostly leave it alone. I get closer to what Claude Opus is actually capable of, without an IDE’s opinions interfering.&lt;/p&gt;
&lt;h2 id=&quot;why-i-use-both-and-why-you-might-too&quot;&gt;Why I Use Both (And Why You Might Too)&lt;/h2&gt;
&lt;p&gt;After months of frustration, experimentation, and honest assessment, here’s what I’ve learned:&lt;/p&gt;
&lt;p&gt;I use Cursor for exploration. When I’m starting a new feature, trying to understand how different parts of the codebase connect, planning architecture—Cursor’s RAG system is unmatched. It’s worth the mental overhead for that initial phase. I get a clear map of what needs to change and why.&lt;/p&gt;
&lt;p&gt;Then I switch to Windsurf for implementation. When I know what needs to be built and I need it done &lt;em&gt;right&lt;/em&gt;—clean types, consistent style, modular structure—Windsurf respects my codebase’s patterns. The code it generates fits naturally into what already exists.&lt;/p&gt;
&lt;p&gt;And when I’ve burned through quotas on both? Claude Code becomes the fallback. All-or-nothing interaction, but at least it’s thinking is sharp. I can’t edit incrementally, but for architectural decisions or complex reasoning, it still delivers.&lt;/p&gt;
&lt;p&gt;This isn’t indecision. It’s pragmatism. Different tools for different phases of work.&lt;/p&gt;
&lt;p&gt;The uncomfortable truth is that neither Cursor nor Windsurf is “better.” They represent fundamentally different philosophies about what AI assistance should be. Cursor is productized intelligence—heavily orchestrated, optimized for consistency across millions of users, opinionated by design. Windsurf is model-first intelligence—thinner abstraction, codebase-driven behavior, fewer guardrails.&lt;/p&gt;
&lt;p&gt;If you’re a beginner or working on unfamiliar territory, Cursor’s opinions help you. If you’re a senior engineer with strong preferences and established patterns, those same opinions fight you.&lt;/p&gt;
&lt;h2 id=&quot;the-ide-i-actually-want-to-use-in-five-years&quot;&gt;The IDE I Actually Want to Use in Five Years&lt;/h2&gt;
&lt;p&gt;Right now, we’re in the hype phase. Everyone’s building “AI agents” and treating prompts like trade secrets. Cursor hides its orchestration entirely. Windsurf is more transparent but still opaque. We’re supposed to trust these black boxes because AI is magic and mysterious.&lt;/p&gt;
&lt;p&gt;But the hype will fade. It always does. And when developers start caring more about productivity than novelty, something will shift.&lt;/p&gt;
&lt;p&gt;The winning IDE won’t be the smartest. It’ll be the most honest.&lt;/p&gt;
&lt;p&gt;I want an IDE that completely exposes its agent prompts. Not just makes them accessible if you hack around—actually &lt;em&gt;shows&lt;/em&gt; them to you. In the UI. As first-class configuration. “Here’s what I’m telling the model. Here’s the context I’m injecting. Here’s the style guidance I’m applying. Change any of it.”&lt;/p&gt;
&lt;p&gt;Full customizability. Per-project preferences. Per-user behavior profiles. Even AI-assisted prompt tuning—let me tell the IDE “be more like Windsurf” and have it adjust its orchestration accordingly.&lt;/p&gt;
&lt;p&gt;This isn’t radical. It’s just taking developers seriously. We’re the users. We understand prompts. We can handle complexity. Stop treating us like consumers who need hand-holding and start treating us like engineers who need control.&lt;/p&gt;
&lt;p&gt;The best AI IDE won’t have opinions. It will learn yours.&lt;/p&gt;
&lt;h2 id=&quot;what-this-means-for-you&quot;&gt;What This Means for You&lt;/h2&gt;
&lt;p&gt;If you’re evaluating these tools, here’s what actually matters:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Choose Cursor if:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You’re learning or working in unfamiliar domains&lt;/li&gt;
&lt;li&gt;You need powerful project-wide context and planning&lt;/li&gt;
&lt;li&gt;You’re okay with opinionated defaults and can work within them&lt;/li&gt;
&lt;li&gt;You have predictable, moderate usage patterns&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Choose Windsurf if:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You’re a senior engineer with strong style preferences&lt;/li&gt;
&lt;li&gt;You prioritize code quality and codebase consistency&lt;/li&gt;
&lt;li&gt;You want transparent, predictable pricing&lt;/li&gt;
&lt;li&gt;You’re willing to manually invoke context when needed&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Choose both if:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You do complex, multi-phase development work&lt;/li&gt;
&lt;li&gt;You can afford the cognitive overhead of switching&lt;/li&gt;
&lt;li&gt;You value exploration (Cursor) and implementation (Windsurf) differently&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;But here’s the deeper insight: don’t evaluate AI IDEs like you evaluate programming languages or frameworks. Evaluate them like you evaluate &lt;em&gt;working relationships&lt;/em&gt;. Does this tool respect your expertise? Does it impose cognitive overhead or reduce it? Does it amplify your thinking or fight your intent?&lt;/p&gt;
&lt;p&gt;Because we’re not just choosing tools anymore. We’re choosing collaborators. And the best collaborators aren’t the smartest—they’re the ones who know when to lead and when to follow.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;I still use Cursor. I still get frustrated by it. I still pay the bills.&lt;/p&gt;
&lt;p&gt;But I know now what I’m paying for: powerful intelligence that comes with the cost of constant negotiation. Some days, that’s worth it. Other days, I need a tool that just… builds what I asked for, the way I asked for it.&lt;/p&gt;
&lt;p&gt;The future of AI-assisted development isn’t about building smarter agents. It’s about building more honest ones. Tools that expose their assumptions, respect our preferences, and understand that control isn’t the opposite of intelligence—it’s a requirement for trust.&lt;/p&gt;
&lt;p&gt;Until then, I’ll keep both IDEs open. And keep fighting for the code I actually want to write.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;(Written by Human, improved using AI where applicable.)&lt;/em&gt;&lt;/p&gt;</description><pubDate>Fri, 19 Dec 2025 00:00:00 GMT</pubDate></item><item><title>Why AI Cannot Engineer</title><link>https://shanechang.com/p/why-ai-cannot-engineer/</link><guid isPermaLink="true">https://shanechang.com/p/why-ai-cannot-engineer/</guid><description>&lt;img src=&quot;https://shanechang.com/_astro/cover.CTct8cc3_1GfOkr.webp&quot; alt=&quot;Featured image of post Why AI Cannot Engineer&quot; /&gt;&lt;p&gt;I asked Claude to build me a customer dashboard last month. Ten minutes later, I had gorgeous React components, a clean API structure, and authentication that worked flawlessly on my laptop. I felt like a wizard.&lt;/p&gt;
&lt;p&gt;Two days later, when we put it in front of actual users, everything fell apart. The API crumbled under concurrent requests. The authentication tokens expired in the middle of user sessions. The database queries that felt snappy with my 10 test records took 30 seconds with real data.&lt;/p&gt;
&lt;p&gt;The code looked beautiful. It even worked—sort of. But it was a disaster waiting to happen, and I had no one to blame but myself. I’d asked for a construction worker when I needed an architect.&lt;/p&gt;
&lt;h2 id=&quot;the-skyscraper-nobody-can-live-in&quot;&gt;The Skyscraper Nobody Can Live In&lt;/h2&gt;
&lt;p&gt;Think about building a skyscraper for a moment. You need two completely different types of expertise:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Architects and Engineers&lt;/strong&gt; design the building. They figure out where the load-bearing walls go, how to route all the electrical wiring and plumbing, what materials can handle the wind shear at that height, how the building responds to earthquakes. They think about fire escapes and elevator shafts and how people will actually move through the space. They create detailed specifications for everything.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Construction Workers&lt;/strong&gt; build it. They know how to mix concrete, how to weld steel beams, how to install windows. Give them a good blueprint with all the specifications—“use this gauge of rebar, space them 6 inches apart, let the concrete cure for 48 hours”—and they’ll build you something solid and safe.&lt;/p&gt;
&lt;p&gt;Here’s the interesting part: construction workers understand building principles. They know that rebar makes concrete stronger, that you can’t just drill holes wherever you want in a load-bearing wall, that certain materials expand in heat. They might not be able to calculate the exact compression loads or explain the physics, but they know &lt;em&gt;how&lt;/em&gt; to build.&lt;/p&gt;
&lt;p&gt;So in theory, if you asked construction workers to build a skyscraper without giving them architectural plans, they could do it. They’d use their knowledge to put up walls and install wiring and plumbing. The building would stand.&lt;/p&gt;
&lt;p&gt;For a while.&lt;/p&gt;
&lt;p&gt;Then someone tries to actually &lt;em&gt;live&lt;/em&gt; in it. Or an earthquake hits. Or there’s a fire. And you realize the electrical system is a tangled mess that can’t handle the load. The plumbing creates massive pressure problems on the upper floors. There are no proper fire exits. The structure can’t flex with the wind. It looks like a building, but it’s not safe, not livable, not reliable. It’s a expensive disaster wrapped in a pretty facade.&lt;/p&gt;
&lt;p&gt;You really needed those architects and engineers.&lt;/p&gt;
&lt;h2 id=&quot;we-accidentally-merged-two-jobs&quot;&gt;We Accidentally Merged Two Jobs&lt;/h2&gt;
&lt;p&gt;Now let’s talk about software. In our industry, we have the same two roles—but we’ve gotten confused about which is which.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Software Engineers&lt;/strong&gt; are supposed to design systems. They think about scalability, reliability, security, maintainability. They establish patterns and guardrails. They consider edge cases: What happens when 10,000 users hit this endpoint simultaneously? How do we handle partial failures? What’s our data consistency model? How do we make this debuggable when something goes wrong at 3am? They create the “blueprints” that make the difference between software that works on localhost and software that works in production.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Programmers&lt;/strong&gt; write code. They understand syntax, algorithms, design patterns. They know how to implement features, how to make things work. Given good specifications and architectural guidelines, they write solid, maintainable code that does exactly what it needs to do.&lt;/p&gt;
&lt;p&gt;But here’s where we got confused: engineers also write code. They have to—you can’t design a system well if you don’t understand implementation realities. An architect who doesn’t understand how concrete and steel actually behave can’t design a building that stands. Similarly, a software engineer needs to understand how code actually executes.&lt;/p&gt;
&lt;p&gt;Because of this overlap, we started treating them as the same job. Most companies just have “software engineers” who do everything—system design &lt;em&gt;and&lt;/em&gt; implementation. We stopped distinguishing between the person who decides “we need a caching layer with these invalidation rules” and the person who implements Redis with that specification.&lt;/p&gt;
&lt;p&gt;For years, this worked okay. The engineer-programmer would design the system in their head, then implement it. Both roles were happening, just inside one person.&lt;/p&gt;
&lt;h2 id=&quot;enter-the-ai-programmer&quot;&gt;Enter the AI Programmer&lt;/h2&gt;
&lt;p&gt;Now we have large language models that can write code. And here’s what I’ve realized: &lt;strong&gt;LLMs are incredible programmers, but terrible engineers.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;An LLM is like that construction worker who can build you a skyscraper without blueprints. Ask it to “build me a todo list app,” and it will. The code will be clean. It’ll have proper separation of concerns. The variable names will make sense. It’ll work beautifully on your laptop.&lt;/p&gt;
&lt;p&gt;But it’s missing all the engineering:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No load considerations:&lt;/strong&gt; It’ll use an in-memory array for the todo list, which works great for testing but fails when you have 10,000 users&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No failure handling:&lt;/strong&gt; What happens when the database connection drops mid-request? The LLM’s code probably doesn’t handle that gracefully&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No security thinking:&lt;/strong&gt; Authentication might be present, but is it actually secure against common attacks? Are there SQL injection vulnerabilities?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No performance engineering:&lt;/strong&gt; That nested loop through every user’s todos on every request? Didn’t consider that case&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No operational thinking:&lt;/strong&gt; Good luck debugging when something goes wrong—there’s minimal logging and no monitoring&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The code &lt;em&gt;looks&lt;/em&gt; professional. It follows good practices. It even works, in the ideal case. But the moment reality hits—concurrent users, network failures, edge cases, malicious input—it falls apart.&lt;/p&gt;
&lt;p&gt;Just like that skyscraper built without architects.&lt;/p&gt;
&lt;h2 id=&quot;what-this-actually-means&quot;&gt;What This Actually Means&lt;/h2&gt;
&lt;p&gt;I used to think the distinction between “engineer” and “programmer” was just semantic nitpicking. Now I realize it’s crucial, and we’re about to learn this lesson the hard way.&lt;/p&gt;
&lt;p&gt;When you tell an LLM “build me an authentication system,” you’re giving it a task without constraints. It’s like telling a construction worker “build me a building” without specifying:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;How many people it needs to support&lt;/li&gt;
&lt;li&gt;What kind of disasters it needs to withstand&lt;/li&gt;
&lt;li&gt;What the fire safety requirements are&lt;/li&gt;
&lt;li&gt;How the utilities should be routed&lt;/li&gt;
&lt;li&gt;What the budget constraints are&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The LLM will build you &lt;em&gt;something&lt;/em&gt;. That something might even impress you. But it won’t be robust, because nobody did the engineering.&lt;/p&gt;
&lt;p&gt;Here’s a real example from my startup, Empath Legal. We’re building jury selection tools for attorneys. I could ask an LLM: “Create an API endpoint that analyzes jury questionnaires and returns insights.”&lt;/p&gt;
&lt;p&gt;The LLM would build me something functional:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;It would parse the questionnaires&lt;/li&gt;
&lt;li&gt;It would call an LLM to analyze the text&lt;/li&gt;
&lt;li&gt;It would return formatted results&lt;/li&gt;
&lt;li&gt;The code would be clean and well-structured&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;But here’s what it wouldn’t consider without engineering input:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Business requirements I need to specify:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;These questionnaires can be 50+ pages and attorneys need results in under 30 seconds&lt;/li&gt;
&lt;li&gt;Our customers are lawyers who need citations for everything—we can’t just hallucinate insights&lt;/li&gt;
&lt;li&gt;The analysis must be deterministic enough that an attorney can rely on it in court&lt;/li&gt;
&lt;li&gt;We have a credit-based pricing model, so we need to track and limit usage&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Technical guardrails I need to establish:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;We need to chunk large questionnaires intelligently (by section, not arbitrary token counts)&lt;/li&gt;
&lt;li&gt;We must implement streaming so attorneys see progressive results, not a 30-second blank screen&lt;/li&gt;
&lt;li&gt;We need a caching layer so re-analyzing the same questionnaire doesn’t burn credits&lt;/li&gt;
&lt;li&gt;We need robust error handling because LLM API calls fail sometimes&lt;/li&gt;
&lt;li&gt;We need audit trails for compliance reasons&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;System design decisions I need to make:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Should this be synchronous or async? (Async, because of the 30-second requirement)&lt;/li&gt;
&lt;li&gt;What happens if the LLM call fails midway? (We need idempotent retries)&lt;/li&gt;
&lt;li&gt;How do we handle rate limits from the LLM provider? (We need a queue with backpressure)&lt;/li&gt;
&lt;li&gt;What’s our data consistency model? (We need to decide what happens if analysis partially completes)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The LLM could write beautiful code for the happy path. But without me doing the engineering work—understanding the requirements, establishing the constraints, designing the system architecture—that code would be worthless in production.&lt;/p&gt;
&lt;h2 id=&quot;the-new-division-of-labor&quot;&gt;The New Division of Labor&lt;/h2&gt;
&lt;p&gt;This isn’t about LLMs being bad. They’re phenomenal at what they do. This is about understanding what they do.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;LLMs are expert programmers:&lt;/strong&gt; Give them a well-specified task with clear constraints, and they’ll implement it beautifully. They know patterns, they write clean code, they can even debug and optimize within a defined scope.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Humans need to be the engineers:&lt;/strong&gt; We need to understand the business requirements, design the system architecture, establish the guardrails and principles, consider the edge cases and failure modes. We need to ask: What are we really trying to achieve? What could go wrong? How does this need to scale?&lt;/p&gt;
&lt;p&gt;This is actually liberating, if you think about it. We can offload the tedious parts—writing boilerplate, implementing standard patterns, translating specifications into code—and focus on the interesting parts: understanding user needs, designing robust systems, making architectural tradeoffs.&lt;/p&gt;
&lt;p&gt;But only if we actually &lt;em&gt;do&lt;/em&gt; the engineering work. If we just prompt “build me X” without providing context, constraints, and architectural guidance, we get that beautiful skyscraper that nobody can live in.&lt;/p&gt;
&lt;h2 id=&quot;how-to-actually-work-with-ai-coding-tools&quot;&gt;How to Actually Work with AI Coding Tools&lt;/h2&gt;
&lt;p&gt;Here’s what I’ve learned about working effectively with LLMs:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Bad prompt:&lt;/strong&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;“Build me a payment system for my SaaS app”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;Good prompt:&lt;/strong&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;“Build me a payment system with these requirements:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Support credit-based pricing (users buy credits, features consume credits)&lt;/li&gt;
&lt;li&gt;Must handle concurrent credit deductions safely (two API calls shouldn’t both succeed if there’s only enough credits for one)&lt;/li&gt;
&lt;li&gt;Need audit trail for billing disputes&lt;/li&gt;
&lt;li&gt;Should fail gracefully if payment processor is down (degrade to cached credit counts, log for reconciliation)&lt;/li&gt;
&lt;li&gt;Performance requirement: credit check must add &amp;#x3C; 50ms to request latency&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Use PostgreSQL with row-level locking for credit deductions. Implement idempotent credit operations so retries don’t double-charge. Include comprehensive logging for debugging billing issues.”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The second prompt does the engineering work. It specifies:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The business model (credit-based pricing)&lt;/li&gt;
&lt;li&gt;The critical constraint (safe concurrent access)&lt;/li&gt;
&lt;li&gt;The compliance requirement (audit trail)&lt;/li&gt;
&lt;li&gt;The reliability requirement (graceful degradation)&lt;/li&gt;
&lt;li&gt;The performance requirement (latency budget)&lt;/li&gt;
&lt;li&gt;The technical approach (PostgreSQL with row locks)&lt;/li&gt;
&lt;li&gt;The operational requirement (debuggable)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Now the LLM can write code that actually works in production, because I’ve given it the architectural blueprint.&lt;/p&gt;
&lt;h2 id=&quot;the-uncomfortable-truth&quot;&gt;The Uncomfortable Truth&lt;/h2&gt;
&lt;p&gt;Most of us who’ve been writing code for years have been doing both roles simultaneously for so long that we don’t even notice when we’re switching hats. When we sit down to “code,” we’re actually doing engineering work (thinking about requirements and constraints) &lt;em&gt;and then&lt;/em&gt; programming work (implementing the solution).&lt;/p&gt;
&lt;p&gt;With LLMs, we can’t do that anymore. The programming part gets delegated to the AI. But if we don’t explicitly do the engineering part ourselves—if we just treat the LLM as a magic “build me X” box—we get code that works in the demo but fails in reality.&lt;/p&gt;
&lt;p&gt;And here’s the uncomfortable part: &lt;strong&gt;many people who call themselves engineers are actually just programmers&lt;/strong&gt;. They’re really good at writing code, but they’ve never had to think deeply about system design because they were implementing someone else’s architecture, or working on small enough systems that the complexity didn’t matter.&lt;/p&gt;
&lt;p&gt;With LLMs, this distinction now matters. The code-writing skills that used to be valuable are being commoditized. What’s becoming valuable is the engineering thinking: understanding requirements, designing robust systems, making good architectural tradeoffs, considering failure modes.&lt;/p&gt;
&lt;h2 id=&quot;what-this-means-for-you&quot;&gt;What This Means For You&lt;/h2&gt;
&lt;p&gt;If you’re learning to code right now, don’t just learn syntax and frameworks. Learn to think like an engineer:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;When you build something, ask: “What happens when 1000 people use this simultaneously?”&lt;/li&gt;
&lt;li&gt;Before writing code, ask: “What are all the ways this could fail, and how should it handle each one?”&lt;/li&gt;
&lt;li&gt;Study real-world system design, not just toy examples&lt;/li&gt;
&lt;li&gt;Learn to write specifications that would let someone else implement your vision correctly&lt;/li&gt;
&lt;li&gt;Understand the &lt;em&gt;why&lt;/em&gt; behind patterns, not just the &lt;em&gt;how&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you’re already working as a developer, pay attention to which role you’re actually performing. Are you mostly implementing features based on specs? You’re in the programmer role, and LLMs are coming for that job. Start developing engineering skills: system design, requirements analysis, architectural thinking.&lt;/p&gt;
&lt;p&gt;If you’re hiring, stop asking “can you code fizzbuzz?” and start asking “design a URL shortener that handles 1000 requests/second and explain your failure modes.” The code-writing part is increasingly automated. The thinking part isn’t.&lt;/p&gt;
&lt;h2 id=&quot;the-punchline&quot;&gt;The Punchline&lt;/h2&gt;
&lt;p&gt;Here’s the irony: I wrote this post with Claude’s help. Not because I couldn’t write it myself, but because Claude is a better &lt;em&gt;programmer&lt;/em&gt; than I am. I gave it my core idea, my architectural guidelines (the blog writing guide), my requirements (make it engaging and human), and my constraints (use the construction analogy, include real examples).&lt;/p&gt;
&lt;p&gt;Claude wrote beautiful prose. But I did the engineering: I decided what to communicate, how to structure the argument, what examples would land, what the reader needs to understand.&lt;/p&gt;
&lt;p&gt;The future of software development isn’t “no human involved.” It’s humans doing what humans are uniquely good at—understanding messy real-world requirements, making tradeoffs with incomplete information, designing robust systems for complex environments—while AI does what it’s uniquely good at: writing clean, correct code that implements those specifications.&lt;/p&gt;
&lt;p&gt;But only if we remember to do the engineering work. Otherwise, we’re just building skyscrapers without architects.&lt;/p&gt;
&lt;p&gt;And we all know how that story ends.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;What’s your experience with AI coding tools? Are you finding yourself doing more engineering thinking, or just prompting and hoping? I’d love to hear your stories—the spectacular failures are often more educational than the successes.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;(Written by Human, improved using AI where applicable.)&lt;/em&gt;&lt;/p&gt;</description><pubDate>Thu, 20 Nov 2025 00:00:00 GMT</pubDate></item></channel></rss>