Skip to content

fix: harden workflow lifecycle, concurrency, and retries with structured errors - #249

Merged
liuedcson merged 2 commits into
mainfrom
refactor/workflow-lifecycle-structured-errors
Oct 14, 2026
Merged

liuedcson merged 2 commits into
mainfrom
refactor/workflow-lifecycle-structured-errors

Conversation

@liuedcson

@liuedcson liuedcson commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Features

  • Structured provider error classification. AssistantMessage.errorKind (rate_limit | quota | auth | transient | invalid_request) is derived from the HTTP status, the provider's error type/code, and transport error codes. Mid-stream errors (Anthropic SSE error, OpenAI Responses error/response.failed) carry their code via ProviderStreamError, and ProviderRetryDelayError keeps the original error as cause. Retry decisions use errorKind first and fall back to message patterns only when no status or code is available.
  • Separate 429 retry budget for sessions. retry.rateLimitMaxRetries applies only to errorKind === "rate_limit", honors Retry-After, falls back to jittered backoff (1s–30s), and can be cancelled.
  • Shared admission window for subagents and workflows. Parallel subagent batches and workflow nodes take slots from one process-wide window, capped by a CPU-derived ceiling (optionally lowered by STEP_WORKFLOW_CONCURRENCY_LIMIT). Congestion control cuts the window by 25% per 429 epoch, recovers after four successes, honors Retry-After (capped at 30s), and keeps a throttled window for 60s between back-to-back runs.
  • Large subagent batches. A subagent call accepts up to 1000 tasks and queues the tasks beyond its concurrency window.
  • Content-addressed workflow resume. resumeFromRunId replays a completed call whose prompt and options are unchanged, regardless of its position. Failed, new, and changed calls run live; identical calls each consume one cached result.
    • Once a live call starts a write tool, a shell command, or an unrecognized tool (or cannot report its tool activity), only cached calls that used no file, shell, or network tools still replay.
    • Cached results are re-validated against the call's schema; calls with a runtime (safeParse) schema always run live.
    • Replayed calls are journaled under the resumed run's own sequence numbers.
  • Workflow result reporting. Runs report partial_failed/failed with the failed nodes, callback failures as taskFailures (including nested workflows), requested vs. effective concurrency, and node timing. parallel() also accepts promise arrays.

Fixes

  • A 400 response that mentions "429" is no longer treated as a rate limit, and a 429 whose code reports exhausted quota is terminal instead of retried.
  • A provider stream that throws, ends without a terminal event, or fails during construction now finalizes the partial assistant message instead of leaving the turn unfinished; a stream that closes early is classified as transient.
  • Tool results with isError: true keep their partial output and reach the model as errors; subagent results with failed or aborted children are marked as errors.
  • Workflow shutdown cancels and drains running and queued children before publishing a terminal state, seals that state, and aborts the run scope when the tool call ends.
  • Workflow node timeouts cover execution only (not queue time). Tokens reported by a cancelled or timed-out attempt are still charged, and a runner that ignores cancellation keeps its slot until it settles.
  • Runs that return with unawaited operations fail instead of finishing silently; node failures are counted once and not reported again as callback failures.
  • Journal, progress, and live-update write failures no longer replace a node's real error, and a failed progress write no longer blocks later writes.
  • Workflow and parallel subagent children hold their admission slot until the process exits, so a child that is still shutting down or cancelling no longer overlaps the next queued task.
  • Parallel subagent batches honor STEP_WORKFLOW_CONCURRENCY_LIMIT even when no workflow is running.
  • Retry-After is preserved when the retry-delay cap rejects a wait, and an empty or non-numeric retry-after-ms is treated as absent.

Testing

  • pnpm run check
  • providers, agent-core, apps/cli, coding-agent test suites (the 3 failures in completion-check-cli.test.ts also occur on main: they need a Git worktree with a HEAD commit).

@liuedcson
liuedcson force-pushed the refactor/workflow-lifecycle-structured-errors branch from 9275efa to e996c30 Compare October 14, 2026 09:15
…red errors

Supersedes #247 as a single change. It keeps that PR's lifecycle, concurrency,
and error-propagation fixes, and replaces the parts that inferred behavior
from text.

Structured provider errors (no text matching for known failures)
- Add AssistantMessage.errorKind: rate_limit | quota | auth | transient |
  invalid_request. Providers set it in their catch blocks from the HTTP
  status, the provider's machine-readable error type/code (insufficient_quota,
  GoUsageLimitError, rate_limit_exceeded, overloaded_error, ...), and transport
  error codes (APIConnectionError, ECONNRESET, ...). Mid-stream error events
  (Anthropic SSE `error`, OpenAI Responses `error`/`response.failed`) now throw
  ProviderStreamError carrying the code, and ProviderRetryDelayError keeps the
  original 429 as its cause, so both stay classifiable.
- The agent loop classifies errors thrown by a provider stream and marks a
  stream that closes without a terminal event as transient.
- isRetryableAssistantError trusts errorKind when present; the old message
  patterns remain only for failures that arrive with no status or code.
- Session retries pick the separate 429 budget, and workflow congestion
  control throttles the admission window, only for errorKind === "rate_limit".
  A 400 that mentions "429" is no longer a rate limit, and a 429 whose code
  reports exhausted quota is terminal. The regex-based isRateLimitError and
  isTerminalQuotaError are removed. Subagent children forward the kind in
  their messages; workflow runners report it as WorkflowAgentRunResult.errorKind.

Intent is the model's call, not a regex
- Do not add #247's counted fan-out detector (COUNTED_AGENT_LAUNCH, REFUSAL,
  META_QUESTION); ultraloop-opt-in.ts stays as on main. The detector missed
  common requests ("开10个子agent分别审查" hit the "别" in 分别, "…看看怎么回事"
  hit the "怎么" question filter, and verbs like 用/让 were not listed) while
  trying to rule out negations and questions with word lists. Only the
  explicit ultraloop/ultracode keyword, the fixed opt-in phrases, and
  /ultracode session mode are recognized by the harness; the model judges
  everything else from the existing tool descriptions. No intent guidance is
  added to the system prompt.

One admission window for subagents and workflows
- Parallel subagent batches take slots from the same process-wide admission
  window as workflow nodes, and feed its AIMD control with their model-request
  events. Running both at once can no longer exceed the host ceiling, and a
  429 seen by either lowers the window for both.
- Parallel subagent batches also honor STEP_WORKFLOW_CONCURRENCY_LIMIT, so the
  configured provider limit applies without a workflow running, and an
  admitted child holds its slot until its process exits (awaitExit), so a
  child still shutting down or cancelling cannot overlap queued work.

Content-addressed workflow resume
- resumeFromRunId replays any completed call whose prompt+options hash
  matches, independent of its sequence number. A failed, inserted, or
  reordered call (pipeline stages finish in nondeterministic order) now
  re-runs only itself instead of invalidating every later call. Identical
  calls are consumed in their original order, one cached result each.
  Nothing after a malformed journal line is trusted.
- A replayed call is journaled under the resumed run's own seq, with the
  schema-cleaned value the script received, so sequence numbers stay unique
  and chained resumes see what was consumed.
- A cached result must still satisfy the call's JSON schema, otherwise the
  call runs live. Calls with a runtime (safeParse) schema never replay: such
  schemas hash to one placeholder, so two different ones are
  indistinguishable.
- The cache closes on the first write, as ZCode's amend-resume does. The
  default runner forwards each child tool_execution_start (emitted before the
  tool's permission check and execution) and reports toolActivityObserved
  only when the child exited normally, since a killed child can lose events.
  classifyWorkflowToolEffect maps tools to none (clarify_user, find_tools),
  read (file reads/searches, search_web), or mutate (file writes, shell
  commands, and any unrecognized tool such as MCP). Completed entries record
  worldAccess. Once a live call starts a mutating tool, or a live call cannot
  vouch for its tool activity, only cached entries with worldAccess === false
  still replay; entries from older journals count as having touched the
  environment. Calls issued together before any live write, such as a resumed
  fan-out where a few nodes had failed, still replay in full.
- Resume wording in the workflow tool description, its guidance, and the
  system prompt's workflow section now describes this behavior.

Kept from #247
- Workflow runtime: cancel and drain running and queued children before
  publishing a terminal state, seal terminal states, and abort the run scope
  on exit. Clamp requested concurrency to a CPU-derived ceiling (optionally
  lowered by STEP_WORKFLOW_CONCURRENCY_LIMIT) and report clamping as a notice.
  Share a process-wide admission window with a 25% cut per congestion epoch,
  recovery after four successes, a Retry-After cooldown capped at 30s, and
  60s retention of a throttled window between back-to-back workflows.
- Record parallel()/pipeline() callback failures as taskFailures (including
  nested workflows), accept promise arrays in parallel(), keep node failures
  out of the callback channel and count them once, and fail runs that return
  with unawaited operations. Report partial_failed/failed with the failed
  nodes in the final tool result.
- Node timeouts cover execution only (not queue time) and come from our own
  timer, not a substring match. Tokens a cancelled or timed-out attempt
  reports while settling are still charged. A runner that ignores
  cancellation keeps its shared slot until it settles.
- Failure recording, progress.json writes, and live-update listeners are best
  effort and cannot replace a node's real error. A failed progress write no
  longer poisons the write queue.
- Subagents: accept batches above the concurrency window (up to 1000) and
  queue the excess, wait for workflow children to exit before releasing their
  slot, mark tool results with failed or aborted children as isError, and
  report rate-limit retries and request start/success over RPC.
- Session retries: an independent 429 budget (retry.rateLimitMaxRetries) that
  honors Retry-After, falls back to jittered backoff (1s to 30s), and can be
  cancelled.
- Agent loop: honor AgentToolResult.isError. Finalize a partial assistant
  message when a provider stream throws, ends early, or fails during
  construction, without reclassifying consumer/persistence errors or emitting
  a second terminal message.
- Providers: preserve Retry-After when the delay cap rejects a wait, and treat
  an empty or non-numeric retry-after-ms as absent.
@liuedcson
liuedcson force-pushed the refactor/workflow-lifecycle-structured-errors branch from e996c30 to 4323796 Compare October 14, 2026 09:30
@liuedcson
liuedcson merged commit adcd672 into main Oct 14, 2026
2 checks passed
Hylouis233 added a commit to Hylouis233/Step-Code that referenced this pull request Oct 15, 2026
* feat(compaction): use model-managed CLM by default

Keep canonical history while validating bounded edits to a persisted working
view. Reuse compatible actor prefixes for maintenance and retain native
compaction for failures, overflow recovery, and explicit requests.

Honor explicit projection settings and disabled auto-compaction, defer unused
maintenance after final answers, and cover default and native behavior in the
session, CLI, persistence, and usage regressions.

* fix(cli): retain explicit context projection after resource loading

Apply the CLI mode after resource discovery reloads settings, so an explicit
off selection takes precedence over the default CLM mode. Exercise the real
CLI startup path with a local streaming endpoint and keep the config unchanged.

* feat: add Full Access and simplify permission tiers

Introduce Ask, Approve for Me, and Full Access with session-only or remembered risk acknowledgment. Reset legacy permission settings to Ask and enable bounded model-error continuation in both automatic tiers. Preserve explicit tool overrides and scope consent to the active session.

Route /exit through the existing /quit shutdown path, remove duplicate extension registration, and cover permission migration, consent lifecycle, and exit behavior.

* fix(shell): discover native Git Bash on Windows instead of WSL

On Windows, a Git for Windows install in a non-default directory only puts
<Git>\cmd on PATH, so shell discovery fell through to `where bash.exe` and
picked C:\Windows\System32\bash.exe, silently running commands inside WSL.

- Look for Git Bash in Program Files, Program Files (x86),
  %LOCALAPPDATA%\Programs\Git, then relative to each git.exe on PATH, then
  native bash.exe on PATH. Resolve symlinks and drop the where.exe dependency.
- Never select the WSL launcher automatically; an explicit shellPath to it
  keeps its stdin command transport.
- Add the Git installation's bin and usr/bin to the Git Bash child PATH only.
- On Windows, pipe background command output into the log file instead of
  handing the file descriptor to MSYS, which exited 1 with an empty log.
- Generate candidates lazily so a standard install returns without scanning
  PATH on every command or permission check.

Fixes stepfun-ai#136

Co-authored-by: ZXJC-niusile <2632590648@qq.com>

* fix(shell): use Git Bash child PATH for config value commands

Config values written as `!command` were run with a direct spawnSync on the
resolved shell, bypassing spawnShellChild, so on Windows they did not get the
Git installation's bin and usr/bin on PATH. Export withGitBashPath and apply
it there too so these commands see the same tools as run_command.

* fix(skills): emit readable single-line diagnostics for SKILL.md frontmatter errors

* fix(skills): make frontmatter diagnostic hints syntax-aware

Inspect recovered YAML structure to avoid quoting hints for comments and block scalars. Escape parser control characters and tolerate absent error values. Cover both skill loaders and CLI diagnostic rendering.

* fix: stop repeating Ultracode capability reminders (stepfun-ai#241)

* fix: announce Ultracode mode only when context state changes

Keep capability guidance in the workflow tool description instead of appending a tutorial to every user turn. Announce session-mode transitions after real user input and reuse the last runtime-authored state in the active context. Preserve per-turn opt-in and reconcile missing or changed mode state before provider requests.

Cover provider-visible message order, unchanged turns, mode toggles, compaction, restricted profiles, and streaming revocation.

* fix: keep workflow opt-in guidance accurate across tool profiles

Explain unavailable workflow tools for per-turn requests and make standing consent conditional on the current tool profile. Cover restricted per-turn requests and changing tool availability without repeated state notices.

* fix: avoid persisting an unchanged retry default

Skip the native retry setter when automatic permission mode finds retry already enabled. This prevents startup from adding a redundant retry section to config.toml and preserves runtime-only CLI overrides.

---------

Co-authored-by: liuedcson <332840681+liuedcson@users.noreply.github.com>

* fix(skills): quiet startup skill conflicts for duplicated shared skill dirs

Users who run both Codex and Claude Code often have the same skills
copied into ~/.agents/skills and ~/.claude/skills. Each copy produced a
collision entry, flooding startup even though the shadowed copy is never
loaded.

- Skip a colliding skill silently when its SKILL.md is byte-identical to
  the winner, the same way symlinked duplicates are already skipped.
- Without the full resource listing, collapse remaining skill/prompt
  collisions into one line per (skipped dir, winning dir) pair; --verbose
  still lists each one. Warnings and errors are shown in full as before.

* feat: persist Ultracode mode per session (stepfun-ai#245)

* feat: persist Ultracode mode per session

Record the latest Ultracode on/off choice in the current session journal and restore it when that session is reopened. Keep new sessions opted out and avoid global or project settings.

* fix: restore Ultracode status after session reopen

---------

Co-authored-by: liuedcson <332840681+liuedcson@users.noreply.github.com>

* docs: add guidelines for platform and provider packages

Document configuration migrations, host contracts, provider adapters, telemetry, and terminal rendering.

Define dependency boundaries and module-specific validation commands.

* docs: define agent runtime and coding product boundaries

Describe Agent Loop, session, context projection, and compaction ownership.

Keep coding-agent guidance at package scope and document tools, extensions, orchestration, and Step integrations against the current implementation.

* docs: document CLI hosting and UI layer responsibilities

Define startup, mode routing, process lifecycle, and protocol output boundaries.

Document host-to-runtime-to-view dependencies, input handling, redraws, and focused validation.

* docs: establish repository and support directory guidelines

Connect module guidance through the repository architecture and ownership map.

Add documentation, script, release, and plugin conventions while preserving the existing commit attribution policy and CLAUDE.md reference.

* fix: harden workflow lifecycle, concurrency, and retries with structured errors (stepfun-ai#249)

* fix: harden workflow lifecycle, concurrency, and retries with structured errors

Supersedes stepfun-ai#247 as a single change. It keeps that PR's lifecycle, concurrency,
and error-propagation fixes, and replaces the parts that inferred behavior
from text.

Structured provider errors (no text matching for known failures)
- Add AssistantMessage.errorKind: rate_limit | quota | auth | transient |
  invalid_request. Providers set it in their catch blocks from the HTTP
  status, the provider's machine-readable error type/code (insufficient_quota,
  GoUsageLimitError, rate_limit_exceeded, overloaded_error, ...), and transport
  error codes (APIConnectionError, ECONNRESET, ...). Mid-stream error events
  (Anthropic SSE `error`, OpenAI Responses `error`/`response.failed`) now throw
  ProviderStreamError carrying the code, and ProviderRetryDelayError keeps the
  original 429 as its cause, so both stay classifiable.
- The agent loop classifies errors thrown by a provider stream and marks a
  stream that closes without a terminal event as transient.
- isRetryableAssistantError trusts errorKind when present; the old message
  patterns remain only for failures that arrive with no status or code.
- Session retries pick the separate 429 budget, and workflow congestion
  control throttles the admission window, only for errorKind === "rate_limit".
  A 400 that mentions "429" is no longer a rate limit, and a 429 whose code
  reports exhausted quota is terminal. The regex-based isRateLimitError and
  isTerminalQuotaError are removed. Subagent children forward the kind in
  their messages; workflow runners report it as WorkflowAgentRunResult.errorKind.

Intent is the model's call, not a regex
- Do not add stepfun-ai#247's counted fan-out detector (COUNTED_AGENT_LAUNCH, REFUSAL,
  META_QUESTION); ultraloop-opt-in.ts stays as on main. The detector missed
  common requests ("开10个子agent分别审查" hit the "别" in 分别, "…看看怎么回事"
  hit the "怎么" question filter, and verbs like 用/让 were not listed) while
  trying to rule out negations and questions with word lists. Only the
  explicit ultraloop/ultracode keyword, the fixed opt-in phrases, and
  /ultracode session mode are recognized by the harness; the model judges
  everything else from the existing tool descriptions. No intent guidance is
  added to the system prompt.

One admission window for subagents and workflows
- Parallel subagent batches take slots from the same process-wide admission
  window as workflow nodes, and feed its AIMD control with their model-request
  events. Running both at once can no longer exceed the host ceiling, and a
  429 seen by either lowers the window for both.
- Parallel subagent batches also honor STEP_WORKFLOW_CONCURRENCY_LIMIT, so the
  configured provider limit applies without a workflow running, and an
  admitted child holds its slot until its process exits (awaitExit), so a
  child still shutting down or cancelling cannot overlap queued work.

Content-addressed workflow resume
- resumeFromRunId replays any completed call whose prompt+options hash
  matches, independent of its sequence number. A failed, inserted, or
  reordered call (pipeline stages finish in nondeterministic order) now
  re-runs only itself instead of invalidating every later call. Identical
  calls are consumed in their original order, one cached result each.
  Nothing after a malformed journal line is trusted.
- A replayed call is journaled under the resumed run's own seq, with the
  schema-cleaned value the script received, so sequence numbers stay unique
  and chained resumes see what was consumed.
- A cached result must still satisfy the call's JSON schema, otherwise the
  call runs live. Calls with a runtime (safeParse) schema never replay: such
  schemas hash to one placeholder, so two different ones are
  indistinguishable.
- The cache closes on the first write, as ZCode's amend-resume does. The
  default runner forwards each child tool_execution_start (emitted before the
  tool's permission check and execution) and reports toolActivityObserved
  only when the child exited normally, since a killed child can lose events.
  classifyWorkflowToolEffect maps tools to none (clarify_user, find_tools),
  read (file reads/searches, search_web), or mutate (file writes, shell
  commands, and any unrecognized tool such as MCP). Completed entries record
  worldAccess. Once a live call starts a mutating tool, or a live call cannot
  vouch for its tool activity, only cached entries with worldAccess === false
  still replay; entries from older journals count as having touched the
  environment. Calls issued together before any live write, such as a resumed
  fan-out where a few nodes had failed, still replay in full.
- Resume wording in the workflow tool description, its guidance, and the
  system prompt's workflow section now describes this behavior.

Kept from stepfun-ai#247
- Workflow runtime: cancel and drain running and queued children before
  publishing a terminal state, seal terminal states, and abort the run scope
  on exit. Clamp requested concurrency to a CPU-derived ceiling (optionally
  lowered by STEP_WORKFLOW_CONCURRENCY_LIMIT) and report clamping as a notice.
  Share a process-wide admission window with a 25% cut per congestion epoch,
  recovery after four successes, a Retry-After cooldown capped at 30s, and
  60s retention of a throttled window between back-to-back workflows.
- Record parallel()/pipeline() callback failures as taskFailures (including
  nested workflows), accept promise arrays in parallel(), keep node failures
  out of the callback channel and count them once, and fail runs that return
  with unawaited operations. Report partial_failed/failed with the failed
  nodes in the final tool result.
- Node timeouts cover execution only (not queue time) and come from our own
  timer, not a substring match. Tokens a cancelled or timed-out attempt
  reports while settling are still charged. A runner that ignores
  cancellation keeps its shared slot until it settles.
- Failure recording, progress.json writes, and live-update listeners are best
  effort and cannot replace a node's real error. A failed progress write no
  longer poisons the write queue.
- Subagents: accept batches above the concurrency window (up to 1000) and
  queue the excess, wait for workflow children to exit before releasing their
  slot, mark tool results with failed or aborted children as isError, and
  report rate-limit retries and request start/success over RPC.
- Session retries: an independent 429 budget (retry.rateLimitMaxRetries) that
  honors Retry-After, falls back to jittered backoff (1s to 30s), and can be
  cancelled.
- Agent loop: honor AgentToolResult.isError. Finalize a partial assistant
  message when a provider stream throws, ends early, or fails during
  construction, without reclassifying consumer/persistence errors or emitting
  a second terminal message.
- Providers: preserve Retry-After when the delay cap rejects a wait, and treat
  an empty or non-numeric retry-after-ms as absent.

* test: make subagent concurrency checks independent of host CPU count

---------

Co-authored-by: liuedcson <332840681+liuedcson@users.noreply.github.com>

* fix: route then_run through run_command (stepfun-ai#248)

* docs: define development and testing practices

* docs: map repository structure and engineering rules

* docs: standardize English commits and pull request reviews

* fix: provision StepPage on Windows via the published bundle

provisionBuiltinPlugin() skipped win32 entirely and ensureStepPageReady()
refused to install without a healthy executable, so the built-in steppage
plugin could never set up steppage-mcp on Windows and every launch reported
"automatic installation is currently supported on macOS and Linux" (stepfun-ai#219).

Windows cannot run the POSIX installer script, so install the published
bundle directly: download steppage-mcp.mjs, write a steppage-mcp.cmd wrapper
next to it under ~/.local/bin, and verify the result. Executable probes run
through `cmd /d /c` on Windows so both bare PATHEXT lookups and explicit
.cmd files resolve on every Node runtime.

The lock, Node.js >= 20 check, credential handling and failure reporting
stay shared with the POSIX path; only the download-and-execute step is
platform-specific. The Windows failure hint points at `step mcp prepare`
instead of the POSIX-only `curl | sh` pipeline.

* ci: run existing checks on the StepPage fix branch

* ci: add workflow_dispatch so CI runs can be dispatched manually

Restore the canonical push trigger (the branch was only added to work around
the missing manual entry) and add workflow_dispatch so the CI workflow can be
triggered from the Actions tab without pushing a no-op commit.

* ci: retrigger checks for this PR

CI had no workflow run attached to this PR. Actions is confirmed working, so
this empty commit fires the pull_request synchronize event against main.

* ci: retrigger PR checks

Actions only started accepting event triggers after the first manual dispatch;
this forces a pull_request synchronize event now that it is live.

---------

Co-authored-by: youwang <61931019+MelodyVAR@users.noreply.github.com>
Co-authored-by: liuedcson <332840681+liuedcson@users.noreply.github.com>
Co-authored-by: edcsonzzz <liuedcson@gmail.com>
Co-authored-by: xuyunfang <xuyunfang@stepfun.com>
Co-authored-by: ZXJC-niusile <2632590648@qq.com>
Co-authored-by: Hylouis233 <Hylouis233@users.noreply.github.com>
Co-authored-by: Uking-xxx <xuklaus1@gmail.com>
Co-authored-by: ZouR-Ma <2605315944@qq.com>
Co-authored-by: ZouR Ma <37286405+ZouR-Ma@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant