Repository: celery/celery

Issue:
task_id must not be empty with chain as body of a chord
<!--
Please fill this template entirely and do not erase parts of it.
We reserve the right to close without a response
bug reports which are incomplete.
-->
# Checklist
<!--
To check an item on the list replace [ ] with [x].
-->
- [x] I have verified that the issue exists against the `main` branch of Celery.
- [x] This has already been asked to the [discussions forum](https://github.com/celery/celery/discussions) first.
- [x] I have read the relevant section in the
  [contribution guide](https://docs.celeryq.dev/en/main/contributing.html#other-bugs)
  on reporting bugs.
- [x] I have checked the [issues list](https://github.com/celery/celery/issues?q=is%3Aissue+label%3A%22Issue+Type%3A+Bug+Report%22+-label%3A%22Category%3A+Documentation%22)
  for similar or identical bug reports.
- [x] I have checked the [pull requests list](https://github.com/celery/celery/pulls?q=is%3Apr+label%3A%22PR+Type%3A+Bugfix%22+-label%3A%22Category%3A+Documentation%22)
  for existing proposed fixes.
- [x] I have checked the [commit log](https://github.com/celery/celery/commits/main)
  to find out if the bug was already fixed in the main branch.
- [x] I have included all related issues and possible duplicate issues
  in this issue (If there are none, check this box anyway).
- [x] I have tried to reproduce the issue with [pytest-celery](https://docs.celeryq.dev/projects/pytest-celery/en/latest/userguide/celery-bug-report.html) and added the reproduction script below.

## Mandatory Debugging Information

- [x] I have included the output of ``celery -A proj report`` in the issue.
    (if you are not able to do this, then at least specify the Celery
     version affected).
- [x] I have verified that the issue exists against the `main` branch of Celery.
- [x] I have included the contents of ``pip freeze`` in the issue.
- [x] I have included all the versions of all the external dependencies required
  to reproduce this bug.

## Optional Debugging Information
<!--
Try some of the below if you think they are relevant.
It will help us figure out the scope of the bug and how many users it affects.
-->
- [ ] I have tried reproducing the issue on more than one Python version
  and/or implementation.
- [ ] I have tried reproducing the issue on more than one message broker and/or
  result backend.
- [ ] I have tried reproducing the issue on more than one version of the message
  broker and/or result backend.
- [ ] I have tried reproducing the issue on more than one operating system.
- [ ] I have tried reproducing the issue on more than one workers pool.
- [ ] I have tried reproducing the issue with autoscaling, retries,
  ETA/Countdown & rate limits disabled.
- [ ] I have tried reproducing the issue after downgrading
  and/or upgrading Celery and its dependencies.

## Related Issues and Possible Duplicates
<!--
Please make sure to search and mention any related issues
or possible duplicates to this issue as requested by the checklist above.

This may or may not include issues in other repositories that the Celery project
maintains or other repositories that are dependencies of Celery.

If you don't know how to mention issues, please refer to Github's documentation
on the subject: https://help.github.com/en/articles/autolinked-references-and-urls#issues-and-pull-requests
-->

#### Related Issues

- #8578
- #8816

#### Possible Duplicates

- None

## Environment & Settings
<!-- Include the contents of celery --version below -->
**Celery version**:
5.5.3
<!-- Include the output of celery -A proj report below -->
<details>
<summary><b><code>celery report</code> Output:</b></summary>
<p>

```
software -> celery:5.5.3 (immunity) kombu:5.5.4 py:3.11.4
            billiard:4.2.1 py-amqp:5.3.1
platform -> system:Darwin arch:64bit
            kernel version:24.5.0 imp:CPython
loader   -> celery.loaders.default.Loader
settings -> transport:amqp results:disabled

deprecated_settings: None

```

</p>
</details>

# Steps to Reproduce

## Required Dependencies
<!-- Please fill the required dependencies to reproduce this issue -->
- **Minimal Python Version**: N/A or Unknown
- **Minimal Celery Version**: N/A or Unknown
- **Minimal Kombu Version**: N/A or Unknown
- **Minimal Broker Version**: N/A or Unknown
- **Minimal Result Backend Version**: N/A or Unknown
- **Minimal OS and/or Kernel Version**: N/A or Unknown
- **Minimal Broker Client Version**: N/A or Unknown
- **Minimal Result Backend Client Version**: N/A or Unknown

### Python Packages
<!-- Please fill the contents of pip freeze below -->
<details>
<summary><b><code>pip freeze</code> Output:</b></summary>
<p>

```
amqp==5.3.1
billiard==4.2.1
boto3==1.38.40
botocore==1.38.40
celery==5.5.3
certifi==2025.6.15
charset-normalizer==3.4.2
click==8.2.1
click-didyoumean==0.3.1
click-plugins==1.1.1
click-repl==0.3.0
debugpy==1.8.14
docker==7.1.0
idna==3.10
iniconfig==2.1.0
jmespath==1.0.1
kombu==5.5.4
packaging==25.0
pluggy==1.6.0
prompt_toolkit==3.0.51
psutil==7.0.0
Pygments==2.19.1
pytest==8.4.1
pytest-celery==1.2.0
pytest_docker_tools==3.1.9
python-dateutil==2.9.0.post0
python-memcached==1.62
redis==6.2.0
requests==2.32.4
s3transfer==0.13.0
six==1.17.0
tenacity==9.1.2
tzdata==2025.2
urllib3==1.26.20
vine==5.1.0
wcwidth==0.2.13
```

</p>
</details>

### Other Dependencies
<!--
Please provide system dependencies, configuration files
and other dependency information if applicable
-->
<details>
<p>
N/A
</p>
</details>

## Minimally Reproducible Test Case
<!--
Please provide a reproducible test case.
Refer to the Reporting Bugs section in our contribution guide.

We prefer submitting test cases in the form of a PR to our integration test suite.
If you can provide one, please mention the PR number below.
If not, please attach the most minimal code example required to reproduce the issue below.
If the test case is too large, please include a link to a gist or a repository below.

Alternatively, the pytest-celery plugin can be used to create standalone reproduction scripts
that can be added to this report. See the pytest-celery documentation for more information at
pytest-celery.readthedocs.io
-->

<details>
<p>

### standalone reproduction scripts using `pytest-celery` [link](https://docs.celeryq.dev/projects/pytest-celery/en/latest/userguide/celery-bug-report.html)

Create 3 files named:
- requirements-pytest-celery.txt
- bug_task.py
- test_celery_bug_report.py

With the following content, and then run the below commands in the shell

```shell
python -m venv .venv

source .venv/bin/activate

pip install -r requirements-pytest-celery.txt

pytest test_celery_bug_report.py
```

```txt
# requirements-pytest-celery.txt
pytest-celery[all]>=1.2.0
pytest>=7.0.0
```

```python
# bug_task.py

"""
Bug reproduction tasks for the pytest-celery bug report.

These tasks are used to reproduce the ValueError: task_id must not be empty
that occurs when a group with a failing task is part of a chain.
"""

import time
from celery import shared_task


@shared_task
def add(x, y):
    """Simple addition task with sleep to simulate work"""
    print(f"Adding {x} + {y}")
    time.sleep(1)
    result = x + y
    print(f"Result: {result}")
    return result


@shared_task
def failc():
    """Task that always fails to trigger the bug"""
    print("About to fail...")
    raise ValueError("failc - intentional failure")
```

```python
# test_celery_bug_report.py

# flake8: noqa
"""
Pytest-Celery Bug Report

This test reproduces the ValueError: task_id must not be empty. Got None instead.
that occurs when a task in a group fails and the group is part of a chain.

To run:
1. Install pytest-celery: pip install -U "pytest-celery[all]"
2. Run: pytest test_celery_bug_report.py

The plugin will automatically:
- Start a Redis Docker container for broker and backend
- Start a Celery worker Docker container
- Run the test and clean up afterward

Docs: https://docs.celeryq.dev/projects/pytest-celery/en/latest/userguide/celery-bug-report.html
"""

from __future__ import annotations

import pytest
import time
from celery import Celery, chain, group
from celery.canvas import Signature
from celery.result import AsyncResult

from pytest_celery import CeleryBackendCluster
from pytest_celery import CeleryBrokerCluster
from pytest_celery import CeleryTestSetup
from pytest_celery import RedisTestBackend
from pytest_celery import RedisTestBroker

###############################################################################
# Redis Broker Setup
###############################################################################

@pytest.fixture
def celery_broker_cluster(celery_redis_broker: RedisTestBroker) -> CeleryBrokerCluster:
    cluster = CeleryBrokerCluster(celery_redis_broker)
    yield cluster
    cluster.teardown()

@pytest.fixture
def default_redis_broker_image() -> str:
    return "redis:latest"

###############################################################################
# Redis Backend Setup
###############################################################################

@pytest.fixture
def celery_backend_cluster(celery_redis_backend: RedisTestBackend) -> CeleryBackendCluster:
    cluster = CeleryBackendCluster(celery_redis_backend)
    yield cluster
    cluster.teardown()

@pytest.fixture
def default_redis_backend_image() -> str:
    return "redis:latest"

###############################################################################
# Worker Configuration
###############################################################################

@pytest.fixture(scope="session")
def default_worker_celery_version() -> str:
    return ""  # Use latest version

@pytest.fixture(scope="session")
def default_worker_celery_log_level() -> str:
    return "INFO"

@pytest.fixture(scope="session")
def default_worker_celery_worker_queue() -> str:
    return "celery"

@pytest.fixture
def default_worker_command(default_worker_command: list[str]) -> list[str]:
    return default_worker_command + [
        "--without-gossip",
        "--without-mingle",
        "--without-heartbeat",
    ]

@pytest.fixture
def default_worker_app(default_worker_app: Celery) -> Celery:
    app = default_worker_app
    # Configure app for bug reproduction
    app.conf.update(
        task_serializer='json',
        accept_content=['json'],
        result_serializer='json',
        timezone='UTC',
        enable_utc=True,
        task_track_started=True,
        task_always_eager=False,
    )
    return app

###############################################################################
# Custom Tasks for Bug Reproduction
###############################################################################

import bug_tasks

@pytest.fixture
def default_worker_tasks(default_worker_tasks: set) -> set:
    default_worker_tasks.add(bug_tasks)
    return default_worker_tasks

###############################################################################
# Bug Reproduction Test
###############################################################################

def test_group_chain_failure_bug(celery_setup: CeleryTestSetup, caplog: pytest.LogCaptureFixture):
    """
    Reproduces the bug: ValueError: task_id must not be empty. Got None instead.
    
    When a group containing a failing task is part of a chain with additional tasks,
    instead of proper error propagation, a ValueError is raised.
    """
    from bug_tasks import add, failc

    # This chain triggers the bug:
    # - group contains one successful and one failing task
    # - followed by two more tasks in the chain
    mychain = chain(
        group([add.si(15, 7), failc.si()]),
        add.si(9, 7),
        add.si(5, 7)
    )
    
    _ = mychain.apply_async(queue=celery_setup.worker.worker_queue)
    
    time.sleep(10) 
    
    assert (
        "ValueError: task_id must not be empty. Got None instead" not in celery_setup.worker.logs()
    ), "Expected task error, not celery ValueError on task_id being None"
```

</p>
</details>

# Expected Behavior
<!-- Describe in detail what you expect to happen -->

I'm encountering a `ValueError: task_id must not be empty. Got None instead.` when using chords with a specific pattern: a group containing both succeeding and failing tasks, followed by a chain body with multiple tasks.

This issue was previously reported in above discussions, and there was an attempt to fix it, but the issue still persists in the latest version (5.5.3).

The chain should fail with the exception from `failc()`, allowing proper error handling.

# Actual Behavior
<!--
Describe in detail what actually happened.
Please include a backtrace and surround it with triple backticks (```).
In addition, include the Celery daemon logs, the broker logs,
the result backend logs and system logs below if they will help us debug
the issue.
-->

The following error occurs:

```
 -------------- celery@Diegos-MacBook-Pro.local v5.5.3 (immunity)
--- ***** ----- 
-- ******* ---- macOS-15.5-arm64-arm-64bit 2025-06-19 23:39:11
- *** --- * --- 
- ** ---------- [config]
- ** ---------- .> app:         celery_failing:0x104befdd0
- ** ---------- .> transport:   redis://localhost:6379//
- ** ---------- .> results:     redis://localhost:6379/
- *** --- * --- .> concurrency: 10 (prefork)
-- ******* ---- .> task events: OFF (enable -E to monitor tasks in this worker)
--- ***** ----- 
 -------------- [queues]
                .> celery           exchange=celery(direct) key=celery
                

[tasks]
  . tasks.add
  . tasks.failc

[2025-06-19 23:39:11,730: INFO/MainProcess] Connected to redis://localhost:6379//
[2025-06-19 23:39:11,750: INFO/MainProcess] mingle: searching for neighbors
[2025-06-19 23:39:12,789: INFO/MainProcess] mingle: all alone
[2025-06-19 23:39:12,824: INFO/MainProcess] celery@Diegos-MacBook-Pro.local ready.
[2025-06-19 23:39:16,731: INFO/MainProcess] Task tasks.add[2069eb9f-b57e-48e4-98d8-595efd5f5922] received
[2025-06-19 23:39:16,735: INFO/MainProcess] Task tasks.failc[f9032a00-4ee6-4430-bf27-eb04f3d66cd7] received
[2025-06-19 23:39:16,748: ERROR/ForkPoolWorker-1] Task tasks.failc[f9032a00-4ee6-4430-bf27-eb04f3d66cd7] raised unexpected: Exception('failc')
Traceback (most recent call last):
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/app/trace.py", line 453, in trace_task
    R = retval = fun(*args, **kwargs)
                 ^^^^^^^^^^^^^^^^^^^^
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/app/trace.py", line 736, in __protected_call__
    return self.run(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/diego/Development/my_celery_project/tasks.py", line 13, in failc
    raise Exception("failc")
Exception: failc
[2025-06-19 23:39:19,750: ERROR/ForkPoolWorker-8] Chord 'e0e30bbb-f009-455e-9cdd-3bb1df2e6619' raised: ChordError("Dependency f9032a00-4ee6-4430-bf27-eb04f3d66cd7 raised Exception('failc')")
Traceback (most recent call last):
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/backends/redis.py", line 533, in on_chord_part_return
    resl = [unpack(tup, decode) for tup in resl]
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/backends/redis.py", line 533, in <listcomp>
    resl = [unpack(tup, decode) for tup in resl]
            ^^^^^^^^^^^^^^^^^^^
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/backends/redis.py", line 439, in _unpack_chord_result
    raise ChordError(f'Dependency {tid} raised {retval!r}')
celery.exceptions.ChordError: Dependency f9032a00-4ee6-4430-bf27-eb04f3d66cd7 raised Exception('failc')
[2025-06-19 23:39:19,755: WARNING/ForkPoolWorker-8] /Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/app/trace.py:662: RuntimeWarning: Exception raised outside body: ValueError('task_id must not be empty. Got None instead.'):
Traceback (most recent call last):
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/backends/redis.py", line 533, in on_chord_part_return
    resl = [unpack(tup, decode) for tup in resl]
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/backends/redis.py", line 533, in <listcomp>
    resl = [unpack(tup, decode) for tup in resl]
            ^^^^^^^^^^^^^^^^^^^
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/backends/redis.py", line 439, in _unpack_chord_result
    raise ChordError(f'Dependency {tid} raised {retval!r}')
celery.exceptions.ChordError: Dependency f9032a00-4ee6-4430-bf27-eb04f3d66cd7 raised Exception('failc')

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/app/trace.py", line 520, in trace_task
    task.backend.mark_as_done(
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/backends/base.py", line 159, in mark_as_done
    self.on_chord_part_return(request, state, result)
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/backends/redis.py", line 554, in on_chord_part_return
    return self.chord_error_from_stack(callback, exc)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/backends/base.py", line 304, in chord_error_from_stack
    return backend.fail_from_current_stack(callback.id, exc=exc)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/backends/base.py", line 311, in fail_from_current_stack
    self.mark_as_failure(task_id, exc, exception_info.traceback)
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/backends/base.py", line 167, in mark_as_failure
    self.store_result(task_id, exc, state,
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/backends/base.py", line 526, in store_result
    self._store_result(task_id, result, state, traceback,
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/backends/base.py", line 975, in _store_result
    current_meta = self._get_task_meta_for(task_id)
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/backends/base.py", line 997, in _get_task_meta_for
    meta = self.get(self.get_key_for_task(task_id))
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/backends/base.py", line 871, in get_key_for_task
    raise ValueError(f'task_id must not be empty. Got {task_id} instead.')
ValueError: task_id must not be empty. Got None instead.

  warn(RuntimeWarning(

[2025-06-19 23:39:19,760: ERROR/ForkPoolWorker-8] Task tasks.add[2069eb9f-b57e-48e4-98d8-595efd5f5922] raised unexpected: ValueError('task_id must not be empty. Got None instead.')
Traceback (most recent call last):
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/backends/redis.py", line 533, in on_chord_part_return
    resl = [unpack(tup, decode) for tup in resl]
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/backends/redis.py", line 533, in <listcomp>
    resl = [unpack(tup, decode) for tup in resl]
            ^^^^^^^^^^^^^^^^^^^
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/backends/redis.py", line 439, in _unpack_chord_result
    raise ChordError(f'Dependency {tid} raised {retval!r}')
celery.exceptions.ChordError: Dependency f9032a00-4ee6-4430-bf27-eb04f3d66cd7 raised Exception('failc')

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/app/trace.py", line 520, in trace_task
    task.backend.mark_as_done(
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/backends/base.py", line 159, in mark_as_done
    self.on_chord_part_return(request, state, result)
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/backends/redis.py", line 554, in on_chord_part_return
    return self.chord_error_from_stack(callback, exc)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/backends/base.py", line 304, in chord_error_from_stack
    return backend.fail_from_current_stack(callback.id, exc=exc)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/backends/base.py", line 311, in fail_from_current_stack
    self.mark_as_failure(task_id, exc, exception_info.traceback)
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/backends/base.py", line 167, in mark_as_failure
    self.store_result(task_id, exc, state,
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/backends/base.py", line 526, in store_result
    self._store_result(task_id, result, state, traceback,
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/backends/base.py", line 975, in _store_result
    current_meta = self._get_task_meta_for(task_id)
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/backends/base.py", line 997, in _get_task_meta_for
    meta = self.get(self.get_key_for_task(task_id))
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/diego/Development/my_celery_project/.venv/lib/python3.11/site-packages/celery/backends/base.py", line 871, in get_key_for_task
    raise ValueError(f'task_id must not be empty. Got {task_id} instead.')
ValueError: task_id must not be empty. Got None instead.
```

### Root cause analysis

After debugging with rdb breakpoints, I found that the issue occurs in the `chord.run()` method in `celery/canvas.py`. When a chord body is frozen, it's currently using `task_id` instead of `group_id`:

```python
# Line ~2261 in celery/canvas.py
bodyres = body.freeze(task_id, root_id=root_id)
```

The problem is:
1. `task_id` can be `None` or an external chord task ID
2. When error propagation occurs, it looks for `callback.id` to handle the error
3. Since the body was frozen with `None` or wrong ID, `callback.id` is `None` in redis backend file inside `on_chord_part_return`method
4. This causes the "task_id must not be empty" error when trying to store the failure

The fix should be to use `group_id` instead:

```python
bodyres = body.freeze(group_id, root_id=root_id)
```

This creates the proper semantic relationship between the group and its callback, allowing error handling to work correctly.

I have a working fix and tests ready in the linked pull request.



Discussion:

Patch:
diff --git a/celery/canvas.py b/celery/canvas.py
index da395c1390e..1ceeacc166d 100644
--- a/celery/canvas.py
+++ b/celery/canvas.py
@@ -2234,7 +2234,8 @@ def run(self, header, body, partial_args, app=None, interval=None,
             options.pop('task_id', None)
             body.options.update(options)
 
-        bodyres = body.freeze(task_id, root_id=root_id)
+        body_task_id = task_id or uuid()
+        bodyres = body.freeze(body_task_id, group_id=group_id, root_id=root_id)
 
         # Chains should not be passed to the header tasks. See #3771
         options.pop('chain', None)


Test patch:
diff --git a/t/integration/conftest.py b/t/integration/conftest.py
index 1707e3ca324..61b5ff85397 100644
--- a/t/integration/conftest.py
+++ b/t/integration/conftest.py
@@ -1,5 +1,7 @@
 import json
 import os
+import re
+import time
 
 import pytest
 
@@ -25,6 +27,32 @@ def get_active_redis_channels():
     return get_redis_connection().execute_command('PUBSUB CHANNELS')
 
 
+def check_for_logs(
+    caplog,
+    message: str,
+    max_wait: float = 1.0,
+    interval: float = 0.1
+) -> bool:
+    """Check if a specific message exists in the logs.
+
+    Args:
+        caplog: The pytest caplog fixture
+        message: The message to look for, can be a regex pattern
+        max_wait: Maximum time to wait for the log message (in seconds)
+        interval: Time between log checks (in seconds)
+
+    Returns:
+        bool: True if the message was found, False otherwise
+    """
+    start_time = time.monotonic()
+    while time.monotonic() - start_time < max_wait:
+        # Check if the message is in the logs
+        if any(re.search(message, record.message) for record in caplog.records):
+            return True
+        time.sleep(interval)
+    return False
+
+
 @pytest.fixture(scope='session')
 def celery_config(request):
     config = {
diff --git a/t/integration/test_canvas.py b/t/integration/test_canvas.py
index ed838dc6730..fb544c05471 100644
--- a/t/integration/test_canvas.py
+++ b/t/integration/test_canvas.py
@@ -16,7 +16,7 @@
 from celery.signals import before_task_publish, task_received
 
 from . import tasks
-from .conftest import TEST_BACKEND, get_active_redis_channels, get_redis_connection
+from .conftest import TEST_BACKEND, check_for_logs, get_active_redis_channels, get_redis_connection
 from .tasks import (ExpectedException, StampOnReplace, add, add_chord_to_chord, add_replaced, add_to_all,
                     add_to_all_to_chord, build_chain_inside_task, collect_ids, delayed_sum,
                     delayed_sum_with_soft_guard, errback_new_style, errback_old_style, fail, fail_replaced, identity,
@@ -3151,6 +3151,93 @@ def test_upgraded_chord_link_error_with_header_errback_enabled(self, manager, su
 
         redis_connection.delete(errback_key, body_key)
 
+    @flaky
+    @pytest.mark.parametrize(
+        "input_body",
+        [
+            (lambda: add.si(9, 7)),
+            (
+                lambda: chain(
+                    add.si(9, 7),
+                    add.si(5, 7),
+                )
+            ),
+            pytest.param(
+                (
+                    lambda: group(
+                        [
+                            add.si(9, 7),
+                            add.si(5, 7),
+                        ]
+                    )
+                ),
+                marks=pytest.mark.skip(reason="Task times out"),
+            ),
+            (
+                lambda: chord(
+                    group(
+                        [
+                            add.si(1, 1),
+                            add.si(2, 2),
+                        ]
+                    ),
+                    add.si(10, 10),
+                )
+            ),
+        ],
+        ids=[
+            "body is a single_task",
+            "body is a chain",
+            "body is a group",
+            "body is a chord",
+        ],
+    )
+    def test_chord_error_propagation_with_different_body_types(
+        self, manager, caplog, input_body
+    ) -> None:
+        """Integration test for issue #9773: task_id must not be empty on chain of groups.
+
+        This test reproduces the exact scenario from GitHub issue #9773 where a chord
+        with a failing group task and a chain body causes a ValueError during error handling.
+
+        The test verifies that:
+        1. The chord executes without the "task_id must not be empty" error
+        2. The failure from the group properly propagates to the chain body
+        3. Error handling works correctly with proper task IDs
+
+        Args:
+            input_body (callable): A callable that returns a Celery signature for the body of the chord.
+        """
+        try:
+            manager.app.backend.ensure_chords_allowed()
+        except NotImplementedError as e:
+            raise pytest.skip(e.args[0])
+
+        # Create the failing group header (same for all tests)
+        failing_chord = chain(
+            group(
+                [
+                    add.si(15, 7),
+                    # failing task
+                    fail.si(),
+                ]
+            ),
+            # dynamic parametrized body
+            input_body(),
+        )
+
+        result = failing_chord.apply_async()
+
+        # The chain should fail due to the failing task in the group
+        with pytest.raises(ExpectedException):
+            result.get(timeout=TIMEOUT)
+
+        # Verify that error propagation worked correctly without the task_id error
+        # This test passes if no "task_id must not be empty" error was logged
+        # Check if the message appears in the logs (it shouldn't)
+        error_found = check_for_logs(caplog=caplog, message="ValueError: task_id must not be empty")
+        assert not error_found, "The 'task_id must not be empty' error was found in the logs"
+
 
 class test_signature_serialization:
     """
diff --git a/t/smoke/tests/test_canvas.py b/t/smoke/tests/test_canvas.py
index 02fbe9334f8..7750c365ba7 100644
--- a/t/smoke/tests/test_canvas.py
+++ b/t/smoke/tests/test_canvas.py
@@ -103,3 +103,82 @@ def test_sanity(self, celery_setup: CeleryTestSetup):
         )
         res = sig.apply_async(queue=celery_setup.worker.worker_queue)
         assert res.get(timeout=RESULT_TIMEOUT) == ["body_task"] * 3
+
+    @pytest.mark.parametrize(
+        "input_body",
+        [
+            (lambda queue: add.si(9, 7).set(queue=queue)),
+            (
+                lambda queue: chain(
+                    add.si(9, 7).set(queue=queue),
+                    add.si(5, 7).set(queue=queue),
+                )
+            ),
+            pytest.param(
+                (
+                    lambda queue: group(
+                        [
+                            add.si(9, 7).set(queue=queue),
+                            add.si(5, 7).set(queue=queue),
+                        ]
+                    )
+                ),
+                marks=pytest.mark.skip(reason="Task times out"),
+            ),
+            (
+                lambda queue: chord(
+                    group(
+                        [
+                            add.si(1, 1).set(queue=queue),
+                            add.si(2, 2).set(queue=queue),
+                        ]
+                    ),
+                    add.si(10, 10).set(queue=queue),
+                )
+            ),
+        ],
+        ids=[
+            "body is a single_task",
+            "body is a chain",
+            "body is a group",
+            "body is a chord",
+        ],
+    )
+    def test_chord_error_propagation_with_different_body_types(
+        self, celery_setup: CeleryTestSetup, input_body
+    ) -> None:
+        """Reproduce issue #9773 with different chord body types.
+
+        This test verifies that the "task_id must not be empty" error is fixed
+        regardless of the chord body type. The issue occurs when:
+        1. A chord has a group with both succeeding and failing tasks
+        2. The chord body can be any signature type (single task, chain, group, chord)
+        3. When the group task fails, error propagation should work correctly
+
+        Args:
+            input_body (callable): A callable that returns a Celery signature for the chord body.
+        """
+        queue = celery_setup.worker.worker_queue
+
+        # Create the failing group header (same for all tests)
+        failing_group = group(
+            [
+                add.si(15, 7).set(queue=queue),
+                # failing task
+                fail.si().set(queue=queue),
+            ]
+        )
+
+        # Create the chord
+        test_chord = chord(failing_group, input_body(queue))
+
+        result = test_chord.apply_async()
+
+        # The worker should not log the "task_id must not be empty" error
+        celery_setup.worker.assert_log_does_not_exist(
+            "ValueError: task_id must not be empty. Got None instead."
+        )
+
+        # The chord should fail with the expected exception from the failing task
+        with pytest.raises(ExpectedException):
+            result.get(timeout=RESULT_TIMEOUT)
diff --git a/t/unit/tasks/test_canvas.py b/t/unit/tasks/test_canvas.py
index d4ed5e39afd..1eb088f0c51 100644
--- a/t/unit/tasks/test_canvas.py
+++ b/t/unit/tasks/test_canvas.py
@@ -1781,7 +1781,103 @@ def test_link_error_on_chord_header(self, header):
         assert errback == err
         for header_task in c.tasks:
             assert header_task.options['link_error'] == [err.clone(immutable=True)]
-        assert c.body.options['link_error'] == [err]
+        assert c.body.options["link_error"] == [err]
+
+    def test_chord_run_ensures_body_has_valid_task_id(self):
+        """Test that chord.run() ensures body always gets a valid task ID.
+
+        This is the unit test for the fix to issue #9773. The chord body should always
+        be frozen with a valid task ID to prevent "task_id must not be empty" errors.
+        """
+        # Create a chord with header group and body chain
+        header = group([self.add.s(1, 1), self.add.s(2, 2)])
+        body = chain(self.add.s(10, 10), self.add.s(20, 20))
+        test_chord = chord(header, body)
+
+        # Set up specific IDs for testing
+        chord_task_id = "test-chord-id"
+        group_task_id = "test-group-id"
+        header.options["task_id"] = group_task_id
+
+        # Use patch to spy on body.freeze method
+        with patch.object(body, "freeze", wraps=body.freeze) as mock_freeze:
+            test_chord.run(header, body, (), task_id=chord_task_id)
+
+            # Assert that body.freeze was called with the provided task_id and group_id
+            mock_freeze.assert_called_once_with(
+                chord_task_id, group_id=group_task_id, root_id=None
+            )
+
+    def test_chord_run_generates_task_id_when_none_provided(self):
+        """Test that chord.run() generates a task_id when none is provided."""
+        # Create a chord with header group and body chain (no task_id set)
+        header = group([self.add.s(1, 1), self.add.s(2, 2)])
+        body = chain(self.add.s(10, 10), self.add.s(20, 20))
+        test_chord = chord(header, body)
+
+        # Set group ID
+        group_id = "test-group-id"
+        header.options["task_id"] = group_id
+
+        # Use patch to spy on body.freeze method
+        with patch.object(body, "freeze", wraps=body.freeze) as mock_freeze:
+            test_chord.run(header, body, (), task_id=None)
+
+            # Assert that body.freeze was called with a generated UUID and group_id
+            mock_freeze.assert_called_once()
+            args, kwargs = mock_freeze.call_args
+            body_task_id = args[0] if args else kwargs.get("_id")
+            passed_group_id = kwargs.get("group_id")
+
+            # Body should get a unique task ID (not None, not group_id)
+            assert body_task_id is not None
+            assert body_task_id != group_id  # Should be different from group_id
+            assert passed_group_id == group_id  # But should know its group
+
+    def test_chord_run_body_freeze_prevents_task_id_empty_error(self):
+        """Test that proper body.freeze() call prevents 'task_id must not be empty' error.
+
+        This test ensures that when chord body is frozen with a valid task ID,
+        subsequent error handling won't encounter the "task_id must not be empty" error.
+        """
+        # Create chord components
+        header = group([self.add.s(1, 1), self.add.s(2, 2)])
+        body = chain(self.add.s(10, 10), self.add.s(20, 20))
+        test_chord = chord(header, body)
+
+        # Set a group task ID
+        group_id = "test-group-12345"
+        header.options["task_id"] = group_id
+
+        # Run the chord with external task ID
+        external_task_id = "external-task-id"
+        result = test_chord.run(header, body, (), task_id=external_task_id)
+
+        # Verify the frozen result has the external task ID, not group_id
+        assert result.id == external_task_id
+        assert body.id is not None
+        assert result.parent is not None
+
+        # Body should know its group but have its own ID
+        assert body.options.get('group_id') == group_id or body.id != group_id
+
+    def test_chord_run_body_freeze_with_no_external_task_id(self):
+        """Test chord body gets unique ID when no external task_id provided."""
+        header = group([self.add.s(1, 1), self.add.s(2, 2)])
+        body = chain(self.add.s(10, 10), self.add.s(20, 20))
+        test_chord = chord(header, body)
+
+        group_id = "test-group-12345"
+        header.options["task_id"] = group_id
+
+        # Run chord without external task ID
+        result = test_chord.run(header, body, (), task_id=None)
+
+        # Body should get unique ID, different from group_id
+        assert result.id is not None
+        assert result.id != group_id
+        assert body.id is not None
+        assert body.id != group_id
 
 
 class test_maybe_signature(CanvasCase):