Summary
TaskService.cleanupReportedLeafTask runs under the workspace event lock (every runtime caller reaches it through requestReportedTaskCleanupRecheck, which wraps it in workspaceEventLocks.withLock, and from stream-end finalization) and calls WorkspaceService.remove(), which takes the task-tree lifecycle lock: event -> lifecycle. task_send_message nests the other way, withTaskTreeLifecycleLock(taskId, () => workspaceEventLocks.withLock(taskId, ...)): lifecycle -> event. If a reported task's cleanup and a task_send_message to that same task interleave, both can wait on each other's lock.
Surfaced by Codex on #4058 (which only added a live-state recheck inside the existing remove() acquisition and documented the inversion). The ordering itself predates that PR.
Suggested direction
Either run reported-task removal outside the event lock (queue the removal after the event-locked section releases, with the live recheck still inside remove()'s lifecycle lock), or make the send path release the lifecycle lock before taking the event lock. One lock order for every path that touches both.
Generated with xum • Model: anthropic:claude-fable-5-1 • Thinking: xhigh • Cost: $188.37
Summary
TaskService.cleanupReportedLeafTaskruns under the workspace event lock (every runtime caller reaches it throughrequestReportedTaskCleanupRecheck, which wraps it inworkspaceEventLocks.withLock, and from stream-end finalization) and callsWorkspaceService.remove(), which takes the task-tree lifecycle lock: event -> lifecycle.task_send_messagenests the other way,withTaskTreeLifecycleLock(taskId, () => workspaceEventLocks.withLock(taskId, ...)): lifecycle -> event. If a reported task's cleanup and atask_send_messageto that same task interleave, both can wait on each other's lock.Surfaced by Codex on #4058 (which only added a live-state recheck inside the existing
remove()acquisition and documented the inversion). The ordering itself predates that PR.Suggested direction
Either run reported-task removal outside the event lock (queue the removal after the event-locked section releases, with the live recheck still inside
remove()'s lifecycle lock), or make the send path release the lifecycle lock before taking the event lock. One lock order for every path that touches both.Generated with
xum• Model:anthropic:claude-fable-5-1• Thinking:xhigh• Cost:$188.37