Tracker adapter contract¶
Trackers are external task sources (Linear, Jira, GitHub Projects v2, GitLab, ClickUp, Asana, Plane, ServiceNow, or an enterprise's private tracker). Bernstein consumes them through a single abstract contract so the orchestrator, federation layer, audit log, and cost cap reason about every tracker uniformly.
This page documents the contract surface and walks through the minimum viable adapter.
Surface¶
| Module | Contents |
|---|---|
bernstein.core.trackers.contract | AbstractTrackerAdapter, dataclasses, exceptions |
bernstein.core.trackers.registry | TrackerRegistry, register_tracker, plugin discovery |
bernstein.plugins.hookspecs | provide_tracker_adapter hookspec |
bernstein.cli.commands.trackers_cmd | bernstein trackers list / trackers test |
Required methods¶
| Method | Required | Description |
|---|---|---|
pull_open_tickets(filter) | yes | Yields Ticket objects for the open queue. |
add_comment(ticket_id, body, *, idempotency_key) | yes | Posts a comment. Replays with same key + body return original result. |
transition(ticket_id, status_id, *, idempotency_key, etag) | yes | Moves a ticket to a new status. Stale etag raises OptimisticConcurrencyError. |
claim_ticket(ticket_id, agent_id, *, etag) | optional | Marks a ticket as in-flight for agent_id. Default: NotImplementedError. |
attach_blob(ticket_id, blob, mime, *, idempotency_key) | optional | Uploads a binary blob. Default: NotImplementedError. |
Exceptions¶
| Exception | Meaning |
|---|---|
OptimisticConcurrencyError | etag precondition failed; reload the ticket. |
IdempotencyConflict | Idempotency key reused with a different payload. |
RateLimited(retry_after=<seconds>) | Back off before retrying. |
TrackerUnavailable | Tracker is unreachable or 5xx. |
Idempotency¶
Adapters should treat idempotency_key as authoritative whenever the tracker's API supports it (e.g. Linear's mutation clientMutationId, or an Idempotency-Key request header). Where the tracker does not, the adapter must dedupe locally via a comment marker (the agent posts a <!-- bernstein-key: ... --> marker) or a SQLite ledger keyed on (ticket_id, op, key).
Etag semantics¶
etag is opaque. Adapters serialise whatever the tracker's API uses for optimistic concurrency:
- GitHub:
If-MatchETag header. - Jira:
versionAtUpdatefield. - Linear: GraphQL mutation versioning.
- ServiceNow:
sys_mod_countcolumn.
A None etag means "skip the precondition check"; callers that always want concurrency control should refuse to act on tickets whose etag is None.
Minimum viable adapter¶
from collections.abc import Iterator
from typing import Any
from bernstein.core.trackers.contract import (
AbstractTrackerAdapter,
CommentResult,
Ticket,
TransitionResult,
)
class AcmeTracker(AbstractTrackerAdapter):
name = "acme"
def __init__(self, *, base_url: str, token: str) -> None:
self._base_url = base_url
self._token = token
def pull_open_tickets(self, filter: dict[str, Any] | None = None) -> Iterator[Ticket]:
# Call out to Acme's REST API, yield normalised Ticket objects.
...
def add_comment(
self,
ticket_id: str,
body: str,
*,
idempotency_key: str | None = None,
) -> CommentResult: ...
def transition(
self,
ticket_id: str,
status_id: str,
*,
idempotency_key: str | None = None,
etag: str | None = None,
) -> TransitionResult: ...
Shipping the adapter as a plugin¶
Out-of-tree adapters register through the provide_tracker_adapter pluggy hook. The plugin returns a TrackerRegistration, a (name, factory) tuple, or a list of either; the registry coerces the shape.
from bernstein.core.trackers.registry import TrackerRegistration
from bernstein.plugins import hookimpl
class AcmePlugin:
@hookimpl
def provide_tracker_adapter(self):
return TrackerRegistration(
name="acme",
factory=AcmeTracker,
summary="Acme tracker REST adapter.",
capabilities=("comment", "transition"),
)
Register the plugin via the bernstein.plugins entry-point group or the plugins: list in bernstein.yaml. Once loaded:
$ bernstein trackers list --source plugin
NAME SOURCE CAPABILITIES SUMMARY
acme plugin comment,transition Acme tracker REST adapter.
Smoke-testing an adapter¶
bernstein trackers test <name> constructs the adapter, calls pull_open_tickets once with an empty filter, and reports the result. The command is read-only: it never claims, comments, transitions, or attaches.
When the adapter's factory requires constructor arguments that are not present in bernstein.yaml, the command reports status: skipped with the missing-argument error rather than failing. This makes the same invocation safe to run in ephemeral CI environments.
Synthetic transaction¶
bernstein trackers test proves an adapter is reachable. It does not prove it is right: a status id can map to the wrong workflow state and a comment can land on the wrong field while the smoke test stays green.
bernstein.core.trackers.synthetic drives the stronger check. It creates one throwaway entity through the adapter, runs an ordered list of validator callables against it, and lets the last validator delete it and assert absence:
from bernstein.core.trackers.synthetic import run_synthetic_transaction
report = run_synthetic_transaction(adapter)
raise SystemExit(report.exit_code)
Each validator prints one verdict line (PASS / FAIL / SKIP) and report.exit_code is non-zero if any validator failed. A validator that does not apply to an adapter raises ValidatorSkipped; a skip is not a failure. Adding a check is appending one callable to DEFAULT_VALIDATORS, with the deleting validator staying last.
Every entity is titled with a deterministic, date-derived marker (bernstein-synthetic-<tracker>-<YYYYMMDD>). Trackers assign their own ids, so the marker -- not the id -- is what a re-run can predict: two runs on the same UTC day compute the same marker, so a run that aborts halfway leaves a leftover the next run recognises and sweeps.
Hosting a throwaway entity is optional, and deliberately not part of AbstractTrackerAdapter: the hot path never creates or deletes tickets, and a general-purpose delete on the shared contract would be reachable by accident. An adapter opts in by implementing three methods -- create_probe_ticket, find_probe_tickets, delete_probe_ticket -- where the delete reads the entity back and refuses any whose title does not carry the marker. Adapters that do not implement them are refused with SyntheticProbeUnsupported; the runner has no per-adapter branch.
GitLabAdapter is the reference implementation.
Reference fake¶
tests/fixtures/trackers/in_memory_tracker.py ships a deterministic in-memory implementation used by every tracker unit test. The fake mirrors the contract (etag bookkeeping, idempotency ledger, rate-limit injection, unavailability toggle) so new adapters can plug into the existing contract test suite by parameterising over the fake's API sequence.
Default-off behaviour¶
No tracker adapter is enabled until it is named in bernstein.yaml: trackers.enabled = [...]. The registry only exposes adapters that have been registered; enabling is the operator's explicit step.