fix(wac): return the checkpointed value from step(), not the live object (#10367)

* fix(wac): return the checkpointed value from step(), not the live object

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(wac): regenerate system prompts and narrow the round-trip claim

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style(wac): condense the round-trip comments and fix the fallback note

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(sdk): type step() as the JSON round trip of its body's result

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(sdk): apply the JSON round trip to task() and the standalone paths

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sdk): encode bigint, keep unknown as unknown, align dropped-key results

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(wac): null out results whose key JSON.stringify would drop

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(wac): normalize only the top-level result, keeping nested keys as they were

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(wac): normalize a child task's result so a deployed job cannot fail to parse

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(sdk): pin non-finite number behavior in Jsonified and its tests

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sdk): admit undefined for keys whose value JSON.stringify may omit

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sdk): make a key JSON.stringify may omit optional, not just nullable

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sdk): treat a class-valued property as dropped, like any other function

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-07-28 09:46:23 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent b0c7e09173
commit 044ce39e5f
17 changed files with 695 additions and 65 deletions
+52 -10
View File
@@ -2702,6 +2702,13 @@ class TaskError(Exception):
self.result = result
def _json_round_trip(value):
"""Put a value through the checkpoint's encoding without checkpointing it, so
the paths that never persist anything still hand back the shape the ones that
do would. ``default=str`` matches the worker wrapper's encoder."""
return json.loads(json.dumps(value, default=str))
def _step_error_marker(key: str, exc: BaseException) -> dict:
"""Serialize a failed ``step()`` body into the ``__wmill_error`` marker that
task failures also use, so it can be stored in ``completed_steps``."""
@@ -2936,7 +2943,22 @@ class WorkflowCtx:
_token = os.environ.get("WM_TOKEN")
if _fast_path_enabled and _job_id and _workspace and _base and _token:
_fast_path_ok = False
_replay_result = None
try:
# ``default=str`` is the encoder the worker wrapper uses on the
# suspend path, so both arms checkpoint the same value — and a
# datetime or set takes the fast path instead of silently
# degrading to a suspend round.
_payload = _json_mod.dumps(
{
"key": key,
"result": result,
"started_at": started_at,
"duration_ms": duration_ms,
},
default=str,
)
_replay_result = _json_mod.loads(_payload)["result"]
if self._inline_lock is None:
self._inline_lock = _asyncio.Lock()
# Lock wraps only the POST, not fn() above — concurrent
@@ -2953,12 +2975,7 @@ class WorkflowCtx:
)
_resp = await self._inline_http_client.post(
f"{_base}/api/w/{_workspace}/jobs/wac/inline_checkpoint/{_job_id}",
json={
"key": key,
"result": result,
"started_at": started_at,
"duration_ms": duration_ms,
},
content=_payload,
)
_resp.raise_for_status()
_fast_path_ok = True
@@ -2976,7 +2993,11 @@ class WorkflowCtx:
# run and miss on the next. ``__cause__`` is for tracebacks only.
if step_error is not None:
raise _step_error_from_marker(result, name) from step_error
return result
# Return the round trip of what was checkpointed, never the
# in-memory value: handing back the live object would let the
# round that ran the body branch on a type — tuple, datetime —
# that no replay of it ever sees.
return _replay_result
raise _StepSuspend({
"mode": "inline_checkpoint",
@@ -3009,6 +3030,10 @@ def task(
- **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API.
- **Standalone**: executes the function body directly.
A task runs as its own job, so its result is always encoded as JSON and
decoded back before the caller sees it: a ``datetime`` comes back as a
string, a tuple as a list.
Usage::
@task
@@ -3081,8 +3106,19 @@ def task(
print(f"Task {func.__name__} ({child_job_id}) completed")
return job_result
# Standalone — execute directly
return func(*args, **kwargs)
# Standalone — execute directly, but round-trip the result: a task's
# value crosses JSON in every other path, so a local run must agree.
# This wrapper is sync, so an ``async def`` task hands back a
# coroutine here — round-tripping that would serialize the coroutine
# object itself.
result = func(*args, **kwargs)
if _asyncio.iscoroutine(result):
async def _round_trip_awaited():
return _json_round_trip(await result)
return _round_trip_awaited()
return _json_round_trip(result)
wrapper._is_task = True
wrapper._task_path = task_path
@@ -3186,6 +3222,10 @@ async def step(name: str, fn):
On replay the cached value is returned without re-executing ``fn``.
Use for lightweight deterministic operations (timestamps, random IDs,
config reads) that should not incur the overhead of a child job.
``fn``'s result is encoded as JSON and decoded back before it is returned,
so the round that runs the body sees the same types every replay sees:
a ``datetime`` comes back as a string, a tuple as a list.
"""
ctx: WorkflowCtx | None = _workflow_ctx.get(None)
if ctx is not None:
@@ -3193,7 +3233,9 @@ async def step(name: str, fn):
result = fn()
if _asyncio.iscoroutine(result):
result = await result
return result
# Outside a workflow nothing is checkpointed, but round-trip anyway: running
# the script locally must not hand back a shape a deployed run never sees.
return _json_round_trip(result)
async def sleep(seconds: int):