Skip to content

[Bug]: Framework-written FAILED status drops status.timestamp, persisting last_updated as NULL #1331

Description

@Linux2010

What happened?

When the framework itself writes a terminal FAILED state (consumer failure, producer failure), the resulting TaskStatus carries no timestamp. Because TaskManager.save_task_event replaces the status wholesale via task.status.CopyFrom(event.status) (src/a2a/server/tasks/task_manager.py:265), the previously stamped status.timestamp is cleared, and DatabaseTaskStore.save derives last_updated from status.timestamp (src/a2a/server/tasks/database_task_store.py:152-156) — persisting NULL.

Consequences:

  • ListTasks ordering (which sorts on last_updated) drops the task to the end;
  • the status_timestamp_after filter never matches the task again;
  • API responses lose the status.timestamp field entirely.

The behavior is also internally inconsistent: the cancel-failure path _mark_task_as_failed (src/a2a/server/agent_execution/active_task.py:945-963) copies the existing status and only overwrites state (preserving the timestamp), while the consumer failure path _write_failed_status (active_task.py:161-169) and the producer failure path construct a fresh TaskStatus(state=...) with no timestamp.

Reproduction

Self-contained script (main @ 494a8ec, Python 3.10)
import asyncio

from a2a.auth.user import UnauthenticatedUser
from a2a.helpers.proto_helpers import new_task_from_user_message
from a2a.server.agent_execution import AgentExecutor
from a2a.server.context import ServerCallContext
from a2a.server.events import EventQueue
from a2a.server.request_handlers import DefaultRequestHandlerV2
from a2a.server.tasks import InMemoryTaskStore
from a2a.types.a2a_pb2 import (
    AgentCapabilities,
    AgentCard,
    Message,
    Part,
    Role,
    SendMessageConfiguration,
    SendMessageRequest,
    TaskState,
)


class ExplodingExecutor(AgentExecutor):
    task_id = None

    async def execute(self, context, event_queue):
        task = new_task_from_user_message(context.message)
        self.task_id = task.id
        await event_queue.enqueue_event(task)
        raise RuntimeError('agent crashed mid-flight')

    async def cancel(self, context, event_queue):
        pass


async def main():
    task_store = InMemoryTaskStore()
    executor = ExplodingExecutor()
    handler = DefaultRequestHandlerV2(
        agent_executor=executor,
        task_store=task_store,
        agent_card=AgentCard(
            name='t',
            version='1.0',
            capabilities=AgentCapabilities(streaming=True),
        ),
    )
    params = SendMessageRequest(
        message=Message(
            role=Role.ROLE_USER, message_id='m1', parts=[Part(text='Hi')]
        ),
        configuration=SendMessageConfiguration(
            accepted_output_modes=['text/plain']
        ),
    )
    try:
        await handler.on_message_send(
            params, ServerCallContext(user=UnauthenticatedUser())
        )
    except Exception as e:
        print(f'on_message_send raised {type(e).__name__} (expected)')
    task = await task_store.get(
        executor.task_id, ServerCallContext(user=UnauthenticatedUser())
    )
    print(
        f'persisted: state={TaskState.Name(task.status.state)}, '
        f'timestamp set={task.status.HasField("timestamp")}'
    )


asyncio.run(main())

Observed output:

on_message_send raised RuntimeError (expected)
persisted: state=TASK_STATE_FAILED, timestamp set=False

Expected behavior

Every task state transition the framework writes — including failures — should carry a status.timestamp, matching what TaskUpdater.update_status does for agent-driven transitions.

Suggested fix

Either:

  • stamp the current time in the three framework failure paths (_write_failed_status, the producer failure path, and — for consistency — _mark_task_as_failed), or
  • in TaskManager.save_task_event, preserve the previous status.timestamp when the incoming status event has none.

Happy to submit a PR with tests.

Related

Observed as a secondary effect in the reproduction of #1313.

Code of Conduct

  • I agree to follow this project's Code of Conduct
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions