Skip to content

fix: comfy run --wait writes job state at submit, names prompt_id on disconnect (BE-4750) - #605

Merged
mattmillerai merged 2 commits into
mainfrom
matt/be-4750-wait-state-file
Jul 28, 2026
Merged

fix: comfy run --wait writes job state at submit, names prompt_id on disconnect (BE-4750)#605
mattmillerai merged 2 commits into
mainfrom
matt/be-4750-wait-state-file

Conversation

@mattmillerai

Copy link
Copy Markdown
Collaborator

ELI-5

When you run comfy run --workflow x --wait, the CLI sits there and watches the job until it finishes. Until now it only wrote the little "here's what happened to this job" file at the very end, on success. So if the ComfyUI server died halfway through (say it got OOM-killed by a huge allocation), you got an error that said "lost connection" and nothing else — no job id, no file on disk. Nothing anywhere remembered that a prompt was in flight when the server went down.

Now the CLI writes that file the moment the server accepts the job, and keeps it updated. If the server dies, the file says so and the error tells you which prompt it was, so comfy jobs status <id> has something to read.

What changed

All in comfy_cli/command/run/__init__.py, local execute path only (execute_cloud already wrote state at submit for both modes and is untouched):

  • Submit-time write on --wait. Right after a successful queue(), the state is created and written with status = "running" — mirroring the async branch's submit-time write. "running" rather than "queued" because this foreground process is actively watching it, not leaving it detached.
  • Completion updates, doesn't recreate. The completion block reuses the submit-time state object (sets status = "completed" + outputs), so it is the same file with the same final shape as before.
  • Cancel paths record it. The post-watch token check, the KeyboardInterrupt handler, and the token-cancelled branch inside the disconnect handler all set status = "cancelled" + error.code = "cancelled". Guarded so a cancel before queue() returned (no state yet) simply skips the write.
  • ws_disconnected records server_died and names the prompt. When a prompt_id exists, the state file goes to status = "error" with error.code = "server_died", and the emitted error keeps code = "ws_disconnected" but names the prompt in the message and carries details = {"prompt_id": ..., "state_file": ...}. A disconnect before queue() returned emits today's bare error unchanged.
  • ws_timeout names the prompt but stays non-terminal. details gains prompt_id; the state file is deliberately left at its submit-time running record, because a timed-out watch says nothing about the job — it may genuinely still be running server-side.

Every state write goes through a _write_state helper that swallows write failures, so a state-dir problem can never fail an otherwise-successful run (matching the async path's tolerance of a watcher that won't spawn).

Tests

Seven new tests in tests/comfy_cli/command/test_run.py (TestWaitStateFile), following the existing WorkflowExecution mock pattern and the autouse _isolate_jobs_state_dir fixture:

  • happy path — a watch_execution side effect reads the file mid-run and asserts status == "running" with no completed_at, then the post-run file is completed with outputs and the same submitted_at (proving it is an update, not a fresh record)
  • watch_execution raises ConnectionError — emitted error is ws_disconnected with details.prompt_id and a state_file path; the file is status == "error", error.code == "server_died"
  • disconnect before a prompt_id exists (queue() raises) — no state file is created, no crash, and the emitted error keeps today's bare shape (details is None)
  • timeout — details == {"timeout": 30, "prompt_id": ...} and the file stays running / non-terminal
  • cancel via the cancellation token, and cancel via KeyboardInterrupt — both leave status == "cancelled"
  • parametrized write-failure test (OSError, ValueError) proving a broken state file never fails the run

ruff check + ruff format --diff clean on the CI-pinned 0.15.15; full pytest green (2777 passed, 37 skipped).

Judgment calls

  • One file outside the ticket's stated scope. The ticket said changes confined to comfy_cli/command/run/__init__.py + tests, but the repo's own test_every_raised_code_is_registered scans {"code": "..."} dict literals in source (explicitly including "the watcher and state-file paths") and fails on any code missing from comfy_cli/error_codes.REGISTRY. So server_died needed a five-line registry entry. That is the repo enforcing its own invariant, not scope creep, but flagging it since it is a deviation from the ticket text.
  • _write_state swallows ValueError as well as OSError. The ticket asked for try/except OSError. jobs_state.state_path raises ValueError on a prompt_id it can't turn into a safe filename, and moving the write from completion to submit would newly let that abort the run before watch_execution() — i.e. we'd stop watching a job the server had already accepted. Swallowing it keeps the submit-time write strictly non-regressive. Covered by the parametrized test.
  • The envelope's state_file falls back to the submit-time path (_write_state(state) or wait_state_file) if the completion write fails, since that file does exist on disk. It would then read running while the envelope says completed — the accepted trade of best-effort state writes.
  • An unexpected exception type (something outside the three handled classes) leaves the record at running rather than marking it terminal. That is deliberate: we don't know the job died, and it is still strictly more information than the previous behavior of no file at all.
  • Capability check (no denial in this diff). The self-review's negative-claim trigger does not fire here: nothing is marked unsupported/unavailable and no deny or dead-end path is added. The ws_disconnected error already existed; this change only adds information to it (prompt_id, state file) and creates an on-disk record where there was none, so the user-facing outcome strictly expands what is recoverable after a mid-run server death.

…disconnect (BE-4750)

On the foreground (--wait) path the jobs state file was written only on
successful completion, so a server that died mid-run (e.g. OOM-kill) left
nothing on disk and emitted a ws_disconnected error with no prompt_id —
no record anywhere that the prompt had been in flight.

Write the state file right after a successful queue() with status
"running" (mirroring the async path's submit-time write), then update
that same record on completion, cancellation, and disconnect. The
disconnect path now records error.code = "server_died" and names the
prompt_id + state file in the emitted error; the timeout path adds the
prompt_id but deliberately leaves the record non-terminal, since the job
may still be running server-side.

All state writes are best-effort — a state-file failure never fails an
otherwise-successful run.
@mattmillerai mattmillerai added agent-coded PR authored by the agent-work loop cursor-review Request Cursor bot review labels Jul 27, 2026
@mattmillerai
mattmillerai marked this pull request as ready for review July 27, 2026 21:18
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 27 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 8f8149d9-a32b-42ff-83d8-93319d918a4e

📥 Commits

Reviewing files that changed from the base of the PR and between 85b62da and e1f279f.

📒 Files selected for processing (4)
  • comfy_cli/command/run/__init__.py
  • comfy_cli/command/run/execution.py
  • comfy_cli/error_codes.py
  • tests/comfy_cli/command/test_run.py
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch matt/be-4750-wait-state-file
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch matt/be-4750-wait-state-file

Comment @coderabbitai help to get the list of available commands.

@dosubot dosubot Bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Jul 27, 2026

@github-actions github-actions 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.

🔍 Cursor Review — Consolidated panel

Triggered by @mattmillerai.

Found 6 finding(s).

Severity Count
🟠 High 2
🟡 Medium 1
🟢 Low 2
⚪ Nit 1

Panel: 6/8 reviewers contributed findings.

Reviewers that did not contribute: kimi-k2.5:adversarial (empty), kimi-k2.5:edge-case (empty)

Comment thread comfy_cli/command/run/__init__.py Outdated
Comment thread comfy_cli/command/run/__init__.py
Comment thread comfy_cli/command/run/__init__.py
Comment thread comfy_cli/command/run/__init__.py Outdated
Comment thread comfy_cli/command/run/__init__.py
Comment thread comfy_cli/command/run/__init__.py Outdated
…letion out of the disconnect catch (BE-4750)

Cursor review panel findings on the submit-time state file:

- watch_execution() ends a failed run by rendering the error and raising
  typer.Exit (1 for execution_error, 130 for execution_interrupted) — the
  ordinary failure path, not something the disconnect/KeyboardInterrupt
  handlers see. The submit-time `running` record was therefore never moved
  to a terminal status, stranding every server-side node failure as a
  phantom `running` that nothing reaps (`jobs ls` only reaps non-terminal
  records with a dead watcher_pid, which --wait never sets). Catch the exit
  and finalize to error/cancelled, carrying the classified verdict that
  on_error now stashes on the execution.

- The completion rendering/emit sat inside the try that catches
  (WebSocketException, ConnectionError, OSError). A BrokenPipeError from a
  closed stdout (`comfy run --wait … | head`) is an OSError subclass, so it
  rewrote the just-persisted `completed` record to error/server_died,
  printed a false "Lost connection", and exited 1. Build the payload inside
  the try, render it after.

- _mark_cancelled no longer walks an already-terminal record backwards: a
  Ctrl-C landing after the completion write must not turn `completed` into
  `cancelled`.

- Report `state_file` only when the terminal write actually landed; the
  submit-time fallback pointed at a file whose contents still said
  `running`, contradicting the reported status. Drops the now-dead
  wait_state_file assignments with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mattmillerai

Copy link
Copy Markdown
Collaborator Author

🤖 The reviews loop filed Linear follow-up ticket(s) for review thread(s) deferred as out of scope for this PR:

  • BE-4794 — Strip ANSI/control sequences from server-supplied text at the pretty renderer boundary
  • BE-4795 — Exit 0 on BrokenPipeError at the CLI entrypoint so comfy … | head doesn't abort

@skishore23 skishore23 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM

@dosubot dosubot Bot added the lgtm This PR has been approved by a maintainer label Jul 28, 2026
@mattmillerai
mattmillerai merged commit e6eb54c into main Jul 28, 2026
17 checks passed
@mattmillerai
mattmillerai deleted the matt/be-4750-wait-state-file branch July 28, 2026 04:56
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 28, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

agent-coded PR authored by the agent-work loop cursor-review Request Cursor bot review lgtm This PR has been approved by a maintainer size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants