fix(orchestrations): bound loop / sub_orchestration nesting depth - #1197
Merged
Conversation
A graph whose `sub_orchestration` node names itself — directly, or through a
cycle of two graphs — started a child run that started a child run, and nothing
stopped it: `detectCycleExcludingLoopNodes` is intra-graph by construction and
exempts loop nodes deliberately, `Task.automationChainDepth` bounds the
workflow<->dispatch cycle (a nested run transitions no task), `max_call_depth`
bounds agent->agent recursion through tool calls (a `sub_orchestration` node is
not a tool call), and `maxChainGenerations` bounds one agent chain (each child
run meters its own). So the recursion was bounded only by whatever ran out
first — queue capacity, the project's concurrency limit, provider spend — and
the failure that eventually surfaced named nothing about the real cause.
Every run now carries a `run_depth`: 0 for a caller-started run, one more than
its parent's for a `loop` / `sub_orchestration` child. `startOrchestrationRun`
is the single choke point both node types already go through, so the bound is
checked there — before the child's row exists, leaving nothing queued behind a
refusal. The effective bound is the smaller of `MAX_ORCHESTRATION_RUN_DEPTH`
(default 10, matching `max_call_depth`, the closest analog) and the project's
new `max_run_depth`, read at spawn time so lowering the number stops a tree that
is already recursing. `min` rather than "the project's if it sets one": each
narrower scope exists so its owner can be stricter, and letting it raise the
ceiling would make the outer bound opt-out.
The refusal on its own would still have been a silent stop. A child run catches
its own failures — `executeRun` settles the run rather than letting the throw
escape — so the seam handed the parent a settled `failed` run and the node took
`output ?? {}` from it and carried on: the caller of a recursing tree was told
`succeeded`. A child settling in a non-success terminal status (`failed`,
`cancelled`, `expired`) now fails the node that started it, the same rule a
workflow `on_enter` dispatch already follows, and under the child's own error
code where it has one, so the cause reaches the run a caller reads instead of
stopping at the run that noticed it. A child that has merely parked
(`awaiting_input`, `sleeping`) has not settled and is unaffected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bh5kgHuqisvy4cLtMrNf4s
Deploy Outputs
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1185.
The gap
Nothing bounded how deep
loop/sub_orchestrationnesting went. A graph whosesub_orchestrationnode named itself — directly, or through a cycle of two graphs — started a child run that started a child run, until something incidental stopped it. None of the platform's existing bounds reaches that path, for the reasons the issue's table sets out.The bound
Every run now carries a
run_depth:0for a caller-started run, one more than its parent's for aloop/sub_orchestrationchild. Read off the parent's row by the engine driving it and passed throughNestedRunParent, rather than looked up fromparent_run_id— the row is already in hand there, and a child whose parent row had since been deleted would otherwise restart the count from zero.startOrchestrationRunis the single choke point both node types already go through, so the check lives there — before the child's row exists, so a refusal leaves nothing queued behind it, and any nested-run caller added later inherits the bound.The effective bound is the smaller of:
MAX_ORCHESTRATION_RUN_DEPTH10max_run_depthPATCH /api/v1/projects/{project_id}null(defer to the deployment's)Both read at spawn time, not pinned on the tree's root, so lowering the number stops a tree that is already recursing.
The silent stop it had to fix too
The refusal alone would not have surfaced. A child run catches its own failures —
executeRunsettles the run rather than letting the throw escape — so the seam handed the parent a settledfailedrun and the node tookoutput ?? {}from it and carried on. With the guard in place but nothing propagating, all four bound tests reported the root run assucceeded(verified in this session, red).So a child settling in a non-success terminal status (
failed,cancelled,expired) now fails the node that started it — the same rule a workflowon_enterdispatch already follows (ORCHESTRATION_DISPATCH_FAILED) — and under the child's own error code where it has one, so the cause reaches the run a caller reads instead of stopping at the run that noticed it.error.metanames the child run and node at each level, soparent_orchestration_run_idstill walks down to the failure. A child that has merely parked (awaiting_input,sleeping) has not settled and is unaffected.This is a behaviour change beyond the depth bound, and the reviewer's call: today a
sub_orchestrationchild that fails is silently swallowed and the parent succeeds on an empty artifact. Acceptance criteria 1, 2 and 4 cannot hold without it.Acceptance
sub_orchestrationgraph terminates with an error naming the depth bound —ORCHESTRATION_RUN_DEPTH_LIMIT, "would reach nesting depth 11, past the limit of 10", on the run the caller startedloopnodesrun_depthon every run, plus the failed run — which the existingorchestration_runs.failedlistener already auto-files as arun_failedexception carrying{ code, message }, so no new event was neededRed/green
Confirmed red before green, both halves separately:
succeededsucceeded— the silent stopAlso in this PR
ORCHESTRATION_RUN_DEPTH_LIMIT(409) andORCHESTRATION_NESTED_RUN_FAILED(422) in the registry, both withERROR_RESOLUTIONShintsrun_depthon the run schema;max_run_depthon the project schema, request body and docsorchestrations.md— Nesting depth, A child run's failure fails its parent, the two error rows, the env var;projects.md— the new fieldtests/smoke-tests.sh— the project bound set/cleared,run_depthon a child, and a self-referencing graph terminating on the boundResolved open questions
Q: Opt-in with an unbounded default, or a finite default? A: Finite (10) — resolved by long-term; checked: it is the house pattern for every comparable bound in this repo (max_call_depth 10, TASK_AUTOMATION_CHAIN_LIMIT 50, MAX_CONTINUATION_CHAIN_GENERATIONS 100), and an opt-in default enforces nothing. 10 matches max_call_depth, the closest analog (synchronous recursion started by a resource naming another). Verified a 6-level composition passes under it. Q: Where is it configured — per node, per orchestration, per project, or per deployment? A: Deployment env var + project column, min-of-the-two — resolved by long-term; checked: it is byte-for-byte the shape #1176 used for maxChainGenerations (resolveEffectiveLimit in generationChain.ts), so a reader who knows one knows this. A per-node field is purely additive later; a per-node bound alone would be opt-out, which the bound cannot be, since the graph that runs away is the one whose config is wrong. Q: Bound depth, or bound the population of descendants the way maxChainGenerations does? A: Depth — resolved by pareto; checked: the issue's acceptance criteria are depth-stated, and OrchestrationRun.parentRunId/parentNodeId already carry the identity, so a counter needs no walk and no new query. Documented honestly: this bounds recursion, not total work — a loop node fans out, so N children per level still permits N^depth runs, and width stays bounded by the node's `parallelism` and the project's `max_concurrent_runs`. Q: Does a nested child's failure have to propagate, given the issue scopes only the bound? A: Yes — resolved by long-term; checked empirically that without it the caller of a recursing tree is told `succeeded` (the four bound tests go red on exactly that), so criteria 1/2/4 are unreachable. Scoped to non-success terminal statuses and modelled on ORCHESTRATION_DISPATCH_FAILED rather than special-casing the depth code, which would have left the silent-stop hole open for every other failure.Verification
pnpm typecheck— clean (noas any/as unknownadded)pnpm eslint— 36 errors before and after, all pre-existingmax-lineson unrelated large test files; none in any file this PR touchespnpm test(server) — 238 suites / 6696 tests pass, every coverage threshold metpnpm --filter @soat/postgresdb test— 15/15, including the schema-drift suite against the two new columnspnpm run docs-lint— OK, 94 filespnpm --filter @soat/website test— 63/66; the same 3 fail on a cleanmaincheckout (they need a builtpackages/website/build). The two that matter here — every registry code gets a section, and every emitteddocs_urladdresses a heading — pass.sh -n tests/smoke-tests.sh— POSIX-clean. The smoke and tutorials stacks were not run: they need Docker, which is unavailable in this environment. CI covers them.🤖 Generated with Claude Code
https://claude.ai/code/session_01Bh5kgHuqisvy4cLtMrNf4s
Generated by Claude Code