5 min readResonate HQJust published

Infinite AI chess workflow in TypeScript on GCP Cloud Functions

How an AI-vs-AI chess match becomes a generator on a serverless function — durable, bounded in replay, recursive across games via ctx.detached.

Resonate brand card on a dark background with a teal spectrum wave at the bottom and the post headline in white Sansation.

An AI-vs-AI chess match needs to play one move at a time, pause between moves, persist game state for a live viewer, and continue across process restarts and serverless function terminations — without re-playing moves that already happened. On Resonate, the match is a generator function where each move is a ctx.run checkpoint, the inter-move pause is a ctx.sleep durable timer, and the next game is launched via ctx.detached so each game runs in its own fresh root invocation. The example deploys the generator as a Google Cloud Function via the @resonatehq/gcp adapter; when the workflow hits a pending promise the function terminates, and the Resonate server reinvokes it when the promise resolves.

The shape of the solution

export function* chessGame(ctx: Context): Generator<any, void, any> {
  const chess = new Chess();
 
  while (!chess.isGameOver()) {
    const pFn = chess.turn() === "w" ? gePlayer : aiPlayer;
    const san = yield* ctx.run(pFn, chess.fen());
    chess.move(san);
 
    const state: GameState = { gameOver: false, fen: chess.fen(), san };
    yield* ctx.run(publish, state);
    yield* ctx.sleep(10 * 1000);
  }
 
  const final: GameState = { gameOver: true, result: gameResult(chess) };
  yield* ctx.run(publish, final);
 
  yield* ctx.detached(chessGame);
}
// from resonate-chess-gcp/chess-match/src/chess.ts:9-26

The workflow is registered and exposed as a Cloud Function HTTP handler by the GCP adapter:

import { Resonate } from "@resonatehq/gcp";
import { Firestore } from "@google-cloud/firestore";
import { chessGame } from "./chess";
 
const resonate = new Resonate();
const firestore = new Firestore();
 
resonate.setDependency("firestore", firestore);
resonate.register("chessGame", chessGame);
 
export const handler = resonate.handlerHttp();
// from resonate-chess-gcp/chess-match/src/index.ts:1-12

The durable primitives in play

  • ctx.run(pFn, chess.fen()) — durable child invocation of the per-turn player function (gePlayer for white, aiPlayer for black). The returned SAN string is checkpointed, so on replay the SDK feeds the cached move back into the generator instead of asking the player again. chess-match/src/chess.ts:14.
  • ctx.run(publish, state) — checkpointed Firestore write to chess-games/current. chess-match/src/chess.ts:18 and again at :23 for the final state.
  • ctx.sleep(10 * 1000) — durable 10-second pause between moves. When the generator hits the sleep, the Cloud Function invocation ends; the Resonate server starts a fresh invocation when the timer completes. chess-match/src/chess.ts:19.
  • ctx.detached(chessGame) — tail call that re-roots the next game as a fresh top-level invocation rather than a child of the current one. Caps the per-invocation replay scope at one game forever. chess-match/src/chess.ts:25. The SDK documents this as the forever-loop pattern: "Each invocation plays exactly one game, then spawns the next as a fresh root and returns. Replay scope is bounded to one game forever." (resonate-sdk-ts/src/context.ts:520-528).
  • ctx.getDependency("firestore") — pulls the Firestore client registered at module init out of the context. chess-match/src/chess.ts:72.
  • resonate.register("chessGame", chessGame) — registers the generator under a stable name so the server can address it across invocations. chess-match/src/index.ts:10.
  • resonate.handlerHttp() — the @resonatehq/gcp HTTP adapter; returns the Google Cloud Functions HTTP handler. Validates the inbound ExecuteMsg, mints OIDC credentials for outbound calls to the Resonate server, and replies with completed or suspended. chess-match/src/index.ts:12; adapter behavior at resonate-faas-gcp-ts/src/index.ts:152-232.

What the SDK handles vs. what you write

You write a single TypeScript generator with a while (!chess.isGameOver()) loop, an in-memory chess.js board, two player functions, and a Firestore-write side effect. There is no continuation token, no event-loop process, no per-move polling, no retry wrapper around the Anthropic call, no game-state serialization between invocations, and no manual "is this a replay or a fresh run?" branch.

The SDK and the GCP adapter handle everything around the yields. Each yield* ctx.run(...) and yield* ctx.sleep(...) is a durable promise: argument list and result are persisted before the function returns. On the next invocation the SDK replays the generator from the top, advancing each completed promise immediately from the persisted history until it reaches the first pending one (README.md:36). The Cloud Function invocation terminates as soon as the workflow suspends on a pending promise — there is no long-running process, no idle compute, no timer thread. The Resonate server holds the timer for ctx.sleep, and when it fires the server reinvokes the Cloud Function URL the adapter computed from the inbound request headers (resonate-faas-gcp-ts/src/index.ts:190-216). The chess.js board state is rebuilt deterministically on each replay because every move that produced it came back from a durable ctx.run result.

Failure modes covered

  • Cloud Function invocation ends between moves. Each ctx.run and ctx.sleep checkpoints to the durable promise store. On the next invocation, the SDK replays the generator and advances completed promises immediately rather than re-executing them, then continues at the first pending one. chess-match/src/chess.ts:14,18,19,23; replay semantics in README.md:36.
  • Workflow runtime exceeds any single Cloud Function timeout. The 10-second ctx.sleep between moves ends each invocation; the server starts a fresh invocation when the timer completes. chess-match/src/chess.ts:19; adapter contract in resonate-faas-gcp-ts/README.md:11-15.
  • Claude returns an illegal move. aiPlayer parses the response against the legal-move list and throws illegal move from Claude: <text> if neither SAN nor UCI parsing yields a legal move. chess-match/src/chess.ts:60-65. Because the throw happens inside ctx.run(aiPlayer, fen), the SDK retries the player step. Earlier ctx.run results in the same game are not re-executed because they're already in the durable promise store.
  • Replay history grows unbounded across many games. The ctx.detached(chessGame) tail call at the end of a game re-roots the next game as a fresh invocation, so the per-invocation replay scope is bounded to one game no matter how long the worker has been running. chess-match/src/chess.ts:25. The SDK docstring is explicit about why this matters: without the split, "once replay duration exceeds the acquired-task lease, the server reassigns the task mid-execution (error code 1199) and cadence collapses" (resonate-sdk-ts/src/context.ts:536-540).
  • Process-local resources (Firestore client) need to be available after a restart. The client is registered once at module init via resonate.setDependency("firestore", firestore) (chess-match/src/index.ts:8) and pulled inside the side-effect function via ctx.getDependency("firestore") (chess-match/src/chess.ts:72). Fresh invocations get a fresh client without re-running prior moves.

When to reach for this pattern

  • If you're running an unbounded workflow on serverless infrastructure with per-invocation timeouts and need the workflow itself to outlive any single invocation.
  • If a workflow loops "forever" over discrete units of work (one game, one batch, one cycle) and you want per-invocation replay cost bounded to a single unit instead of growing with total uptime — ctx.detached of the workflow function from its own tail is the mechanism.
  • If a long-running workflow has natural pause points where doing nothing is fine for seconds or minutes — ctx.sleep ends the serverless invocation entirely rather than billing for idle compute.
  • If a workflow includes a step that can fail intermittently (LLM call returning bad output, external API hiccup) and you want the SDK to retry just that step without re-running earlier steps.
  • If you're considering polling state out of a database every N seconds to figure out "where the workflow is" — replace that with a generator whose position is the durable history itself.

Sources