orchestrata_ultimate.tools.task_tools

Reference › orchestrata_ultimate › tools › task_tools

This module provides the two task management tools of SPEC-069: TaskStopTool and TaskOutputTool, backed by an in-process TaskManager. They are registered through register_task_tools, by agents.tool_registry, and exported from the package __init__. tests/tools/test_task_tools.py covers them.

The module records a spec fork: the base create, get, list and update tasks that SPEC-069 extends do not exist as tools in this codebase, so TaskManager implements those operations itself as a thread-safe store (create, get, list, update, append_output). It also states that the Redis-cluster store, WebSocket streaming and external alerting the spec describes are not built.

What TaskManager does for real:

TaskStopTool.execute stops by task_id or batch-stops by name with exact or pattern matching through stop_by_pattern; requesting_agent_id is required. TaskOutputTool.execute returns full, tail, line-range or streamed output (OutputMode) or the task status, after a rate-limit check. Both return failed ToolResult values for TaskToolError and other exceptions.

At a glance

Source src/orchestrata_ultimate/tools/task_tools.py
Lines of code 1357
Classes 18
Functions 5
Imports (internal) orchestrata_ultimate.core.base.traces, orchestrata_ultimate.core.tracing.tracer, orchestrata_ultimate.tools.base, orchestrata_ultimate.tools.registry
Imported by orchestrata_ultimate.agents.tool_registry, orchestrata_ultimate.tools
Tests that import it tests/tools/test_task_tools.py

Class diagram

Class diagram orchestrata_ultimate.tools.task_tools

Dependency diagram

Dependency diagram orchestrata_ultimate.tools.task_tools

API

Constants

Name Type Value
_TASK_STOP_PARAMETERS dict[str, Any] {'type': 'object', 'properties': {'task_id': {'type': 'string', 'description': …
_TASK_OUTPUT_PARAMETERS dict[str, Any] {'type': 'object', 'properties': {'task_id': {'type': 'string', 'description': …

Classes

TaskStatus (enum)

Bases: StrEnum · Line 77

Task lifecycle state (spec.md Terminology "Task State").

Member Value
PENDING 'pending'
RUNNING 'running'
STOPPED 'stopped'
COMPLETED 'completed'
FAILED 'failed'
TIMEOUT 'timeout'

TaskStopMode (enum)

Bases: StrEnum · Decorators: traces(spec='069', story='US-069-001', reqs=['REQ-069-024', 'REQ-069-025']) · Line 95

How :class:TaskStopTool terminates a task (spec.md TaskStopMode).

Member Value
GRACEFUL 'graceful'
FORCE 'force'
CANCEL 'cancel'

OutputMode (enum)

Bases: StrEnum · Decorators: traces(spec='069', story='US-069-002', reqs=['REQ-069-028', 'REQ-069-029', 'REQ-069-030', 'REQ-069-031']) · Line 108

How :class:TaskOutputTool windows captured output (spec.md OutputMode).

Member Value
FULL 'full'
TAIL 'tail'
LINES 'lines'
STREAM 'stream'

TaskType (enum)

Bases: StrEnum · Decorators: traces(spec='069', story='US-069-002', reqs=['REQ-069-032']) · Line 118

Task category for governance (TaskStatusResult schema).

Member Value
BUILD 'build'
TEST 'test'
DEPLOY 'deploy'
PROCESS 'process'
CUSTOM 'custom'

PatternType (enum)

Bases: StrEnum · Decorators: traces(spec='069', story='US-069-003', reqs=['REQ-069-023']) · Line 129

Name-matching mode for batch stop / list (spec.md TaskStopByNameRequest).

Member Value
EXACT 'exact'
REGEX 'regex'
PREFIX 'prefix'
SUFFIX 'suffix'

TaskStopConfig (dataclass)

Decorators: dataclass(slots=True, frozen=True) · Line 142

Mirrors spec.md's TASK_STOP_CONFIG.

Attribute Type Default
graceful_timeout float 10.0
max_concurrent_stops int 10
retry_attempts int 3
retry_delay float 1.0

TaskOutputConfig (dataclass)

Decorators: dataclass(slots=True, frozen=True) · Line 152

Mirrors spec.md's TASK_OUTPUT_CONFIG.

Attribute Type Default
max_output_bytes int 100 * 1024 * 1024
retention_days int 7
tail_lines_default int 100
rate_limit_per_minute int 50

TaskToolErrorCode

Decorators: traces(spec='069', story='US-069-001', reqs=['REQ-069-034', 'REQ-069-035', 'REQ-069-036', 'REQ-069-037', 'REQ-069-038',… · Line 182

Error codes for the task-management tools (spec.md Error Conditions).

TaskToolError

Bases: Exception · Line 208

Raised by :class:TaskManager on any spec.md ERR-00N condition.

Method Signature Summary
__init__ __init__(code: str, message: str) -> None
to_result to_result(tool_name: str) -> ToolResult Render this error as a failed :class:ToolResult for tool_name.

TaskStatusResult (dataclass)

Decorators: traces(spec='069', story='US-069-002', reqs=['REQ-069-032']), dataclass(slots=True, frozen=True) · Line 292

Mirrors spec.md's TaskStatusResult JSON Schema (FR-011).

Attribute Type Default
task_id str
name str
status str
started_at str
output_lines int
stopped_at str | None None
progress dict[str, Any] | None None
pid int | None None
exit_code int | None None
memory_mb float | None None
cpu_percent float | None None
agent_id str ''
task_type str 'custom'

TaskOutput (dataclass)

Decorators: traces(spec='069', story='US-069-002', reqs=['REQ-069-028', 'REQ-069-033']), dataclass(slots=True, frozen=True) · Line 312

Mirrors spec.md's TaskOutput JSON Schema (FR-007/FR-012).

Attribute Type Default
task_id str
stdout str
stderr str
combined str
timestamp str
line_count int
byte_count int
tail_lines int | None None
line_range dict[str, int] | None None

StopOutcome (dataclass)

Decorators: traces(spec='069', story='US-069-001', reqs=['REQ-069-022']), dataclass(slots=True, frozen=True) · Line 328

Result of a single :meth:TaskManager.stop call (TaskStopByIdResponse.data).

Attribute Type Default
task_id str
stop_mode str
signal_sent str | None
wait_time_ms int
exit_code int | None

BatchStopOutcome (dataclass)

Decorators: traces(spec='069', story='US-069-003', reqs=['REQ-069-027']), dataclass(slots=True, frozen=True) · Line 340

Result of :meth:TaskManager.stop_by_pattern (TaskStopByNameResponse.data).

Attribute Type Default
name_pattern str
tasks_found int
tasks_stopped int
failures int
task_ids tuple[str, ...]

_OutputLine (dataclass)

Decorators: dataclass(slots=True) · Line 354

Attribute Type Default
seq int
stream str
text str
ts datetime

_TaskRecord (dataclass)

Decorators: traces(spec='069', story='US-069-001', reqs=['REQ-069-016', 'REQ-069-017']), dataclass(slots=True) · Line 363

Attribute Type Default
task_id str
name str
status TaskStatus
agent_id str
task_type TaskType
started_at datetime
stopped_at datetime | None None
pid int | None None
process subprocess.Popen[bytes] | None None
exit_code int | None None
protected bool False
critical bool False
progress_current int 0
progress_total int 0
progress_message str | None None
memory_mb_override float | None None
cpu_percent_override float | None None
lines list[_OutputLine] field(default_factory=list)
version int 0
stopping bool False

TaskManager

Line 392

Thread-safe in-process task store backing create/get/list/update/stop/output.

Method Signature Summary
__init__ __init__(stop_config: TaskStopConfig | None = None, output_config: TaskOutputConfig | None = None, id_factory: Callable[[], str] | None = None) -> None
_default_id_factory staticmethod _default_id_factory() -> str Cryptographically random task ID (SEC-REQ-014 / SEC-001).
_audit _audit(action: str, task_id: str, agent_id: str, success: bool, reason: str) -> None Append an immutable audit entry (RLock-safe, callable while locked).
audit_log audit_log() -> tuple[dict[str, Any], ...] A read-only snapshot of the audit trail (SEC-REQ-021).
create create(name: str, agent_id: str, task_type: TaskType | str = TaskType.CUSTOM, *, protected: bool = False, critical: bool = False, process: subprocess.Popen[bytes] | None = None, pid: int | None = None, status: TaskStatus = TaskStatus.RUNNING) -> _TaskRecord Create and register a new task; returns the live record.
get get(task_id: str) -> _TaskRecord | None Return the task record for task_id, or None.
list list(name: str | None = None, pattern_type: PatternType = PatternType.EXACT, status: TaskStatus | None = None) -> list[_TaskRecord] List tasks, optionally filtered by name pattern and/or status.
update update(task_id: str, *, status: TaskStatus | None = None, progress_current: int | None = None, progress_total: int | None = None, progress_message: str | None = None, memory_mb: float | None = None, cpu_percent: float | None = None) -> _TaskRecord Mutate a task's status/progress/reported metrics under the lock.
append_output append_output(task_id: str, stream: str, text: str) -> None Append one output line (stream is "stdout" or "stderr").
stop stop(task_id: str, mode: TaskStopMode, requesting_agent_id: str, *, is_admin: bool = False) -> StopOutcome Stop a single task (FR-001/003/004/005).
_signal_and_wait _signal_and_wait(process: subprocess.Popen[bytes], mode: TaskStopMode) -> tuple[str | None, int | None] Send the signal mode implies and wait for the process to exit.
_await_exit _await_exit(process: subprocess.Popen[bytes]) -> int Wait for process to exit, retrying per TaskStopConfig.
stop_by_pattern stop_by_pattern(name: str, pattern_type: PatternType, force: bool, requesting_agent_id: str, *, is_admin: bool = False) -> BatchStopOutcome Stop every running task whose name matches name (FR-002/006).
check_rate_limit check_rate_limit(agent_id: str) -> None Raise RATE_LIMIT_EXCEEDED (ERR-013) past the per-agent quota.
output_of output_of(task_id: str, mode: OutputMode, *, tail: int | None = None, lines: tuple[int, int] | None = None, stream_position: int = 0, combined_only: bool = False, max_output_bytes: int | None = None) -> tuple[TaskOutput, int | None] Retrieve windowed output for task_id (FR-007/009/010/012).
status_of status_of(task_id: str) -> TaskStatusResult Comprehensive status + performance metrics for task_id (FR-011).
purge_expired_output purge_expired_output(retention: timedelta | None = None) -> int Drop captured output older than retention (default 7 days).

TaskStopTool

Bases: BaseTool · Decorators: traces(spec='069', story='US-069-001', reqs=['REQ-069-022', 'REQ-069-023']) · Line 1041

The registrable BaseTool for stopping tasks (FR-001..006).

Method Signature Summary
__init__ __init__(manager: TaskManager, metadata: ToolMetadata, tracer: Tracer | None = None) -> None
metadata property metadata() -> ToolMetadata
execute async execute(arguments: dict[str, Any]) -> ToolResult Stop by task_id or batch-stop by name pattern.
create classmethod create(manager: TaskManager, trace_config: TraceConfig | None = None) -> TaskStopTool Build a :class:TaskStopTool over the given :class:TaskManager.

TaskOutputTool

Bases: BaseTool · Decorators: traces(spec='069', story='US-069-002', reqs=['REQ-069-028', 'REQ-069-030', 'REQ-069-031', 'REQ-069-032', 'REQ-069-033']) · Line 1208

The registrable BaseTool for output retrieval + status (FR-007..012).

Method Signature Summary
__init__ __init__(manager: TaskManager, metadata: ToolMetadata, tracer: Tracer | None = None) -> None
metadata property metadata() -> ToolMetadata
execute async execute(arguments: dict[str, Any]) -> ToolResult Retrieve output (full/tail/lines/stream) or status for a task.
create classmethod create(manager: TaskManager, trace_config: TraceConfig | None = None) -> TaskOutputTool Build a :class:TaskOutputTool over the given :class:TaskManager.

Functions

Function Signature Summary
_validate_task_id traces _validate_task_id(task_id: Any) -> None Raise INVALID_INPUT unless task_id matches task_[a-zA-Z0-9_-]+.
_sanitize_string traces _sanitize_string(value: str) -> str Strip control characters and clamp to :data:_MAX_STRING_LEN.
_validate_name traces _validate_name(name: Any) -> None Raise INVALID_INPUT unless name is a non-blank, bounded string.
_name_matches traces _name_matches(candidate: str, pattern: str, pattern_type: PatternType) -> bool Whether candidate matches pattern under pattern_type (FR-002).
register_task_tools register_task_tools(registry: ToolRegistry, manager: TaskManager | None = None, trace_config: TraceConfig | None = None) -> tuple[TaskStopTool, TaskOutputTool] Build :class:TaskStopTool + :class:TaskOutputTool sharing one :class:TaskManager, and register both into registry.

Source

Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/a4cbc2bdf/src/orchestrata_ultimate/tools/task_tools.py

"""Task management tools — TaskStopTool & TaskOutputTool (SPEC-069).

**Spec fork (read before extending):** SPEC-069's "Extends" metadata names
065-task-management-create/066-...-update/067-...-get/068-...-list as the
base CRUD tools this suite builds on. Those spec numbers do not describe
task-management tools in this codebase — ``specs/065-lsp-tool``,
``066-powershell-tool``, ``067-remote-trigger-tool``, and
``068-schedule-cron-tool`` are unrelated tools, and no TaskCreate/TaskUpdate/
TaskGet/TaskList ``BaseTool`` exists anywhere in the tree. Per the build
brief, this module does not fabricate specs/tools for that missing chain.
Instead :class:`TaskManager` is the in-process, thread-safe backing store
that implements the base create/get/list/update operations SPEC-069's two
tools need, and :class:`TaskStopTool` / :class:`TaskOutputTool` are the two
``BaseTool`` classes this spec actually defines and registers.

SPEC-069 also describes infrastructure this codebase does not have wired up
(a Redis-cluster-backed store with TLS + at-rest encryption, a WebSocket
transport for live streaming, Prometheus/PagerDuty export). Those pieces are
NOT fabricated here; every such gap is called out inline and in
``specs/069-task-management-tools/checklists/requirements.md``. What IS real
and load-bearing in this module:

    - Real OS process termination (SIGTERM -> escalate to SIGKILL) against a
      genuine :class:`subprocess.Popen`, with the exact graceful-timeout /
      retry-attempt / retry-delay knobs spec.md's ``TASK_STOP_CONFIG`` names.
    - Real ownership/critical/protected-task authorization (the in-process
      analogue of SEC-REQ-003's Prolog ``may_stop_task/2`` gate — wiring to
      the real governance engine is a follow-up integration, not invented
      here).
    - Real, deterministic collision-safe task-ID generation
      (``secrets.token_urlsafe``, retried under a lock) and idempotent stop
      handling (a per-record ``stopping`` flag makes a second concurrent
      stop call a clean no-op error instead of a double signal).
    - Real in-process rate limiting and output-retention purging — the
      process-local analogues of SEC-REQ-012/ERR-013 that don't require
      Redis to implement correctly.

Architecture (composition over inheritance, one ABC/Protocol boundary, per
``plans/build-factory-architecture.md`` — mirrors ``tools/search.py``):
    - :class:`TaskStopTool` / :class:`TaskOutputTool` are the two
      :class:`~orchestrata_ultimate.tools.base.BaseTool` (Command) instances
      callers/LLMs see. Both compose the same :class:`TaskManager`.
    - :class:`TaskManager` owns all task state (a ``dict[str, _TaskRecord]``
      behind a single :class:`threading.RLock`), process signalling, output
      windowing, rate limiting, and the in-process audit log.
"""

from __future__ import annotations

import json
import re
import secrets
import subprocess
import threading
import time
from collections import deque
from collections.abc import Callable
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime, timedelta
from enum import StrEnum
from typing import Any

from orchestrata_ultimate.core.base.traces import traces
from orchestrata_ultimate.core.tracing.tracer import TraceConfig, Tracer
from orchestrata_ultimate.tools.base import (
    BaseTool,
    ToolCategory,
    ToolMetadata,
    ToolPriority,
    ToolResult,
)
from orchestrata_ultimate.tools.registry import ToolRegistry

# ── enums (spec.md Type Definitions + Terminology) ──────────────────────


class TaskStatus(StrEnum):
    """Task lifecycle state (spec.md Terminology "Task State")."""

    PENDING = "pending"
    RUNNING = "running"
    STOPPED = "stopped"
    COMPLETED = "completed"
    FAILED = "failed"
    TIMEOUT = "timeout"


#: Terminal states a task can never leave (idempotency guard for stop/cancel).
_TERMINAL_STATUSES = frozenset(
    {TaskStatus.STOPPED, TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.TIMEOUT}
)


@traces(spec="069", story="US-069-001", reqs=["REQ-069-024", "REQ-069-025"])
class TaskStopMode(StrEnum):
    """How :class:`TaskStopTool` terminates a task (spec.md TaskStopMode)."""

    GRACEFUL = "graceful"
    FORCE = "force"
    CANCEL = "cancel"


@traces(
    spec="069",
    story="US-069-002",
    reqs=["REQ-069-028", "REQ-069-029", "REQ-069-030", "REQ-069-031"],
)
class OutputMode(StrEnum):
    """How :class:`TaskOutputTool` windows captured output (spec.md OutputMode)."""

    FULL = "full"
    TAIL = "tail"
    LINES = "lines"
    STREAM = "stream"


@traces(spec="069", story="US-069-002", reqs=["REQ-069-032"])
class TaskType(StrEnum):
    """Task category for governance (TaskStatusResult schema)."""

    BUILD = "build"
    TEST = "test"
    DEPLOY = "deploy"
    PROCESS = "process"
    CUSTOM = "custom"


@traces(spec="069", story="US-069-003", reqs=["REQ-069-023"])
class PatternType(StrEnum):
    """Name-matching mode for batch stop / list (spec.md TaskStopByNameRequest)."""

    EXACT = "exact"
    REGEX = "regex"
    PREFIX = "prefix"
    SUFFIX = "suffix"


# ── configuration (spec.md Technical Design > Configuration) ────────────


@dataclass(slots=True, frozen=True)
class TaskStopConfig:
    """Mirrors spec.md's ``TASK_STOP_CONFIG``."""

    graceful_timeout: float = 10.0
    max_concurrent_stops: int = 10
    retry_attempts: int = 3
    retry_delay: float = 1.0


@dataclass(slots=True, frozen=True)
class TaskOutputConfig:
    """Mirrors spec.md's ``TASK_OUTPUT_CONFIG``."""

    max_output_bytes: int = 100 * 1024 * 1024
    retention_days: int = 7
    tail_lines_default: int = 100
    rate_limit_per_minute: int = 50


# ── errors (spec.md Error Conditions) ────────────────────────────────────


@traces(
    spec="069",
    story="US-069-001",
    reqs=[
        "REQ-069-034",
        "REQ-069-035",
        "REQ-069-036",
        "REQ-069-037",
        "REQ-069-038",
        "REQ-069-039",
        "REQ-069-040",
        "REQ-069-041",
        "REQ-069-042",
        "REQ-069-044",
        "REQ-069-046",
        "REQ-069-048",
    ],
)
class TaskToolErrorCode:
    """Error codes for the task-management tools (spec.md Error Conditions).

    ERR-010 (STREAM_FAILED), ERR-012 (STORAGE_CORRUPTION), and ERR-014
    (REDIS_ERROR) have no code here: this MVP has no WebSocket transport, no
    persistent/corruptible storage, and no Redis dependency (see the module
    docstring's fork note), so those conditions cannot occur.
    """

    TASK_NOT_FOUND = "TASK_NOT_FOUND"
    TASK_NOT_RUNNING = "TASK_NOT_RUNNING"
    PERMISSION_DENIED = "PERMISSION_DENIED"
    TERMINATION_FAILED = "TERMINATION_FAILED"
    CRITICAL_TASK = "CRITICAL_TASK"
    PROTECTED_TASK = "PROTECTED_TASK"
    OUTPUT_NOT_AVAILABLE = "OUTPUT_NOT_AVAILABLE"
    INVALID_LINE_RANGE = "INVALID_LINE_RANGE"
    OUTPUT_TOO_LARGE = "OUTPUT_TOO_LARGE"
    TASK_ID_COLLISION = "TASK_ID_COLLISION"
    RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED"
    #: Not a spec.md ERR-0NN code: covers malformed-input rejections
    #: (SEC-REQ-006/007/008) that the spec's error table never assigned a
    #: dedicated code to.
    INVALID_INPUT = "INVALID_INPUT"


class TaskToolError(Exception):
    """Raised by :class:`TaskManager` on any spec.md ERR-00N condition."""

    def __init__(self, code: str, message: str) -> None:
        super().__init__(message)
        self.code = code
        self.message = message

    def to_result(self, tool_name: str) -> ToolResult:
        """Render this error as a failed :class:`ToolResult` for ``tool_name``."""
        return ToolResult(
            tool_name=tool_name,
            success=False,
            output="",
            error=f"{self.code}: {self.message}",
        )


# ── input validation (SEC-REQ-006/007/008/009) ──────────────────────────

_TASK_ID_PATTERN = re.compile(r"^task_[a-zA-Z0-9_-]+$")
_CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]")
_MAX_STRING_LEN = 255
_MAX_ID_ATTEMPTS = 5


@traces(spec="069", story="US-069-001", reqs=["REQ-069-006"])
def _validate_task_id(task_id: Any) -> None:
    """Raise ``INVALID_INPUT`` unless ``task_id`` matches ``task_[a-zA-Z0-9_-]+``."""
    if not isinstance(task_id, str) or not _TASK_ID_PATTERN.match(task_id):
        raise TaskToolError(
            TaskToolErrorCode.INVALID_INPUT,
            f"task_id must match pattern {_TASK_ID_PATTERN.pattern!r}",
        )


@traces(spec="069", story="US-069-001", reqs=["REQ-069-007"])
def _sanitize_string(value: str) -> str:
    """Strip control characters and clamp to :data:`_MAX_STRING_LEN`."""
    return _CONTROL_CHARS.sub("", value).strip()[:_MAX_STRING_LEN]


@traces(spec="069", story="US-069-001", reqs=["REQ-069-007", "REQ-069-008"])
def _validate_name(name: Any) -> None:
    """Raise ``INVALID_INPUT`` unless ``name`` is a non-blank, bounded string."""
    if not isinstance(name, str) or not name.strip():
        raise TaskToolError(
            TaskToolErrorCode.INVALID_INPUT, "name must be a non-blank string"
        )
    if len(name) > _MAX_STRING_LEN:
        raise TaskToolError(
            TaskToolErrorCode.INVALID_INPUT,
            f"name exceeds max length {_MAX_STRING_LEN}",
        )


@traces(spec="069", story="US-069-003", reqs=["REQ-069-023"])
def _name_matches(candidate: str, pattern: str, pattern_type: PatternType) -> bool:
    """Whether ``candidate`` matches ``pattern`` under ``pattern_type`` (FR-002).

    ``prefix``/``suffix`` tolerate a trailing/leading ``*`` in ``pattern``
    (spec.md's own "Batch Stop by Pattern" example passes ``name="test_*"``
    together with ``pattern="prefix"`` — a shell-glob habit layered on top of
    the explicit pattern-type selector) by stripping it before comparing.
    """
    if pattern_type == PatternType.EXACT:
        return candidate == pattern
    if pattern_type == PatternType.PREFIX:
        return candidate.startswith(pattern[:-1] if pattern.endswith("*") else pattern)
    if pattern_type == PatternType.SUFFIX:
        return candidate.endswith(pattern[1:] if pattern.startswith("*") else pattern)
    if pattern_type == PatternType.REGEX:
        try:
            return re.search(pattern, candidate) is not None
        except re.error:
            return False
    return False


# ── public schema dataclasses (spec.md Data Models) ─────────────────────


@traces(spec="069", story="US-069-002", reqs=["REQ-069-032"])
@dataclass(slots=True, frozen=True)
class TaskStatusResult:
    """Mirrors spec.md's ``TaskStatusResult`` JSON Schema (FR-011)."""

    task_id: str
    name: str
    status: str
    started_at: str
    output_lines: int
    stopped_at: str | None = None
    progress: dict[str, Any] | None = None
    pid: int | None = None
    exit_code: int | None = None
    memory_mb: float | None = None
    cpu_percent: float | None = None
    agent_id: str = ""
    task_type: str = "custom"


@traces(spec="069", story="US-069-002", reqs=["REQ-069-028", "REQ-069-033"])
@dataclass(slots=True, frozen=True)
class TaskOutput:
    """Mirrors spec.md's ``TaskOutput`` JSON Schema (FR-007/FR-012)."""

    task_id: str
    stdout: str
    stderr: str
    combined: str
    timestamp: str
    line_count: int
    byte_count: int
    tail_lines: int | None = None
    line_range: dict[str, int] | None = None


@traces(spec="069", story="US-069-001", reqs=["REQ-069-022"])
@dataclass(slots=True, frozen=True)
class StopOutcome:
    """Result of a single :meth:`TaskManager.stop` call (TaskStopByIdResponse.data)."""

    task_id: str
    stop_mode: str
    signal_sent: str | None
    wait_time_ms: int
    exit_code: int | None


@traces(spec="069", story="US-069-003", reqs=["REQ-069-027"])
@dataclass(slots=True, frozen=True)
class BatchStopOutcome:
    """Result of :meth:`TaskManager.stop_by_pattern` (TaskStopByNameResponse.data)."""

    name_pattern: str
    tasks_found: int
    tasks_stopped: int
    failures: int
    task_ids: tuple[str, ...]


# ── internal records ─────────────────────────────────────────────────────


@dataclass(slots=True)
class _OutputLine:
    seq: int
    stream: str
    text: str
    ts: datetime


@traces(spec="069", story="US-069-001", reqs=["REQ-069-016", "REQ-069-017"])
@dataclass(slots=True)
class _TaskRecord:
    task_id: str
    name: str
    status: TaskStatus
    agent_id: str
    task_type: TaskType
    started_at: datetime
    stopped_at: datetime | None = None
    pid: int | None = None
    process: subprocess.Popen[bytes] | None = None
    exit_code: int | None = None
    protected: bool = False
    critical: bool = False
    progress_current: int = 0
    progress_total: int = 0
    progress_message: str | None = None
    memory_mb_override: float | None = None
    cpu_percent_override: float | None = None
    lines: list[_OutputLine] = field(default_factory=list)
    version: int = 0
    #: True while a stop() call is in flight for this record — the
    #: idempotency guard for SEC-REQ-016/017 (a concurrent second stop call
    #: sees this and fails cleanly instead of double-signalling).
    stopping: bool = False


# ── the manager (in-process backing store for create/get/list/update/stop/output) ──


class TaskManager:
    """Thread-safe in-process task store backing create/get/list/update/stop/output.

    See the module docstring's fork note: this replaces the Redis-cluster
    store spec.md assumes. All mutation goes through a single
    :class:`threading.RLock` (``self._lock``); process signalling/waiting
    (the only genuinely slow step) is deliberately performed OUTSIDE the
    lock so concurrent reads/other-task stops are never blocked by one
    task's graceful-timeout wait.
    """

    def __init__(
        self,
        stop_config: TaskStopConfig | None = None,
        output_config: TaskOutputConfig | None = None,
        id_factory: Callable[[], str] | None = None,
    ) -> None:
        self._tasks: dict[str, _TaskRecord] = {}
        self._lock = threading.RLock()
        self._config_stop = stop_config or TaskStopConfig()
        self._config_output = output_config or TaskOutputConfig()
        self._id_factory = id_factory or self._default_id_factory
        self._audit_log: list[dict[str, Any]] = []
        self._rate_buckets: dict[str, deque[float]] = {}

    @staticmethod
    @traces(spec="069", reqs=["REQ-069-014"])
    def _default_id_factory() -> str:
        """Cryptographically random task ID (SEC-REQ-014 / SEC-001)."""
        return f"task_{secrets.token_urlsafe(16)}"

    # ── audit (SEC-REQ-018/019/020/021) ─────────────────────────────

    @traces(spec="069", reqs=["REQ-069-018", "REQ-069-019", "REQ-069-020"])
    def _audit(
        self, action: str, task_id: str, agent_id: str, success: bool, reason: str
    ) -> None:
        """Append an immutable audit entry (RLock-safe, callable while locked)."""
        entry = {
            "action": action,
            "task_id": task_id,
            "agent_id": agent_id,
            "success": success,
            "reason": reason,
            "timestamp": datetime.now(UTC).isoformat(),
        }
        with self._lock:
            self._audit_log.append(entry)

    def audit_log(self) -> tuple[dict[str, Any], ...]:
        """A read-only snapshot of the audit trail (SEC-REQ-021)."""
        with self._lock:
            return tuple(self._audit_log)

    # ── create / get / list / update (the base tool-suite operations) ──

    @traces(
        spec="069",
        story="US-069-001",
        reqs=["REQ-069-014", "REQ-069-015", "REQ-069-044"],
    )
    def create(
        self,
        name: str,
        agent_id: str,
        task_type: TaskType | str = TaskType.CUSTOM,
        *,
        protected: bool = False,
        critical: bool = False,
        process: subprocess.Popen[bytes] | None = None,
        pid: int | None = None,
        status: TaskStatus = TaskStatus.RUNNING,
    ) -> _TaskRecord:
        """Create and register a new task; returns the live record.

        Generates a collision-free ID via ``id_factory`` under the manager
        lock (SEC-REQ-015 atomic creation); raises ``TASK_ID_COLLISION``
        (ERR-011) if :data:`_MAX_ID_ATTEMPTS` consecutive attempts all
        collide with an already-registered ID.
        """
        _validate_name(name)
        with self._lock:
            task_id = ""
            for _ in range(_MAX_ID_ATTEMPTS):
                candidate = self._id_factory()
                if _TASK_ID_PATTERN.match(candidate) and candidate not in self._tasks:
                    task_id = candidate
                    break
            else:
                raise TaskToolError(
                    TaskToolErrorCode.TASK_ID_COLLISION,
                    "Unable to generate a unique task ID after "
                    f"{_MAX_ID_ATTEMPTS} attempts",
                )
            record = _TaskRecord(
                task_id=task_id,
                name=_sanitize_string(name),
                status=status,
                agent_id=agent_id,
                task_type=TaskType(task_type),
                started_at=datetime.now(UTC),
                pid=pid if pid is not None else (process.pid if process else None),
                process=process,
                protected=protected,
                critical=critical,
            )
            self._tasks[task_id] = record
            return record

    def get(self, task_id: str) -> _TaskRecord | None:
        """Return the task record for ``task_id``, or ``None``."""
        with self._lock:
            return self._tasks.get(task_id)

    def list(
        self,
        name: str | None = None,
        pattern_type: PatternType = PatternType.EXACT,
        status: TaskStatus | None = None,
    ) -> list[_TaskRecord]:
        """List tasks, optionally filtered by name pattern and/or status."""
        with self._lock:
            records = list(self._tasks.values())
        if name is not None:
            records = [r for r in records if _name_matches(r.name, name, pattern_type)]
        if status is not None:
            records = [r for r in records if r.status == status]
        return records

    def update(
        self,
        task_id: str,
        *,
        status: TaskStatus | None = None,
        progress_current: int | None = None,
        progress_total: int | None = None,
        progress_message: str | None = None,
        memory_mb: float | None = None,
        cpu_percent: float | None = None,
    ) -> _TaskRecord:
        """Mutate a task's status/progress/reported metrics under the lock."""
        with self._lock:
            record = self._tasks.get(task_id)
            if record is None:
                raise TaskToolError(
                    TaskToolErrorCode.TASK_NOT_FOUND, f"Task {task_id} not found"
                )
            if status is not None:
                record.status = status
            if progress_current is not None:
                record.progress_current = progress_current
            if progress_total is not None:
                record.progress_total = progress_total
            if progress_message is not None:
                record.progress_message = progress_message
            if memory_mb is not None:
                record.memory_mb_override = memory_mb
            if cpu_percent is not None:
                record.cpu_percent_override = cpu_percent
            record.version += 1
            return record

    def append_output(self, task_id: str, stream: str, text: str) -> None:
        """Append one output line (``stream`` is ``"stdout"`` or ``"stderr"``)."""
        with self._lock:
            record = self._tasks.get(task_id)
            if record is None:
                raise TaskToolError(
                    TaskToolErrorCode.TASK_NOT_FOUND, f"Task {task_id} not found"
                )
            seq = len(record.lines)
            record.lines.append(
                _OutputLine(seq=seq, stream=stream, text=text, ts=datetime.now(UTC))
            )

    # ── stop (FR-001..006) ──────────────────────────────────────────

    @traces(
        spec="069",
        story="US-069-001",
        reqs=[
            "REQ-069-001",
            "REQ-069-002",
            "REQ-069-003",
            "REQ-069-004",
            "REQ-069-005",
            "REQ-069-016",
            "REQ-069-017",
            "REQ-069-018",
            "REQ-069-019",
            "REQ-069-020",
            "REQ-069-022",
            "REQ-069-024",
            "REQ-069-025",
            "REQ-069-026",
            "REQ-069-034",
            "REQ-069-035",
            "REQ-069-036",
            "REQ-069-037",
            "REQ-069-038",
            "REQ-069-039",
            "REQ-069-048",
        ],
    )
    def stop(
        self,
        task_id: str,
        mode: TaskStopMode,
        requesting_agent_id: str,
        *,
        is_admin: bool = False,
    ) -> StopOutcome:
        """Stop a single task (FR-001/003/004/005).

        Raises:
            TaskToolError: ``TASK_NOT_FOUND`` (ERR-001), ``TASK_NOT_RUNNING``
                (ERR-002 — also covers the idempotent-retry / terminal-state
                case, SEC-REQ-017), ``PERMISSION_DENIED`` (ERR-003,
                SEC-REQ-002/013), ``CRITICAL_TASK`` (ERR-005, SEC-REQ-005),
                ``PROTECTED_TASK`` (ERR-006, SEC-REQ-004), or
                ``TERMINATION_FAILED`` (ERR-004) if the process still hasn't
                exited after the configured SIGKILL retries.
        """
        with self._lock:
            record = self._tasks.get(task_id)
            if record is None:
                self._audit("stop", task_id, requesting_agent_id, False, "not found")
                raise TaskToolError(
                    TaskToolErrorCode.TASK_NOT_FOUND, f"Task {task_id} not found"
                )
            not_cancelable = record.status in _TERMINAL_STATUSES
            not_running = (
                mode != TaskStopMode.CANCEL and record.status != TaskStatus.RUNNING
            )
            if record.stopping or not_cancelable or not_running:
                self._audit("stop", task_id, requesting_agent_id, False, "not running")
                raise TaskToolError(
                    TaskToolErrorCode.TASK_NOT_RUNNING,
                    f"Task {task_id} is not in running state",
                )
            if not is_admin and requesting_agent_id != record.agent_id:
                self._audit(
                    "stop", task_id, requesting_agent_id, False, "permission denied"
                )
                raise TaskToolError(
                    TaskToolErrorCode.PERMISSION_DENIED,
                    f"Agent does not have permission to stop task {task_id}",
                )
            if record.critical and not is_admin:
                self._audit(
                    "stop", task_id, requesting_agent_id, False, "critical task"
                )
                raise TaskToolError(
                    TaskToolErrorCode.CRITICAL_TASK,
                    f"Cannot stop critical task {task_id}",
                )
            if record.protected and not is_admin:
                self._audit(
                    "stop", task_id, requesting_agent_id, False, "protected task"
                )
                raise TaskToolError(
                    TaskToolErrorCode.PROTECTED_TASK,
                    f"Task {task_id} is protected from termination",
                )
            record.stopping = True
            process = record.process

        start = time.monotonic()
        signal_sent: str | None = None
        exit_code: int | None = None
        try:
            if process is not None:
                signal_sent, exit_code = self._signal_and_wait(process, mode)
        except TaskToolError:
            with self._lock:
                record.stopping = False
            self._audit(
                "stop", task_id, requesting_agent_id, False, "termination failed"
            )
            raise
        wait_ms = int((time.monotonic() - start) * 1000)
        if exit_code is not None and exit_code < 0:
            exit_code = 128 - exit_code

        with self._lock:
            record.status = TaskStatus.STOPPED
            record.stopped_at = datetime.now(UTC)
            record.exit_code = exit_code
            record.stopping = False
            record.version += 1
        self._audit("stop", task_id, requesting_agent_id, True, f"mode={mode.value}")
        return StopOutcome(
            task_id=task_id,
            stop_mode=mode.value,
            signal_sent=signal_sent,
            wait_time_ms=wait_ms,
            exit_code=exit_code,
        )

    @traces(
        spec="069",
        story="US-069-001",
        reqs=["REQ-069-024", "REQ-069-025", "REQ-069-037"],
    )
    def _signal_and_wait(
        self, process: subprocess.Popen[bytes], mode: TaskStopMode
    ) -> tuple[str | None, int | None]:
        """Send the signal ``mode`` implies and wait for the process to exit.

        GRACEFUL (FR-003, the default): SIGTERM, wait up to
        ``graceful_timeout``, escalate to SIGKILL if still alive. FORCE
        (FR-004): SIGKILL immediately. CANCEL: no signal if the process
        already exited; otherwise the same SIGKILL path as FORCE.
        """
        if mode == TaskStopMode.FORCE:
            process.kill()
            return "SIGKILL", self._await_exit(process)
        if mode == TaskStopMode.CANCEL:
            if process.poll() is not None:
                return None, process.returncode
            process.kill()
            return "SIGKILL", self._await_exit(process)
        process.terminate()
        try:
            exit_code = process.wait(timeout=self._config_stop.graceful_timeout)
            return "SIGTERM", exit_code
        except subprocess.TimeoutExpired:
            process.kill()
            return "SIGTERM+SIGKILL", self._await_exit(process)

    @traces(spec="069", reqs=["REQ-069-037"])
    def _await_exit(self, process: subprocess.Popen[bytes]) -> int:
        """Wait for ``process`` to exit, retrying per ``TaskStopConfig``.

        Raises ``TERMINATION_FAILED`` (ERR-004) if the process is still
        alive after every retry — reachable deterministically with
        ``TaskStopConfig(retry_attempts=0)``, which skips waiting entirely
        and checks ``poll()`` immediately after the signal is sent.
        """
        for _ in range(max(self._config_stop.retry_attempts, 0)):
            try:
                return process.wait(timeout=self._config_stop.retry_delay)
            except subprocess.TimeoutExpired:
                continue
        if process.poll() is None:
            raise TaskToolError(
                TaskToolErrorCode.TERMINATION_FAILED,
                f"Failed to terminate process PID {process.pid}",
            )
        return process.returncode

    @traces(spec="069", story="US-069-003", reqs=["REQ-069-023", "REQ-069-027"])
    def stop_by_pattern(
        self,
        name: str,
        pattern_type: PatternType,
        force: bool,
        requesting_agent_id: str,
        *,
        is_admin: bool = False,
    ) -> BatchStopOutcome:
        """Stop every running task whose name matches ``name`` (FR-002/006).

        Protected/critical tasks are excluded from ``tasks_found`` unless
        ``is_admin`` (US-003 acceptance criteria); a per-task failure during
        stop is counted in ``failures`` rather than aborting the batch.
        """
        mode = TaskStopMode.FORCE if force else TaskStopMode.GRACEFUL
        with self._lock:
            candidates = [
                r
                for r in self._tasks.values()
                if r.status == TaskStatus.RUNNING
                and _name_matches(r.name, name, pattern_type)
            ]
        eligible = [
            r
            for r in candidates
            if (is_admin or not r.protected) and (is_admin or not r.critical)
        ]
        stopped_ids: list[str] = []
        failures = 0
        for record in eligible:
            try:
                self.stop(record.task_id, mode, requesting_agent_id, is_admin=is_admin)
                stopped_ids.append(record.task_id)
            except TaskToolError:
                failures += 1
        return BatchStopOutcome(
            name_pattern=name,
            tasks_found=len(eligible),
            tasks_stopped=len(stopped_ids),
            failures=failures,
            task_ids=tuple(stopped_ids),
        )

    # ── output / status (FR-007..012) ───────────────────────────────

    @traces(spec="069", story="US-069-004", reqs=["REQ-069-046"])
    def check_rate_limit(self, agent_id: str) -> None:
        """Raise ``RATE_LIMIT_EXCEEDED`` (ERR-013) past the per-agent quota.

        A process-local sliding window (SEC-REQ isn't distributed — see the
        module docstring's fork note: production would need this backed by
        Redis for multi-process fairness).
        """
        now = time.monotonic()
        with self._lock:
            bucket = self._rate_buckets.setdefault(agent_id, deque())
            while bucket and now - bucket[0] > 60.0:
                bucket.popleft()
            if len(bucket) >= self._config_output.rate_limit_per_minute:
                raise TaskToolError(
                    TaskToolErrorCode.RATE_LIMIT_EXCEEDED,
                    "Too many output requests "
                    f"({self._config_output.rate_limit_per_minute}/minute)",
                )
            bucket.append(now)

    @traces(
        spec="069",
        story="US-069-002",
        reqs=[
            "REQ-069-028",
            "REQ-069-030",
            "REQ-069-031",
            "REQ-069-033",
            "REQ-069-040",
            "REQ-069-041",
            "REQ-069-042",
        ],
    )
    def output_of(
        self,
        task_id: str,
        mode: OutputMode,
        *,
        tail: int | None = None,
        lines: tuple[int, int] | None = None,
        stream_position: int = 0,
        combined_only: bool = False,
        max_output_bytes: int | None = None,
    ) -> tuple[TaskOutput, int | None]:
        """Retrieve windowed output for ``task_id`` (FR-007/009/010/012).

        Returns ``(output, new_stream_position)`` — ``new_stream_position``
        is only meaningful for ``mode=OutputMode.STREAM`` (``None`` otherwise).

        Raises:
            TaskToolError: ``TASK_NOT_FOUND`` (ERR-001), ``OUTPUT_NOT_AVAILABLE``
                (ERR-007 — no output captured yet), ``INVALID_LINE_RANGE``
                (ERR-008), or ``OUTPUT_TOO_LARGE`` (ERR-009, full mode only).
        """
        with self._lock:
            record = self._tasks.get(task_id)
            if record is None:
                raise TaskToolError(
                    TaskToolErrorCode.TASK_NOT_FOUND, f"Task {task_id} not found"
                )
            all_lines = list(record.lines)

        total = len(all_lines)
        if total == 0:
            raise TaskToolError(
                TaskToolErrorCode.OUTPUT_NOT_AVAILABLE,
                f"Output not found for task {task_id}",
            )
        total_bytes = sum(len(line.text.encode()) for line in all_lines)
        new_position: int | None = None

        if mode == OutputMode.FULL:
            limit = (
                max_output_bytes
                if max_output_bytes is not None
                else self._config_output.max_output_bytes
            )
            if total_bytes > limit:
                raise TaskToolError(
                    TaskToolErrorCode.OUTPUT_TOO_LARGE,
                    f"Output size {total_bytes} exceeds limit {limit}",
                )
            window = all_lines
        elif mode == OutputMode.TAIL:
            n = tail if tail is not None else self._config_output.tail_lines_default
            if n < 1:
                raise TaskToolError(
                    TaskToolErrorCode.INVALID_INPUT, "tail must be >= 1"
                )
            window = all_lines[-n:]
        elif mode == OutputMode.LINES:
            if lines is None or len(lines) != 2:
                raise TaskToolError(
                    TaskToolErrorCode.INVALID_INPUT, "lines must be a (start, end) pair"
                )
            start, end = lines
            if start < 1 or end < start or end > total:
                raise TaskToolError(
                    TaskToolErrorCode.INVALID_LINE_RANGE,
                    f"Line range ({start}, {end}) exceeds output length ({total})",
                )
            window = all_lines[start - 1 : end]
        else:  # STREAM
            pos = max(stream_position, 0)
            window = all_lines[pos:]
            new_position = total

        stdout_text = "\n".join(line.text for line in window if line.stream == "stdout")
        stderr_text = "\n".join(line.text for line in window if line.stream == "stderr")
        combined_text = "\n".join(line.text for line in window)
        if combined_only:
            stdout_text = ""
            stderr_text = ""

        output = TaskOutput(
            task_id=task_id,
            stdout=stdout_text,
            stderr=stderr_text,
            combined=combined_text,
            timestamp=datetime.now(UTC).isoformat(),
            line_count=total,
            byte_count=total_bytes,
            tail_lines=(
                (tail or self._config_output.tail_lines_default)
                if mode == OutputMode.TAIL
                else None
            ),
            line_range=(
                {"start": lines[0], "end": lines[1]}
                if lines and mode == OutputMode.LINES
                else None
            ),
        )
        return output, new_position

    @traces(spec="069", story="US-069-002", reqs=["REQ-069-032"])
    def status_of(self, task_id: str) -> TaskStatusResult:
        """Comprehensive status + performance metrics for ``task_id`` (FR-011).

        ``memory_mb``/``cpu_percent`` are sampled live via ``psutil`` when a
        real process is attached and still running; otherwise they fall back
        to any value reported through :meth:`update`, or ``None``.
        """
        with self._lock:
            record = self._tasks.get(task_id)
            if record is None:
                raise TaskToolError(
                    TaskToolErrorCode.TASK_NOT_FOUND, f"Task {task_id} not found"
                )
            memory_mb = record.memory_mb_override
            cpu_percent = record.cpu_percent_override
            pid = record.pid
            if record.process is not None and record.process.poll() is None:
                import psutil

                try:
                    proc = psutil.Process(record.process.pid)
                    memory_mb = proc.memory_info().rss / (1024 * 1024)
                    cpu_percent = proc.cpu_percent(interval=0.0)
                except psutil.Error:
                    pass
            elif record.status != TaskStatus.RUNNING:
                pid = None
            progress = None
            if record.progress_total > 0:
                percent = (record.progress_current / record.progress_total) * 100
                progress = {
                    "current": record.progress_current,
                    "total": record.progress_total,
                    "percent": max(0.0, min(100.0, percent)),
                    "message": record.progress_message,
                }
            return TaskStatusResult(
                task_id=record.task_id,
                name=record.name,
                status=record.status.value,
                started_at=record.started_at.isoformat(),
                stopped_at=record.stopped_at.isoformat() if record.stopped_at else None,
                progress=progress,
                pid=pid,
                exit_code=record.exit_code,
                output_lines=len(record.lines),
                memory_mb=memory_mb,
                cpu_percent=cpu_percent,
                agent_id=record.agent_id,
                task_type=record.task_type.value,
            )

    @traces(spec="069", story="US-069-002", reqs=["REQ-069-012"])
    def purge_expired_output(self, retention: timedelta | None = None) -> int:
        """Drop captured output older than ``retention`` (default 7 days).

        SEC-REQ-012 without a Redis TTL: purge is triggered explicitly
        (there is no background scheduler in this MVP) but the retention
        policy itself — output MUST NOT outlive the configured window — is
        real. Returns the number of lines purged.
        """
        cutoff = datetime.now(UTC) - (
            retention or timedelta(days=self._config_output.retention_days)
        )
        purged = 0
        with self._lock:
            for record in self._tasks.values():
                if record.started_at < cutoff and record.lines:
                    purged += len(record.lines)
                    record.lines.clear()
        return purged


# ── TaskStopTool ──────────────────────────────────────────────────────────

_TASK_STOP_PARAMETERS: dict[str, Any] = {
    "type": "object",
    "properties": {
        "task_id": {
            "type": "string",
            "description": "Task ID to stop, matching ^task_[a-zA-Z0-9_-]+$.",
        },
        "name": {
            "type": "string",
            "description": "Name (or pattern) to batch-stop matching tasks by.",
        },
        "pattern": {
            "type": "string",
            "enum": ["exact", "regex", "prefix", "suffix"],
            "description": "How 'name' is matched (default: exact).",
        },
        "force": {
            "type": "boolean",
            "description": "SIGKILL immediately instead of graceful SIGTERM.",
        },
        "mode": {
            "type": "string",
            "enum": ["graceful", "force", "cancel"],
            "description": "Explicit stop mode; overrides 'force' when given.",
        },
        "requesting_agent_id": {
            "type": "string",
            "description": "Agent performing the stop (SEC-REQ-001/002).",
        },
        "is_admin": {
            "type": "boolean",
            "description": "Whether the requesting agent has admin privileges.",
        },
    },
    "required": ["requesting_agent_id"],
}


@traces(spec="069", story="US-069-001", reqs=["REQ-069-022", "REQ-069-023"])
class TaskStopTool(BaseTool):
    """The registrable ``BaseTool`` for stopping tasks (FR-001..006)."""

    def __init__(
        self,
        manager: TaskManager,
        metadata: ToolMetadata,
        tracer: Tracer | None = None,
    ) -> None:
        self._manager = manager
        self._metadata = metadata
        self._tracer = tracer if tracer is not None else Tracer.default()

    @property
    def metadata(self) -> ToolMetadata:
        return self._metadata

    @traces(spec="069", story="US-069-001", reqs=["REQ-069-022", "REQ-069-023"])
    async def execute(self, arguments: dict[str, Any]) -> ToolResult:
        """Stop by ``task_id`` or batch-stop by ``name`` pattern.

        Never raises: any :class:`TaskToolError` (or unforeseen exception)
        becomes a failed :class:`ToolResult`.
        """
        with self._tracer.span("TaskStopTool.execute"):
            try:
                requesting_agent_id = arguments.get("requesting_agent_id")
                if (
                    not isinstance(requesting_agent_id, str)
                    or not requesting_agent_id.strip()
                ):
                    raise TaskToolError(
                        TaskToolErrorCode.INVALID_INPUT,
                        "requesting_agent_id is required",
                    )
                is_admin = bool(arguments.get("is_admin", False))
                force = bool(arguments.get("force", False))
                mode_raw = arguments.get("mode")
                mode = (
                    TaskStopMode(mode_raw)
                    if mode_raw is not None
                    else (TaskStopMode.FORCE if force else TaskStopMode.GRACEFUL)
                )

                task_id = arguments.get("task_id")
                name = arguments.get("name")
                if task_id:
                    _validate_task_id(task_id)
                    outcome = self._manager.stop(
                        task_id, mode, requesting_agent_id, is_admin=is_admin
                    )
                    data = asdict(outcome)
                    message = (
                        f"Task {task_id} cancelled"
                        if outcome.stop_mode == TaskStopMode.CANCEL.value
                        else f"Task {task_id} stopped {outcome.stop_mode}ly"
                    )
                elif name:
                    pattern_type = PatternType(arguments.get("pattern", "exact"))
                    batch = self._manager.stop_by_pattern(
                        name,
                        pattern_type,
                        force,
                        requesting_agent_id,
                        is_admin=is_admin,
                    )
                    data = {
                        "name_pattern": batch.name_pattern,
                        "tasks_found": batch.tasks_found,
                        "tasks_stopped": batch.tasks_stopped,
                        "failures": batch.failures,
                        "task_ids": list(batch.task_ids),
                    }
                    message = f"Stopped {batch.tasks_stopped} tasks"
                else:
                    raise TaskToolError(
                        TaskToolErrorCode.INVALID_INPUT,
                        "either task_id or name is required",
                    )

                payload = {
                    "success": True,
                    "message": message,
                    "data": data,
                    "timestamp": datetime.now(UTC).isoformat(),
                }
                return ToolResult(
                    tool_name=self.metadata.name,
                    success=True,
                    output=json.dumps(payload),
                )
            except TaskToolError as exc:
                return exc.to_result(self.metadata.name)
            except Exception as exc:  # noqa: BLE001 - never propagate
                return ToolResult(
                    tool_name=self.metadata.name,
                    success=False,
                    output="",
                    error=str(exc),
                )

    @classmethod
    def create(
        cls, manager: TaskManager, trace_config: TraceConfig | None = None
    ) -> TaskStopTool:
        """Build a :class:`TaskStopTool` over the given :class:`TaskManager`."""
        tracer = Tracer(trace_config) if trace_config is not None else Tracer.default()
        metadata = ToolMetadata(
            name="task_stop",
            description=(
                "Stop a running task by ID or batch-stop tasks matching a name "
                "pattern, gracefully (SIGTERM->SIGKILL) or forced (SIGKILL)."
            ),
            category=ToolCategory.TASK,
            parameters=_TASK_STOP_PARAMETERS,
            priority=ToolPriority.P2,
            tags=("task", "process", "lifecycle"),
        )
        return cls(manager=manager, metadata=metadata, tracer=tracer)


# ── TaskOutputTool ────────────────────────────────────────────────────────

_TASK_OUTPUT_PARAMETERS: dict[str, Any] = {
    "type": "object",
    "properties": {
        "task_id": {"type": "string", "description": "Task ID to read."},
        "request_type": {
            "type": "string",
            "enum": ["output", "status"],
            "description": "'output' (default) retrieves stdout/stderr; 'status' returns metrics.",
        },
        "mode": {
            "type": "string",
            "enum": ["full", "tail", "lines", "stream"],
            "description": "Output windowing mode (request_type='output' only).",
        },
        "tail": {"type": "integer", "description": "Line count for mode='tail'."},
        "lines": {
            "type": "array",
            "items": {"type": "integer"},
            "minItems": 2,
            "maxItems": 2,
            "description": "[start, end] 1-indexed inclusive range for mode='lines'.",
        },
        "combined": {
            "type": "boolean",
            "description": "Only populate the merged 'combined' field, not stdout/stderr.",
        },
        "stream_position": {
            "type": "integer",
            "description": "Cursor for mode='stream' incremental polling.",
        },
        "requesting_agent_id": {
            "type": "string",
            "description": "Agent making the request (used for rate limiting).",
        },
    },
    "required": ["task_id"],
}


@traces(
    spec="069",
    story="US-069-002",
    reqs=["REQ-069-028", "REQ-069-030", "REQ-069-031", "REQ-069-032", "REQ-069-033"],
)
class TaskOutputTool(BaseTool):
    """The registrable ``BaseTool`` for output retrieval + status (FR-007..012).

    ``mode="stream"`` (FR-008) implements the incremental-chunk primitive
    (poll with a ``stream_position`` cursor) but NOT a live WebSocket push
    transport — see the module docstring's fork note; no gateway wiring was
    built as part of this spec.
    """

    def __init__(
        self,
        manager: TaskManager,
        metadata: ToolMetadata,
        tracer: Tracer | None = None,
    ) -> None:
        self._manager = manager
        self._metadata = metadata
        self._tracer = tracer if tracer is not None else Tracer.default()

    @property
    def metadata(self) -> ToolMetadata:
        return self._metadata

    @traces(
        spec="069",
        story="US-069-002",
        reqs=[
            "REQ-069-028",
            "REQ-069-030",
            "REQ-069-031",
            "REQ-069-032",
            "REQ-069-033",
        ],
    )
    async def execute(self, arguments: dict[str, Any]) -> ToolResult:
        """Retrieve output (full/tail/lines/stream) or status for a task.

        Never raises: any :class:`TaskToolError` (or unforeseen exception)
        becomes a failed :class:`ToolResult`.
        """
        with self._tracer.span("TaskOutputTool.execute"):
            try:
                task_id = arguments.get("task_id")
                if not isinstance(task_id, str) or not task_id.strip():
                    raise TaskToolError(
                        TaskToolErrorCode.INVALID_INPUT, "task_id is required"
                    )
                _validate_task_id(task_id)
                agent_id = arguments.get("requesting_agent_id") or "anonymous"
                self._manager.check_rate_limit(agent_id)

                request_type = arguments.get("request_type", "output")
                if request_type == "status":
                    result = self._manager.status_of(task_id)
                    payload = {
                        "success": True,
                        "message": f"Status for {task_id}",
                        "data": asdict(result),
                        "timestamp": datetime.now(UTC).isoformat(),
                    }
                    return ToolResult(
                        tool_name=self.metadata.name,
                        success=True,
                        output=json.dumps(payload),
                    )

                mode = OutputMode(arguments.get("mode", "full"))
                tail_raw = arguments.get("tail")
                tail = int(tail_raw) if tail_raw is not None else None
                lines_raw = arguments.get("lines")
                lines = (int(lines_raw[0]), int(lines_raw[1])) if lines_raw else None
                combined_only = bool(arguments.get("combined", False))
                stream_position = int(arguments.get("stream_position", 0))

                output, new_position = self._manager.output_of(
                    task_id,
                    mode,
                    tail=tail,
                    lines=lines,
                    stream_position=stream_position,
                    combined_only=combined_only,
                )
                data = asdict(output)
                if new_position is not None:
                    data["stream_position"] = new_position
                payload = {
                    "success": True,
                    "message": f"Retrieved {mode.value} output for {task_id}",
                    "data": data,
                    "timestamp": datetime.now(UTC).isoformat(),
                }
                return ToolResult(
                    tool_name=self.metadata.name,
                    success=True,
                    output=json.dumps(payload),
                )
            except TaskToolError as exc:
                return exc.to_result(self.metadata.name)
            except (TypeError, ValueError) as exc:
                return ToolResult(
                    tool_name=self.metadata.name,
                    success=False,
                    output="",
                    error=f"{TaskToolErrorCode.INVALID_INPUT}: {exc}",
                )
            except Exception as exc:  # noqa: BLE001 - never propagate
                return ToolResult(
                    tool_name=self.metadata.name,
                    success=False,
                    output="",
                    error=str(exc),
                )

    @classmethod
    def create(
        cls, manager: TaskManager, trace_config: TraceConfig | None = None
    ) -> TaskOutputTool:
        """Build a :class:`TaskOutputTool` over the given :class:`TaskManager`."""
        tracer = Tracer(trace_config) if trace_config is not None else Tracer.default()
        metadata = ToolMetadata(
            name="task_output",
            description=(
                "Retrieve a task's captured stdout/stderr (full, tail, line "
                "range, or incremental stream) or its status + performance metrics."
            ),
            category=ToolCategory.TASK,
            parameters=_TASK_OUTPUT_PARAMETERS,
            priority=ToolPriority.P2,
            tags=("task", "process", "output"),
        )
        return cls(manager=manager, metadata=metadata, tracer=tracer)


# ── wiring ────────────────────────────────────────────────────────────────


def register_task_tools(
    registry: ToolRegistry,
    manager: TaskManager | None = None,
    trace_config: TraceConfig | None = None,
) -> tuple[TaskStopTool, TaskOutputTool]:
    """Build :class:`TaskStopTool` + :class:`TaskOutputTool` sharing one
    :class:`TaskManager`, and register both into ``registry``.
    """
    manager = manager if manager is not None else TaskManager()
    stop_tool = TaskStopTool.create(manager, trace_config)
    output_tool = TaskOutputTool.create(manager, trace_config)
    registry.register(stop_tool, replace=True)
    registry.register(output_tool, replace=True)
    return stop_tool, output_tool