bug(cursor-cli-adapter): silently marks task "done" when docker exec fails before stream-json starts #1036

Closed
opened 2026-05-10 11:41:27 +00:00 by claude-desktop · 1 comment
Collaborator

As a dispatch operator,
I want the cursor-cli-adapter to surface in-container exec failures as a cursor_cli_error system event,
so that the runner flags the task instead of marking it "done" with empty output.

Context

PR #1035 fixed a Dockerfile bug where cursor-agent couldn't run inside the container (/usr/local/bin/node: No such file or directory). The diagnosis was made harder than it should have been because every dispatch silently logged done — task completed while the in-container CLI was actually exiting before any output. No SSE error, no failure marker on the task row, no operator-visible signal — just a string of empty completions.

Repro

  1. Set CLAUDE_HOOKS_CONTAINER_BIN to a non-existent path inside the container (or remove cursor-agent from the image).
  2. Dispatch any provider: cursor task.
  3. Observe service log:
    [cursor-cli-adapter] spawning: <shim> (cwd=…, model=composer-2, resume=<none>)
    [<agent>] <task-id>: done — task completed
    
  4. No cursor_cli_error system event reaches the SSE bus; task is marked completed with empty result text.

Root cause

apps/server/src/infrastructure/agent/cursor-cli-adapter.ts (around runTask):

  • The cursor_cli_error system event is only emitted when proc.exited is non-zero AND the abort signal hasn't fired AND the read loop has finished — but the docker-exec exec-fail path causes proc.exited to resolve to non-zero (e.g. 126/127) with no stream-json events at all. The current branch:
    if (exitCode !== 0 && !req.abort.signal.aborted) {
      const stderrText = await new Response(proc.stderr).text();
      yield { type: "system", subtype: "cursor_cli_error",  };
    }
    
    should fire here. The bug is that no result event is ever yielded before this point, so runWithSessionResume (or whatever wraps the iterable) doesn't see a final result and treats the iterator-end as success, not failure.
  • cursor_cli_error is a system event, not a result event — the consumer's "did this run end ok?" check only looks at the result event, so the system error is logged but doesn't influence the task outcome.

Acceptance criteria

Adapter

  • When the spawned process exits non-zero AND no result event has been emitted yet, the adapter synthesises a final result event with ok: false, subtype: "exec_error", and errors: [<stderr tail>] (in addition to the existing cursor_cli_error system event for log breadcrumb).
  • When the spawned process exits zero but no result event was emitted (cursor crashed mid-stream without emitting one), same synthetic failure result — never let "no result" mean "success".
  • Stderr tail captured at the moment of exit (not lazily on next read) so it reflects the actual failure message, capped at e.g. 500 chars.

Tests

  • Unit: the adapter's exit branch synthesises a non-ok result event when no result event preceded it. Mock the spawn boundary to emit only system{init} then exit 127.
  • Unit: a normal result event from cursor passes through unchanged (no double-emit).
  • Unit: abort path doesn't synthesise a result (existing behaviour preserved).

Operator-visible

  • Dispatched task row in tasks table has success: false + the exec error string in its result column.
  • SSE bus carries the failure event so the dashboard column flips red instead of green.

Out of scope

  • Retry / re-dispatch policy on exec failure (separate watchdog concern).
  • Renaming cursor_cli_error system event for parity with the claude adapter.
  • Validating in-container binary presence at boot (separate hardening — the runtime check above is the load-bearing fix).

References

  • PR #1035 (Dockerfile fix that exposed this bug)
  • apps/server/src/infrastructure/agent/cursor-cli-adapter.ts runTask exit branch
  • PR #1032 introduced the adapter
**As a** dispatch operator, **I want** the cursor-cli-adapter to surface in-container exec failures as a `cursor_cli_error` system event, **so that** the runner flags the task instead of marking it "done" with empty output. ## Context PR #1035 fixed a Dockerfile bug where `cursor-agent` couldn't run inside the container (`/usr/local/bin/node: No such file or directory`). The diagnosis was made *harder* than it should have been because every dispatch silently logged `done — task completed` while the in-container CLI was actually exiting before any output. No SSE error, no failure marker on the task row, no operator-visible signal — just a string of empty completions. ## Repro 1. Set `CLAUDE_HOOKS_CONTAINER_BIN` to a non-existent path inside the container (or remove `cursor-agent` from the image). 2. Dispatch any `provider: cursor` task. 3. Observe service log: ``` [cursor-cli-adapter] spawning: <shim> (cwd=…, model=composer-2, resume=<none>) [<agent>] <task-id>: done — task completed ``` 4. No `cursor_cli_error` system event reaches the SSE bus; task is marked completed with empty result text. ## Root cause `apps/server/src/infrastructure/agent/cursor-cli-adapter.ts` (around `runTask`): - The `cursor_cli_error` system event is only emitted when `proc.exited` is non-zero AND the abort signal hasn't fired AND the read loop has finished — but the docker-exec exec-fail path causes `proc.exited` to resolve to non-zero (e.g. 126/127) with no stream-json events at all. The current branch: ```ts if (exitCode !== 0 && !req.abort.signal.aborted) { const stderrText = await new Response(proc.stderr).text(); yield { type: "system", subtype: "cursor_cli_error", … }; } ``` *should* fire here. The bug is that **no `result` event is ever yielded** before this point, so `runWithSessionResume` (or whatever wraps the iterable) doesn't see a final `result` and treats the iterator-end as success, not failure. - `cursor_cli_error` is a `system` event, not a `result` event — the consumer's "did this run end ok?" check only looks at the result event, so the system error is logged but doesn't influence the task outcome. ## Acceptance criteria ### Adapter - [ ] When the spawned process exits non-zero AND no `result` event has been emitted yet, the adapter synthesises a final `result` event with `ok: false`, `subtype: "exec_error"`, and `errors: [<stderr tail>]` (in addition to the existing `cursor_cli_error` system event for log breadcrumb). - [ ] When the spawned process exits zero but no `result` event was emitted (cursor crashed mid-stream without emitting one), same synthetic failure result — never let "no result" mean "success". - [ ] Stderr tail captured at the moment of exit (not lazily on next read) so it reflects the actual failure message, capped at e.g. 500 chars. ### Tests - [ ] Unit: the adapter's exit branch synthesises a non-ok `result` event when no `result` event preceded it. Mock the spawn boundary to emit only `system{init}` then exit 127. - [ ] Unit: a normal `result` event from cursor passes through unchanged (no double-emit). - [ ] Unit: abort path doesn't synthesise a result (existing behaviour preserved). ### Operator-visible - [ ] Dispatched task row in `tasks` table has `success: false` + the exec error string in its result column. - [ ] SSE bus carries the failure event so the dashboard column flips red instead of green. ## Out of scope - Retry / re-dispatch policy on exec failure (separate watchdog concern). - Renaming `cursor_cli_error` system event for parity with the claude adapter. - Validating in-container binary presence at boot (separate hardening — the runtime check above is the load-bearing fix). ## References - PR #1035 (Dockerfile fix that exposed this bug) - `apps/server/src/infrastructure/agent/cursor-cli-adapter.ts` `runTask` exit branch - PR #1032 introduced the adapter
Collaborator

🦵 @charles kicked the queue — re-running implement on @code-lead.

🦵 @charles kicked the queue — re-running implement on @code-lead.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
charles/claude-hooks#1036
No description provided.