Repository navigation
fix(workflow-recipes): wait for the snippet runner before dispatching snippet steps - #1453
Conversation
… snippet steps The coordinator waited for mcp-host before agentic steps but sent the first snippet step without checking the snippet runner, so a runner that was still starting consumed a step attempt. Poll the runner's existing unauthenticated GET /health (same attempts and delay as the mcp-host wait) before running any step, and report the run as recovering when the runner never becomes ready, mirroring the mcp-host path. Document that maxRetries is the total number of attempts, matching how the runtime has always treated it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C3LgMq7sjCivTdAASEMDAk
…cp-host pods Add four reviewed, non-secret tuning variables to MCP_HOST_ENV_PASSTHROUGH so upcoming mcp-host changes can be configured from the WRC Deployment: CLERUM_WORKFLOW_TOOL_SPILLOVER_ENABLED, CLERUM_WORKFLOW_TOOL_SPILLOVER_THRESHOLD, CLERUM_WORKFLOW_TOOL_SPILLOVER_BUDGET_BYTES and CLERUM_WORKFLOW_STEP_ENGINE. mcp-host ignores them today, so this is a no-op until those changes land. The spillover kill switch only reaches recipe pods through this allowlist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C3LgMq7sjCivTdAASEMDAk
…ing step needs it The runner readiness wait ran before the persisted status was read, so a resumed coordinator whose snippet steps were already completed still waited up to the full window for a runner it would never call, then reported the run as recovering. The wait now runs after the all-completed exit and only when a step that has not completed yet uses the snippet runner. It still runs before the run is reported as running. The null-runner configuration error stays spec-based. Adds tests for the resumed cases, the wait ordering, and the default 120 probes 2 s apart. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er reading its status healthCheck read only response.ok and left the body stream open, which keeps the connection checked out when /health stalls or sends a large body. Aborting the request controller in the finally block releases the body and its connection; it cannot change the returned status. Adds tests for the 5 s probe timeout and for the released connection against a real HTTP server whose body never ends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Retries wait min(backoffSeconds x 2^(n-1), 300) seconds, as computeBackoff implements for snippet and agentic steps; the previous text described a fixed delay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Took a deep pass through this one. The core change is solid: the readiness wait sits exactly where the body says it does, the gating on pending steps matches what the dispatcher and the reconciler use, and every number in the description (838 s per wait, 3 recoveries, the backoff formula) checks out against the code. What's left is test quality around the 🟡 Approve with nits
This is my read from the review, not a formal GH approval. Must resolve before approvalNothing here. Optional (non-blocking)You can merge without any of these. They're follow-ups or your call.
What checked out clean
|
…unner-readiness Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * origin/dev: (43 commits) fix(build): track packages/llm-providers for its image consumers chore(release): prepare v0.12.1 chore(release): make the release registry test stub executable fix(release): stop before :stable when the release tag moved during the run test(mcp-host): pin BR-R3-L8 the Error name the redactor keeps test(mcp-host): make BR-R3-L7 the alias witness fail on an empty tool list test(mcp-host): derive BR-R3-M1 the countTokens 429 from the real SDK error docs(mcp-host): mark BR-R3-L5d wireToolAlias and the hash option as test seams test(mcp-host): witness BR-R3-L5c the projection in the pass-through cases test(mcp-host): pin BR-R3-L5a the envelope ClaudeProvider hands its counter docs(mcp-host): correct BR-R3-L4 tool_choice modes and drop external labels fix(mcp-host): log BR-R3-L3 countTokens failure as err, not its message fix(release): force-fetch tags so an annotated release tag can't block :stable test(release): an error from the :stable ancestry check fails the run docs(release): describe the candidate chain, all-or-nothing copies and forward-only :stable test(release): pin the re-run, :stable, ref and copy-failure guards fix(release): converge re-runs on the release's own tag and keep :stable forward-only test(mcp-host): pin lone-surrogate tool names on the makeProvider path docs(release): describe the release image candidate chain in the workflow header test(release): pin sha-before-previous-release order and the exact-revision guard ...
… running A coordinator replaced mid-step leaves that step in status.steps with phase running: the executor reports running before it calls the runner, and coordinator replacement patches only status.workflowExecution. The resume tests modelled a pending step only as absent from the persisted status, so a pending predicate keyed on "has a persisted entry" instead of "is completed" passed them all. The new test persists the step as running, as the WRC stores it, and requires the runner probe before the step is dispatched again and before the run is reported running. It is the only test that fails when the pending predicate ignores the phase or when completed steps are taken from every persisted entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ver, drop the abort pin Adds a test that runs SnippetRunnerClient.healthCheck against createSnippetRunnerServer: healthy on GET /health, unhealthy under a wrong base URL, with both requests recorded by the server. A route or method rename on either side now fails the suite. Removes the mock test that asserted the request signal was aborted after the status was read. It pinned the mechanism; the real-socket test that measures the released connection still fails when the abort is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
computeBackoff clamps every retry wait to 300 s, so a backoffSeconds above 300 behaves as 300 for snippet and agentic steps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@zach88 thanks for the review. Status of the six optional items at head
Also new since your review: a merge of |
…er of run or pod start The pre-coordinator gate failed a run when its mcp-host pod was not Ready 240 s after workflowExecution.startedAt. startedAt is the run start and is never reset, so a replacement mcp-host created by crash recovery was judged by the age of the run: a coordinator that exited as `recovering` after a readiness wait had its run failed with "readiness deadline exceeded" in the same reconcile pass that recreated the pod. A pod created after slow output pods on the first deploy was judged the same way. The wait now starts at the later of the run start and the creation of the pod being waited for, read from the pod the gate already fetches. A pod that is being deleted is the predecessor: its window starts at its deletionTimestamp, so a predecessor stuck terminating still ends the wait. startedAt is unchanged for its other readers (workflowExecutionId, the UI). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…unner-readiness Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * origin/dev: chore(workflow-recipes): remove the dead lint script # Conflicts: # workflow-recipes/package-lock.json # workflow-recipes/package.json
…od is absent
getPodReadiness mapped a GET 404 to { ready: false }, the same shape as a pod
with no timestamps, and the pre-coordinator gate then measured the wait from
the run start. A predecessor that finished terminating between the pass-start
presence read and the gate read failed a recovering run older than 240 s with
"phase=missing; readiness deadline exceeded".
PodReadiness is now one of three states: absent, present, terminating. The
gate switches over them. An absent pod gives no verdict and the next pass
creates it; a present pod is measured from the later of the run start and its
creation; a terminating pod from the later of the run start and its
deletionTimestamp.
Tests: the absent row, a multi-pass graceful-deletion run that ends in "Pod
failed after 3 recovery attempts" after four coordinator waits, the clamp to
the run start for a predecessor deleted before the run, the 409 on the
predecessor's create, and the 404 mapping itself.
orphans checked: podWaitStartedAt (removed with its only use); none left.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… live recipe patchStatus swallowed a failed live read and built the workflowExecution patch from the recipe the pass started with. When the workflow reconciler had already patched attempt + 1 in the same pass, that wrote the older attempt back, so a recovery was not counted against MAX_ATTEMPTS. The same path could overwrite a live phase, startedAt and completedAt. The read error now propagates. The watcher drops a recipe that is gone and retries after any other error, and writes no status for that pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ocks getPodReadiness always returns kind 'absent', 'present' or 'terminating', and the mcp-host readiness gate switches on it. Twenty-five mocks in four test files still returned the older shape without kind. They passed only because those tests never reached the switch; a test that did would fail with "Unknown mcp-host pod readiness" instead of testing the gate. Every mock now returns kind 'present', the state of an existing pod. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…unner-readiness * origin/dev: test(workflow-runtime-core): cover byte-heavy artifact results through the production callers fix(workflow-runtime-core): bound status tool-call previews by serialized bytes test(workflow-recipes): narrow parseJsonBody's result before reading bytesRead test(workflow-recipes): assert the drain cap on what the server read fix(workflow-runtime-core): reduce oversized artifact results with WRC's own validation fix(workflow-recipes): address review findings on status report bounding fix(workflow-runtime-core): name the real cause when a status report fails fix(workflow-recipes): answer oversized WRC request bodies with a readable 413 fix(workflow-runtime-core): bound toolsCalled before reporting step status # Conflicts: # workflow-recipes/package-lock.json # workflow-recipes/package.json
inspectPodReadiness took ready from the phase and the Ready condition only. A pod with a deletionTimestamp keeps Ready=True until its containers stop, so a terminating predecessor mcp-host passed the pre-coordinator readiness gate and the coordinator started against a pod that will not serve again. The eager SDK provisioner could likewise broker /configure into a pod deleted between its drift read and its readiness read. A terminating pod is now never ready, and TerminatingPodReadiness declares ready: false. The gate waits for the replacement and still ends the wait 240 s after the predecessor's deletionTimestamp. The output anchor and prepare waits do not read ready and are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@zach88 @jozer-rami summary of what changed since my last update (head WRC pre-coordinator mcp-host readiness gate (
Tests only ( Merges of Validation at
Follow-ups, not in this PR: #1484 (REST status writers that can overwrite The PR body is updated with the same detail. |
…llover Brings in A1 #1453 (spillover flag passthrough to recipe mcp-host pods). Conflicts: mcp-host/package.json and package-lock.json version lines only, resolved to the higher side. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tput-bound Brings in #1135 (final head 9b7258f) and #1453. Resolved conflicts: - workflow-recipes version: dev 0.1.505 + 1 = 0.1.506 (package.json and both lock lines). - restEndpoints.functional.test.ts: kept both new describe blocks (A3's output-bound tests and #1135's byte-heavy tool-call tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1453 makes the workflow runtime wait for the snippet runner before it reports the run as running. In sdkRuntime.ts that wait sat where this branch replaced the plain 'running' report with reportRunningBoundary, so both are kept in that order: the runner readiness wait first (only when a pending step uses the runner), then the running boundary. The deps interface and the error classes keep both sides. The branch's wire test for the running boundary built a snippet-runner fake without healthCheck, which the merged runtime now calls; the fake gets the same healthCheck that #1453 gave its own fakes. workflow-recipes is set to 0.1.506, above dev's 0.1.505, because this branch changes its code. Its build and suite (159 files, 3494 tests) pass on the merge, and the release version validator accepts it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…althCheck The Real PostgreSQL revocation test in workflowTriggerSelectedPath.realPostgres.integration.test.ts drives workflow-recipes' runWorkflowRuntime with a snippet-runner fake that had only execute. Since dev's #1453, merged into this branch in 19f88d7, the runtime calls snippetRunner.healthCheck() before a pending snippet step, so the test failed with "client.healthCheck is not a function". The merge gave the workflow-recipes wire fixture the same method; this fixture was missed. The real SnippetRunnerClient has healthCheck (workflow-recipes/src/workflow/snippetRunnerClient.ts:57). With this line the file passes 14/14 on a local PostgreSQL 16 (evidence impl-r2/final/probe-healthcheck.log). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
step without checking the snippet runner. A runner that was still starting turned into a
failed attempt, which recipe authors worked around by raising
maxRetries. The coordinatornow polls the runner's existing
GET /healthonce per run, after reading the persisted runstatus and before reporting the run as
running, whenever a step that has not completed yetuses the runner. If the runner never becomes ready the run is reported as
recovering, thesame outcome as the mcp-host path.
not identical to it: each runner probe has a 5 s timeout, while the mcp-host probe has none;
the runner probe sends no credentials, while the mcp-host probe is authenticated; and the two
waits run one after the other, mcp-host first, so a workflow with both kinds of step can wait
for both. Each runner probe aborts its request once the status is read, so an unread or
stalled
/healthbody does not keep the connection open.step is already completed, or only agentic steps are left). The mcp-host wait is unchanged
and still runs before the status is read, so an unready mcp-host still blocks a fully
completed resumed run.
its own retries. Now one coordinator waits up to 838 s (120 probes × 5 s timeout + 119 pauses
× 2 s) when every probe hangs, or about 238 s when connections are refused at once, then
exits as
recovering. Crash recovery replaces the coordinator three times, so the runreaches
failed("Pod failed after 3 recovery attempts") after four waits: up to about56 minutes (4 × 838 s), about 16 minutes with fast refusals, plus pod start time. This is
intended; no total bound is added in this PR.
hasMcpHostReadinessWaitExpired, 240 s) nowjudges the pod it actually reads against that pod's own window. Before, the window started
at the run start, so a replacement pod created by crash recovery, or a pod created after the
output anchor/prepare pods, could fail the run before it had its 240 s. Now:
later of the run start and its
deletionTimestamp, so a predecessor that never goes awaystill ends the wait;
own window.
getPodReadinessreturns this as a discriminated union (absent/present/terminating). A GET error other than 404 is handled as ondev: a transient error requeuesthe pass, any other fails the run.
ready. Kubernetes keeps a terminating pod'sReady condition until its containers stop, so before this change a terminating predecessor
mcp-host passed the gate and the coordinator started against a pod that will not serve again.
The same change stops the eager Plugin Workload SDK provisioner from sending
/configureto anmcp-host deleted between its image-drift read and its readiness read; it returns
deployingand the next pass handles the deletion. The output anchor and prepare waits do not read
readyand are unchanged.patchStatusno longer ignores a failed live read of the recipe. It built theworkflowExecutionpatch from the recipe the pass started with, so when crash recovery hadalready written
attempt + 1in the same pass, the olderattemptwas written back and therecovery did not count against
MAX_ATTEMPTS(3). The read error now fails the pass: thewatcher drops a recipe that is gone (404) and retries after any other error, writing no
status for that pass.
bounded by the stored
attemptand the recipe'smax_duration_seconds, not from the run start.A pod deleted by something other than WRC (preemption, a forced drain) is recreated without
counting an attempt and gets a new window. Read-only check of clerum-dev and clerum over 30 days
(13 and 2 coordinator runs): no recovery or readiness-deadline events. Tracked separately with
the remaining
attemptwriters in the REST status endpoints.honoured, so the run ends
recoveringinstead ofcancelled. This PR adds a second wait withthe same shape as the mcp-host wait. The gap is inherited and unreachable today. The workflow
signal path has no producer since the wrc-trigger retirement (chore: dev → public sync refresh (dev@db43d12) — post-#820 convergence #112, f6abe0c), and
waitForMcpHostReadyhas the same shape on base. Handling is deferred to a separate issue onthe signal machinery.
docs/crds/workflowrecipe.md:maxRetriesis the total number of attempts (the code hasalways treated it that way), and retry n waits
min(backoffSeconds × 2^(n-1), 300)seconds,for snippet and agentic steps alike. The
backoffSecondsrow now says that each wait iscapped at 300 s. The CRD description is left for the next CRD release.
MCP_HOST_ENV_PASSTHROUGHgains four reviewed, non-secret tuning variables that upcomingmcp-host changes read:
CLERUM_WORKFLOW_TOOL_SPILLOVER_ENABLED,CLERUM_WORKFLOW_TOOL_SPILLOVER_THRESHOLD,CLERUM_WORKFLOW_TOOL_SPILLOVER_BUDGET_BYTES,CLERUM_WORKFLOW_STEP_ENGINE. mcp-host ignores them today, so this is a no-op until thosechanges land. The three spillover variables must be in place before the mcp-host spillover
change merges: that change is on by default and this allowlist is the only way its operator
kill switch reaches recipe pods. Kept as a separate commit.
Operator note: these values are part of the mcp-host pod spec. Setting or changing any of the
four variables on the WRC Deployment changes the runtime contract hash of eager Plugin
Workload SDK mcp-host pods, so WRC rolls those pods on their next reconcile. Run-scoped
workflow mcp-host pods are not rolled; they receive the value the next time WRC creates them.
With the variables unset, the pod spec is identical to today's.
Testing
All results below are for head
8ce1cf4fd(includes a merge ofdevat34f14dd37).npm testpasses for changed services (workflow-recipes: 153 files passed, 4 skipped;3350 tests passed, 29 skipped)
npx tsc --noEmitcleanscripts/minikube/t0.sh: affected packageworkflow-recipes, test and build/typecheckpass
9a8988dbawhere expected (resumed runs,wait ordering, released
/healthconnection) and each one fails when the behaviour it coversis removed: wait removed, wait gated on all spec steps, wait moved after
running, defaultattempts 119/121, default delay 1000/3000 ms, probe timeout 4000/6000 ms, abort removed.
running(a coordinator replacedmid-step) waits for the runner before the step runs again.
healthCheck()runs against the real runner server (createSnippetRunnerServer):healthy on
GET /health, unhealthy under a wrong base URL.getPodReadiness, including amulti-pass graceful deletion that reaches "Pod failed after 3 recovery attempts" after four
windows. Each fails when its rule is removed: absent pod measured from the run start,
deadline anchored on the run start only, terminating branch removed or treated as present,
terminating pod reported ready (7 tests), gate disabled (23 tests).
patchStatus: a failed live read leaves the storedattemptat 2 and rejects the pass;swallowing the error again or ignoring the live read fails the tests.
make minikube-t2on a fresh profile (full-bootstrap):MINIKUBE_T2_PASS(T0, T11064/1064 on real PostgreSQL, NP-08, port-forwards and the
workflow-recipeshealth journeyPASS; Playwright not run).
patchStatuscommits (three rounds): every findingis fixed here or listed above as accepted or tracked separately.
Failures already present on
dev:npm run format:checkexits 1 on this head with 113 files, none of which this PRtouches.
Checklist
Generated by Claude Code