Skip to content

🤖 fix: prevent workflow task recovery after interrupts#3594

Merged
ThomasK33 merged 7 commits into
mainfrom
workflow-recovery-8ay6
Jun 18, 2026
Merged

🤖 fix: prevent workflow task recovery after interrupts#3594
ThomasK33 merged 7 commits into
mainfrom
workflow-recovery-8ay6

Conversation

@ThomasK33

Copy link
Copy Markdown
Member

Summary

Prevents task-level startup, queue, stream-end, and stream-error recovery from resurrecting workflow-owned child tasks after their owning workflow run is inactive.

Background

Workflow interrupt/abort is the source of truth for workflow execution. Before this change, TaskService recovery paths could still prompt or restart child agent tasks after the owning workflow was interrupted, which could burn tokens and violate workflow lifecycle boundaries.

Implementation

  • Resolves the nearest workflow-owned task in a task ancestry chain and reads the durable workflow run status from that run's store.
  • Interrupts workflow-owned tasks, including nested descendants, when their owning run is inactive or unavailable instead of prompting for recovery.
  • Reuses task indexes across recovery and descendant checks to avoid repeated full-workspace indexing.
  • Extracts the shared interrupted-task mutation logic so inactive-owner recovery and descendant interruption preserve completed reports and queued prompts consistently.
  • Adds regression coverage for startup recovery and stream-end behavior after an owning workflow is interrupted.

Validation

  • Ran simplify workflow wfr_ed8469895aaa74a1; it applied cleanup commit e0ab3551b and reported targeted validation passed.
  • MUX_ESLINT_CONCURRENCY=1 make static-check

Risks

Moderate TaskService lifecycle risk: this touches recovery paths for queued/running/awaiting-report tasks. The guard is limited to tasks that are workflow-owned directly or through ancestry, and active workflow runs still allow normal recovery.


Generated with mux • Model: openai:gpt-5.5 • Thinking: xhigh • Cost: $17.65

Workflow-owned task recovery now checks the owning workflow run status before startup, queued-launch, stream-end, or stream-error recovery can restart the task. Inactive workflow owners interrupt child tasks instead, preserving queued prompts for later inspection or manual workflow resume.\n\n---\n\n_Generated with mux • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$17.65`_\n\n<!-- mux-attribution: model=openai:gpt-5.5 thinking=xhigh costs=17.65 -->
Signed-off-by: Thomas Kosiewski <tk@coder.com>
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e0ab3551b3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/node/services/taskService.ts
Startup recovery now interrupts inactive workflow-owned tasks before checking whether parent tasks still have active descendants, then reloads the task index so parent awaiting_report recovery can proceed in the same startup pass.\n\n---\n\n_Generated with mux • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$17.65`_\n\n<!-- mux-attribution: model=openai:gpt-5.5 thinking=xhigh costs=17.65 -->
@ThomasK33

Copy link
Copy Markdown
Member Author

Addressed Codex thread PRRT_kwDOPxxmWM6Klrcy: startup recovery now pre-interrupts inactive workflow-owned awaiting/running tasks, refreshes the config/task index, and then runs parent descendant checks so an awaiting parent is not left stuck behind a child that was just interrupted. Added a regression test for that parent-after-child recovery case.

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

Please take another look after the startup recovery refresh fix.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 70a0989963

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/node/services/taskService.ts
When startup recovery interrupts inactive workflow-owned tasks, run the queue scheduler again after recovery prompts so newly freed task slots can start unrelated queued work immediately.\n\n---\n\n_Generated with mux • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$17.65`_\n\n<!-- mux-attribution: model=openai:gpt-5.5 thinking=xhigh costs=17.65 -->
@ThomasK33

Copy link
Copy Markdown
Member Author

Addressed Codex thread PRRT_kwDOPxxmWM6Kl4iu: after startup interrupts inactive workflow-owned tasks and finishes recovery prompts, TaskService now awaits another queue drain so freed capacity can launch unrelated queued tasks immediately. Added a regression test where maxParallelAgentTasks=1 is initially occupied by an inactive workflow child and a queued task starts in the same initialize pass after the interrupt.

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

Please take another look after the post-interrupt queue drain fix.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5279449f86

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/node/services/taskService.ts
Queued workflow-owned tasks from inactive runs are now interrupted before capacity gating, so abandoned workflow steps do not remain live when all parallel task slots are occupied.\n\n---\n\n_Generated with mux • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$17.65`_\n\n<!-- mux-attribution: model=openai:gpt-5.5 thinking=xhigh costs=17.65 -->
@ThomasK33

Copy link
Copy Markdown
Member Author

Addressed Codex thread PRRT_kwDOPxxmWM6KmEKo: maybeStartQueuedTasksFromReservations now scans queued tasks for inactive workflow owners before capacity gating or slot consumption, so abandoned queued workflow steps get interrupted even when no slots are available. Extended the startup queue regression to cover an inactive queued workflow task alongside an unrelated queued task.

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

Please take another look after the queued inactive-workflow prepass fix.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: aa98ca60a8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/node/services/taskService.ts
Inactive workflow-owner interruptions now schedule queued-task draining outside the scheduler path so freed task slots do not leave unrelated queued work stranded. Scheduler callers opt out to avoid recursive queue scans.\n\n---\n\n_Generated with mux • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$17.65`_\n\n<!-- mux-attribution: model=openai:gpt-5.5 thinking=xhigh costs=17.65 -->
@ThomasK33

Copy link
Copy Markdown
Member Author

Addressed Codex thread PRRT_kwDOPxxmWM6KmN8Z: inactive workflow-owner interruptions now schedule queued-task draining by default after a real interruption, and scheduler callsites opt out to avoid recursive queue scans. Extended the stream-end inactive workflow regression to assert that the queue drain is scheduled.

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

Please take another look after the inactive-interrupt queue-drain scheduling fix.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2f1c0627d7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/node/services/taskService.ts Outdated
Workflow recovery now checks every workflow owner in a task ancestry chain so an active nested workflow cannot mask an interrupted outer workflow owner.\n\n---\n\n_Generated with mux • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$17.65`_\n\n<!-- mux-attribution: model=openai:gpt-5.5 thinking=xhigh costs=17.65 -->
@ThomasK33

Copy link
Copy Markdown
Member Author

Addressed Codex thread PRRT_kwDOPxxmWM6KmZWY: workflow recovery now checks every workflow owner in the ancestry chain, returning inactive if any owner is inactive. The regression now covers a nested task whose nearest inner workflow run is active while an outer workflow owner is interrupted.

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

Please take another look after the ancestry-wide workflow owner check fix.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Hooray!

Reviewed commit: 14842dcce6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@ThomasK33 ThomasK33 added this pull request to the merge queue Jun 18, 2026
Merged via the queue into main with commit 4b848d0 Jun 18, 2026
54 of 60 checks passed
@ThomasK33 ThomasK33 deleted the workflow-recovery-8ay6 branch June 18, 2026 17:03
@mux-bot mux-bot Bot mentioned this pull request Jun 18, 2026
mux-bot Bot added a commit that referenced this pull request Jun 18, 2026
…itialize

Extract the awaiting_report/running candidate filters used by the startup
inactive-workflow-owner recovery pass into a single listStartupRecoveryCandidates
closure, mirroring the existing listQueuedTasks pattern, so the initial computation
and the post-interrupt refresh reuse the exact same status filters.

Behavior-preserving cleanup of #3594.
mux-bot Bot added a commit that referenced this pull request Jun 18, 2026
…itialize

Extract the awaiting_report/running candidate filters used by the startup
inactive-workflow-owner recovery pass into a single listStartupRecoveryCandidates
closure, mirroring the existing listQueuedTasks pattern, so the initial computation
and the post-interrupt refresh reuse the exact same status filters.

Behavior-preserving cleanup of #3594.
mux-bot Bot added a commit that referenced this pull request Jun 19, 2026
…itialize

Extract the awaiting_report/running candidate filters used by the startup
inactive-workflow-owner recovery pass into a single listStartupRecoveryCandidates
closure, mirroring the existing listQueuedTasks pattern, so the initial computation
and the post-interrupt refresh reuse the exact same status filters.

Behavior-preserving cleanup of #3594.
mux-bot Bot added a commit that referenced this pull request Jun 19, 2026
…itialize

Extract the awaiting_report/running candidate filters used by the startup
inactive-workflow-owner recovery pass into a single listStartupRecoveryCandidates
closure, mirroring the existing listQueuedTasks pattern, so the initial computation
and the post-interrupt refresh reuse the exact same status filters.

Behavior-preserving cleanup of #3594.
mux-bot Bot added a commit that referenced this pull request Jun 19, 2026
…itialize

Extract the awaiting_report/running candidate filters used by the startup
inactive-workflow-owner recovery pass into a single listStartupRecoveryCandidates
closure, mirroring the existing listQueuedTasks pattern, so the initial computation
and the post-interrupt refresh reuse the exact same status filters.

Behavior-preserving cleanup of #3594.
mux-bot Bot added a commit that referenced this pull request Jun 22, 2026
…itialize

Extract the awaiting_report/running candidate filters used by the startup
inactive-workflow-owner recovery pass into a single listStartupRecoveryCandidates
closure, mirroring the existing listQueuedTasks pattern, so the initial computation
and the post-interrupt refresh reuse the exact same status filters.

Behavior-preserving cleanup of #3594.
mux-bot Bot added a commit that referenced this pull request Jun 22, 2026
…itialize

Extract the awaiting_report/running candidate filters used by the startup
inactive-workflow-owner recovery pass into a single listStartupRecoveryCandidates
closure, mirroring the existing listQueuedTasks pattern, so the initial computation
and the post-interrupt refresh reuse the exact same status filters.

Behavior-preserving cleanup of #3594.
mux-bot Bot added a commit that referenced this pull request Jun 22, 2026
…itialize

Extract the awaiting_report/running candidate filters used by the startup
inactive-workflow-owner recovery pass into a single listStartupRecoveryCandidates
closure, mirroring the existing listQueuedTasks pattern, so the initial computation
and the post-interrupt refresh reuse the exact same status filters.

Behavior-preserving cleanup of #3594.
jim-fung pushed a commit to jim-fung/mux that referenced this pull request Jul 1, 2026
## Summary

Long-lived auto-cleanup PR maintained by the **Auto-Cleanup Agent**.
Each pass lands at most one extremely low-risk, behavior-preserving
cleanup drawn from recently merged `main` activity, then advances the
checkpoint below. The prior long-lived PR (coder#3559) merged into `main`;
this is its successor.

Auto-cleanup checkpoint: 2af29df

## This pass

Considered the one new `main` commit since the previous checkpoint —
coder#3603 (`feat: add built-in loop skill`). That change adds the advertised
`loop` built-in skill (`src/node/builtinSkills/loop.md`), regenerates
`builtInSkillContent.generated.ts` (generated — off-limits), and extends
the agent-skills discovery test expectations. The
skill-content/generated surface offers no safe hand-edit, so the cleanup
was drawn from the production sibling of that area: the skill-discovery
service `agentSkillsService.ts`.

Landed one behavior-preserving cleanup in `agentSkillsService.ts`:
`readSkillDescriptorFromDir` open-coded the same invalid-skill
diagnostic push — `options?.invalidSkills?.push({ directoryName, scope,
displayPath: skillFilePath, message, hint })` — at six failure sites
(missing/unreadable SKILL.md, SKILL.md-is-a-directory, oversized file,
read failure, invalid descriptor, parse failure). Those six sites share
identical identity fields (`directoryName` / `scope` / `displayPath`)
and differ only in `message` + `hint`. All six now delegate to a single
function-local `pushInvalidSkill(message, hint)` closure that captures
the function-constant identity fields. Behavior-preserving: the pushed
object shape and every per-site `message`/`hint` are unchanged, and the
closure short-circuits on `options?.invalidSkills` exactly as the inline
calls did.

## Branch contents (net diff against `main`)

<details>
<summary>Cleanups carried by this PR (not yet merged)</summary>

- **`agentSkillsService.ts` — dedupe invalid-skill diagnostics.**
coder#3603's new built-in skill lives in the agent-skills discovery area;
`readSkillDescriptorFromDir` open-coded the same
`options?.invalidSkills?.push({ directoryName, scope, displayPath:
skillFilePath, message, hint })` at six failure sites that share
identical identity fields and differ only in `message`/`hint`. All six
now delegate to a single function-local `pushInvalidSkill(message,
hint)` closure. Behavior-preserving (identical pushed shape, messages,
hints, and `options?.invalidSkills` short-circuit).
- **`workspaceGoalService.ts` — dedupe session file path resolution.**
The three private accessors `getFilePath` / `getHistoryFilePath` /
`getBoardFilePath` each open-coded the same `assert(...non-empty
workspaceId)` guard and `path.join(getSessionDir(...), <FILE_CONST>)`.
All three now delegate to a single `resolveSessionFilePath(workspaceId,
fileName)` helper. Behavior-preserving (identical guard + join; accessor
names and callers unchanged).
- **`task.ts` — dedupe plan-agent explore-only error.** The plan-agent
restriction error `In the plan agent you may only spawn agentId:
"explore" tasks.` was open-coded twice (the workspace-turn guard and the
per-launch agent-id guard). Both now reference a single module-level
`PLAN_AGENT_EXPLORE_ONLY_ERROR` constant. Behavior-preserving (identical
string output).
- **`task_await.ts` — dedupe workspace-turn error fallback.** coder#3600's
new workspace-turn await paths open-coded the fallback error message
`"Workspace turn failed"` three times (`<record>.error ?? "Workspace
turn failed"`). All three now reference a single module-level
`WORKSPACE_TURN_DEFAULT_ERROR` constant. Behavior-preserving (identical
string output).
- **`goalToolUtils.tsx` / `SetGoalToolCall.tsx` — dedupe turn-count
pluralization.** coder#3595's new goal toolcards open-coded `` `${n} turn${n
=== 1 ? "" : "s"}` `` in both `formatGoalTurns` and the set_goal
toolcard's `formatOptionalTurnCap`. Both now call a shared
`pluralizeTurns(count)` helper. Behavior-preserving (identical string
output).
- **`heartbeat.ts` — dedupe heartbeat tool success result
construction.** coder#3596's new `heartbeat` tool open-coded the same `{
success: true, action, configured, settings, summary }` success object
three times (`get` / `set` / `unset`), each re-calling `summarize({
action, settings })`. All three now route through a single
`buildSuccessResult(action, settings)` helper that derives `configured`
from `settings`. Behavior-preserving: the per-branch `configured` values
(`settings != null`, `false`, `true`) all equal `settings != null` given
the non-null `Ok` payload type for `set`.
- **`taskService.ts` — dedupe startup recovery candidate listing.**
coder#3594's `TaskService.initialize` open-coded the `awaiting_report` /
`running` candidate filters twice (initial computation and
post-interrupt refresh). Both call sites now reuse a single
`listStartupRecoveryCandidates(sourceConfig)` closure returning `{
awaitingReportTasks, runningTasks }`, matching the existing
`listQueuedTasks` pattern. Behavior-preserving.
- **`ChatPane.tsx` — dedupe send-queued-immediately in-flight guard
clear.** coder#3592 introduced `sendQueuedImmediatelyInFlightRef` to block
duplicate send-now attempts; releasing that guard ("clear it only if it
still points at this attempt") was open-coded twice — once after a
failed interrupt result and once in the `catch` arm. Both call sites now
invoke a single callback-local closure `clearInFlightGuardIfCurrent()`.
Behavior-preserving: the failed-result site's combined
`!interruptResult.success && ref === id` becomes `if
(!interruptResult.success) clearInFlightGuardIfCurrent()` (logically
identical); the `catch` site is a verbatim substitution.
- **`WorkflowRunner.ts` — dedupe `parallelAgents` child-failure abort
handling** into a single batch-local closure
`applyChildFailureToBatch(error)`, shared by the per-run task closure
and the bulk-create path.
- **`ChatInput/index.tsx` — dedupe edit-mode attachment toast** string
`"Attachments cannot be added while editing a message."` into a single
module-level constant `EDIT_MODE_ATTACHMENT_ERROR_MESSAGE`, replacing
three inline copies across the add-attachment / paste / drop guards.

</details>

<details>
<summary>Prior passes (landed via coder#3559, merged)</summary>

- Replaced an inline terminal-status check in
`WorkflowService.interruptRunOnAbort` with the
`isTerminalWorkflowRunStatus` helper.
- Deduplicated the `workspaceId`-only metadata JSON Schema repeated by
three id-targeted host actions in `workspaceHostActions.ts` into
`WORKSPACE_ID_ONLY_INPUT_SCHEMA`.
- Extracted a shared `BlockedBadge` component in
`WorkflowDefinitionToolCall.tsx`.
- Deduplicated the repeated `argsSchema.properties must be an object`
error string in `workflowArgs.ts`.
- Deduplicated mocked-module specifier strings in
`useModelsFromSettings.test.ts`.
- Removed a redundant `toggleWorkflowExpanded` wrapper in
`WorkflowRunToolCall.tsx`.
- Deduplicated `SAVE_AUTOMATION_ERROR_MESSAGE` /
`REMOVE_AUTOMATION_ERROR_MESSAGE` in `AutomationModal.tsx`.
- Replaced a duplicated `"wfr_"` literal in `WorkflowRunner.ts` with
`isWorkflowRunTaskId()`.

</details>

## Validation

- `make static-check` — ESLint, both `tsgo` typecheck configs, and
Prettier all pass on the rebased tree. The only failing step is
`fmt-shell-check`, which requires `shfmt` (not installed in this
environment); no shell scripts were touched, so CI (which has `shfmt`)
is unaffected.
- `bun test src/node/services/agentSkills/agentSkillsService.test.ts` —
20 pass, 0 fail (covers skill discovery + the invalid-skill diagnostics
path this pass deduped); `builtInLoopSkill.test.ts` — 1 pass.

---

_Generated with `mux` • Model: `anthropic:claude-opus-4-8` • Thinking:
`xhigh`_

<!-- mux-attribution: model=anthropic:claude-opus-4-8 thinking=xhigh -->

---------

Co-authored-by: mux-bot[bot] <264182336+mux-bot[bot]@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