Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions ami/jobs/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,21 @@ def process_nats_pipeline_result(self, job_id: int, result_data: dict, reply_sub
_, t = t(f"TIME: Updated job {job_id} progress in PROCESS stage progress to {progress_info.percentage*100}%")
job = Job.objects.get(pk=job_id)
job.logger.info(f"Processing pipeline result for job {job_id}, reply_subject: {reply_subject}")
# Audit: warn if the worker echoed back a config that no longer matches what
# Antenna would send today (e.g. ProjectPipelineConfig was edited mid-job, or
# the worker is stale / serving an older job). Logged only — no enforcement.
if pipeline_result and pipeline_result.config is not None and job.pipeline:
try:
echoed_config = dict(pipeline_result.config)
current_config = dict(job.pipeline.get_config(project_id=job.project.pk))
if echoed_config != current_config:
job.logger.warning(
f"Pipeline config drift on job {job_id}: worker used {echoed_config}, "
f"current Antenna config is {current_config}"
)
Comment on lines +314 to +317
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid logging full pipeline config payloads in drift warnings.

This logs complete worker/current config values on every drift event. In practice that can expose sensitive fields and create very large per-job logs. Log a compact diff summary (e.g., differing keys/count) instead of full payloads.

Proposed fix
-                if echoed_config != current_config:
-                    job.logger.warning(
-                        f"Pipeline config drift on job {job_id}: worker used {echoed_config}, "
-                        f"current Antenna config is {current_config}"
-                    )
+                if echoed_config != current_config:
+                    differing_keys = sorted(
+                        key
+                        for key in (set(echoed_config) | set(current_config))
+                        if echoed_config.get(key) != current_config.get(key)
+                    )
+                    job.logger.warning(
+                        "Pipeline config drift on job %s: differing_keys=%s",
+                        job_id,
+                        differing_keys,
+                    )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ami/jobs/tasks.py` around lines 314 - 317, The warning in tasks.py currently
logs full pipeline configs via job.logger.warning using echoed_config and
current_config for job_id, which can leak sensitive data and create large logs;
change the logging to compute and log a compact summary instead — for example
produce a diff that lists only differing top-level keys and counts (or a
truncated hash/summary) between echoed_config and current_config and include
job_id and the differing-keys/count in the message; update the call site that
uses echoed_config/current_config so it no longer interpolates full payloads but
logs the compact summary (or hashes) and ensure the function or helper you add
to compute the summary is used wherever pipeline drift is reported.

except Exception as e:
# Audit must not break result processing.
job.logger.warning(f"Pipeline config audit failed for job {job_id}: {e}")
job.logger.info(
f" Job {job_id} progress: {progress_info.processed}/{progress_info.total} images processed "
f"({progress_info.percentage*100}%), {progress_info.remaining} remaining, {progress_info.failed} failed, "
Expand Down
51 changes: 51 additions & 0 deletions ami/jobs/tests/test_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,57 @@ def test_tasks_endpoint_without_pipeline(self):
self.assertEqual(resp.status_code, 400)
self.assertIn("pipeline", resp.json()[0].lower())

def test_queue_images_to_nats_embeds_pipeline_config(self):
"""Tasks queued to NATS carry the pipeline config (including project overrides)."""
from unittest.mock import AsyncMock, MagicMock, patch

from ami.ml.models import ProjectPipelineConfig
from ami.ml.schemas import PipelineRequestConfigParameters

pipeline = self._create_pipeline()
pipeline.default_config = PipelineRequestConfigParameters({"example_param": "default"})
pipeline.save()
# _create_pipeline already called pipeline.projects.add(self.project) which
# created a ProjectPipelineConfig row; update it rather than creating a duplicate.
ProjectPipelineConfig.objects.filter(project=self.project, pipeline=pipeline).update(
config={"example_param": "project_override"}
)
Comment on lines +578 to +584
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Test does not verify default-key preservation in merge.

Line 578 and Line 583 only exercise an overridden key, so Line 619 cannot catch regressions where defaults are dropped instead of merged. Add one default-only key and assert it survives in task.config.

Suggested test tightening
-        pipeline.default_config = PipelineRequestConfigParameters({"example_param": "default"})
+        pipeline.default_config = PipelineRequestConfigParameters(
+            {"example_param": "default", "default_only_param": 7}
+        )
@@
         self.assertIsNotNone(task.config)
         self.assertEqual(task.config.get("example_param"), "project_override")
+        self.assertEqual(task.config.get("default_only_param"), 7)

Also applies to: 619-619

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ami/jobs/tests/test_jobs.py` around lines 578 - 584, The test currently only
asserts an overridden key survives the merge and misses verifying that
default-only keys are preserved; modify the test around
PipelineRequestConfigParameters usage (the pipeline.default_config assignment
and ProjectPipelineConfig.update call) to add a second key present only in
defaults (e.g., add "default_only": "value" to PipelineRequestConfigParameters)
and then assert that task.config contains both the overridden key
("example_param" with "project_override") and the default-only key; ensure the
update still only overrides the intended key via
ProjectPipelineConfig.objects.filter(...).update(...) and that the assertion
checks task.config (from whatever function creates the Task) for presence of the
default-only key.


job = self._create_ml_job("Config propagation test", pipeline)
job.dispatch_mode = JobDispatchMode.ASYNC_API
job.status = JobState.STARTED
job.save(update_fields=["dispatch_mode", "status"])

image = SourceImage.objects.create(
path="config_test.jpg",
public_base_url="http://example.com",
project=self.project,
)

published_tasks = []

mock_manager = AsyncMock()
mock_manager.log_async = AsyncMock()

async def capture_publish(job_id, data):
published_tasks.append(data)
return True

mock_manager.publish_task = capture_publish

mock_ctx = MagicMock()
mock_ctx.__aenter__ = AsyncMock(return_value=mock_manager)
mock_ctx.__aexit__ = AsyncMock(return_value=False)

with patch("ami.ml.orchestration.jobs.TaskQueueManager", return_value=mock_ctx):
with patch("ami.ml.orchestration.jobs.AsyncJobStateManager"):
queue_images_to_nats(job, [image])

self.assertEqual(len(published_tasks), 1)
task = published_tasks[0]
self.assertIsNotNone(task.config)
self.assertEqual(task.config.get("example_param"), "project_override")

def test_result_endpoint_stub(self):
"""Test the result endpoint accepts results (stubbed implementation)."""
pipeline = self._create_pipeline()
Expand Down
68 changes: 68 additions & 0 deletions ami/jobs/tests/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,74 @@ def test_process_nats_pipeline_result_error_job_not_found(self, mock_manager_cla
# Assert: Task was acknowledged despite missing job
mock_manager.acknowledge_task.assert_called_once_with(reply_subject)

@patch("ami.jobs.tasks.TaskQueueManager")
def test_pipeline_config_drift_logs_warning(self, mock_manager_class):
"""
When a worker echoes a pipeline config that doesn't match what Antenna
would resolve today (e.g. ProjectPipelineConfig was edited mid-job, or
the worker is stale), process_nats_pipeline_result must log a warning.
Drift is logged but not enforced.
"""
from ami.ml.schemas import PipelineRequestConfigParameters

self._setup_mock_nats(mock_manager_class)

self.pipeline.default_config = PipelineRequestConfigParameters({"example_config_param": 5})
self.pipeline.save()

# Worker echoes a config that doesn't match the current default
success_data = PipelineResultsResponse(
pipeline="test-pipeline",
algorithms={},
total_time=1.0,
source_images=[SourceImageResponse(id=str(self.images[0].pk), url="http://example.com/test_image_0.jpg")],
detections=[],
errors=None,
config={"example_config_param": 99},
).dict()

with self.assertLogs(level="WARNING") as cm:
process_nats_pipeline_result.apply(
kwargs={"job_id": self.job.pk, "result_data": success_data, "reply_subject": "reply.drift"}
)

self.assertTrue(
any("Pipeline config drift" in msg for msg in cm.output),
f"Expected drift warning in logs, got: {cm.output}",
)

@patch("ami.jobs.tasks.TaskQueueManager")
def test_pipeline_config_match_does_not_warn(self, mock_manager_class):
"""When echoed config matches current pipeline config, no drift warning is logged."""
from ami.ml.schemas import PipelineRequestConfigParameters

self._setup_mock_nats(mock_manager_class)

self.pipeline.default_config = PipelineRequestConfigParameters({"example_config_param": 5})
self.pipeline.save()

success_data = PipelineResultsResponse(
pipeline="test-pipeline",
algorithms={},
total_time=1.0,
source_images=[SourceImageResponse(id=str(self.images[0].pk), url="http://example.com/test_image_0.jpg")],
detections=[],
errors=None,
config={"example_config_param": 5},
).dict()

# assertLogs requires at least one log; capture INFO so the test doesn't
# spuriously fail when no WARNING is emitted (the assertion below).
with self.assertLogs(level="INFO") as cm:
process_nats_pipeline_result.apply(
kwargs={"job_id": self.job.pk, "result_data": success_data, "reply_subject": "reply.match"}
)
Comment on lines +532 to +577
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Drift tests currently run through a failing save path (missing detector).

These two tests construct a “successful” result but the fixture pipeline has no detection algorithm, so save_results can error independently of drift behavior. Add a minimal detector to keep the test on a clean happy path.

Proposed fix
     def test_pipeline_config_drift_logs_warning(self, mock_manager_class):
@@
-        self._setup_mock_nats(mock_manager_class)
+        mock_manager = self._setup_mock_nats(mock_manager_class)
@@
         self.pipeline.default_config = PipelineRequestConfigParameters({"example_config_param": 5})
         self.pipeline.save()
+        self.pipeline.algorithms.add(
+            Algorithm.objects.create(
+                name="drift-detector",
+                key="drift-detector",
+                task_type=AlgorithmTaskType.LOCALIZATION,
+            )
+        )
@@
         with self.assertLogs(level="WARNING") as cm:
             process_nats_pipeline_result.apply(
                 kwargs={"job_id": self.job.pk, "result_data": success_data, "reply_subject": "reply.drift"}
             )
+        mock_manager.acknowledge_task.assert_called_once_with("reply.drift")
@@
     def test_pipeline_config_match_does_not_warn(self, mock_manager_class):
@@
-        self._setup_mock_nats(mock_manager_class)
+        mock_manager = self._setup_mock_nats(mock_manager_class)
@@
         self.pipeline.default_config = PipelineRequestConfigParameters({"example_config_param": 5})
         self.pipeline.save()
+        self.pipeline.algorithms.add(
+            Algorithm.objects.create(
+                name="match-detector",
+                key="match-detector",
+                task_type=AlgorithmTaskType.LOCALIZATION,
+            )
+        )
@@
         with self.assertLogs(level="INFO") as cm:
             process_nats_pipeline_result.apply(
                 kwargs={"job_id": self.job.pk, "result_data": success_data, "reply_subject": "reply.match"}
             )
+        mock_manager.acknowledge_task.assert_called_once_with("reply.match")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ami/jobs/tests/test_tasks.py` around lines 532 - 577, Both tests are failing
due to save_results hitting a missing-detector path; before invoking
process_nats_pipeline_result in test_pipeline_config_drift_warns and
test_pipeline_config_match_does_not_warn, attach a minimal detector to the
fixture pipeline so save_results can proceed normally. Create and save a simple
Detector (or add an existing minimal detector object) and associate it with
self.pipeline (e.g., via pipeline.detectors.add(...) or the project’s detector
creation helper), ensure it is persisted, then call process_nats_pipeline_result
as before so the tests exercise only the config-drift logic.


self.assertFalse(
any("Pipeline config drift" in msg for msg in cm.output),
f"Did not expect drift warning for matching config, got: {cm.output}",
)


class TestTaskFailureGuard(TransactionTestCase):
"""
Expand Down
2 changes: 1 addition & 1 deletion ami/ml/models/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -1114,7 +1114,7 @@ def get_config(self, project_id: int | None = None) -> PipelineRequestConfigPara
This will be the same as pipeline.default_config, but if a project ID is provided,
the project's pipeline config will be used to override the default config.
"""
config = self.default_config
config = PipelineRequestConfigParameters(self.default_config or {})
if project_id:
try:
project_pipeline_config = self.project_pipeline_configs.get(project_id=project_id)
Expand Down
3 changes: 3 additions & 0 deletions ami/ml/orchestration/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ def queue_images_to_nats(job: "Job", images: list[SourceImage]):
job.logger.info(f"Queuing {len(images)} images to NATS stream for job '{job.pk}'")

# Prepare all messages outside of async context to avoid Django ORM issues
pipeline_config = job.pipeline.get_config(project_id=job.project.pk) if job.pipeline else None

tasks: list[tuple[int, PipelineProcessingTask]] = []
image_ids = []
skipped_count = 0
Expand All @@ -101,6 +103,7 @@ def queue_images_to_nats(job: "Job", images: list[SourceImage]):
id=image_id,
image_id=image_id,
image_url=image_url,
config=pipeline_config,
)
Comment on lines 89 to 107
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pipeline_config is a mutable dict and the same object is being attached to every PipelineProcessingTask. Because Pipeline.get_config() returns (and mutates) the underlying default_config dict, this means all queued tasks share a single config instance, which can lead to cross-task side effects if anything mutates it. Prefer passing an immutable snapshot (e.g., a shallow/deep copy) per task, or otherwise ensure the config is not shared/mutated after task creation.

Copilot uses AI. Check for mistakes.
tasks.append((image.pk, task))

Expand Down
4 changes: 2 additions & 2 deletions ami/ml/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ class PipelineResultsResponse(pydantic.BaseModel):
source_images: list[SourceImageResponse]
detections: list[DetectionResponse]
errors: list | str | None = None
config: PipelineRequestConfigParameters | dict | None = None


class PipelineResultsError(pydantic.BaseModel):
Expand All @@ -257,9 +258,8 @@ class PipelineProcessingTask(pydantic.BaseModel):
image_id: str
image_url: str
reply_subject: str | None = None # The NATS subject to send the result to
# TODO: Do we need these?
config: PipelineRequestConfigParameters | dict | None = None
# detections: list[DetectionRequest] | None = None
# config: PipelineRequestConfigParameters | dict | None = None


class ProcessingServiceClientInfo(pydantic.BaseModel):
Expand Down
29 changes: 29 additions & 0 deletions ami/ml/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,35 @@ def test_project_pipeline_config(self):
final_config = self.pipeline.get_config(self.project.pk)
self.assertEqual(final_config["test_param"], "project_value")

def test_get_config_does_not_mutate_default_config(self):
"""
get_config(project_id=...) must return a copy. Previously it returned
self.default_config directly and called .update() on it, leaking the
project override into the in-memory model attribute and into any
subsequent get_config() call.
"""
from ami.ml.models import ProjectPipelineConfig
from ami.ml.schemas import PipelineRequestConfigParameters

self.pipeline.default_config = PipelineRequestConfigParameters({"test_param": "default_value"})
self.pipeline.save()
ProjectPipelineConfig.objects.create(
project=self.project,
pipeline=self.pipeline,
config={"test_param": "project_value"},
)

# Resolve project-scoped config first; this used to mutate default_config
project_config = self.pipeline.get_config(self.project.pk)
self.assertEqual(project_config["test_param"], "project_value")

# default_config on the model must be untouched
self.assertEqual(self.pipeline.default_config["test_param"], "default_value")

# A subsequent default-only call must still return the unmodified default
default_config = self.pipeline.get_config()
self.assertEqual(default_config["test_param"], "default_value")

def test_image_with_null_detection(self):
"""
Test saving results for a pipeline that returns null detections for some images.
Expand Down
77 changes: 77 additions & 0 deletions docs/claude/planning/pipeline-config-job-level-fetch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Proposal: move pipeline config from per-task to job-level fetch

**Status**: draft, not implemented. Tracks a follow-up to PRs #1279 (Antenna) + ADC #146.

## Context

After #1279, every `PipelineProcessingTask` published to NATS carries a `config` field. All tasks within a single job share the same config — ADC's `rest_collate_fn` already encodes this assumption with `successful[0].get("config")` — so embedding the config redundantly in every task is informationally wasteful and structurally incorrect.

This is fine for the current shape of pipeline configs (a handful of small primitives like `example_config_param: int`). It will not stay fine once configs grow to include things like:

- A taxa allow-list for a CLIP-style classifier (potentially thousands of names)
- Per-stage hyperparameter overrides
- Feature-flag toggles for "roll up taxa on the ADC side", `include_features`, `include_softmax`
- Per-job model variant selection or threshold curves

A job with N images and a config of size M ships N×M bytes through NATS today, when it should ship M bytes once.

## Proposed shape

### Pull mode (NATS)

Add a job metadata fetch that ADC calls **once per job**, before iterating tasks:

```
GET /api/v2/jobs/{job_id}/
```

Comment on lines +24 to +27
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add a language to the fenced code block.

This block is missing a fence language and will trip markdownlint MD040 in repos where docs linting is enforced.

Proposed fix
-```
+```http
 GET /api/v2/jobs/{job_id}/
</details>

<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 24-24: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/claude/planning/pipeline-config-job-level-fetch.md` around lines 24 -
27, The fenced code block containing "GET /api/v2/jobs/{job_id}/" is missing a
language tag which triggers markdownlint MD040; update the fence for that block
(the snippet showing GET /api/v2/jobs/{job_id}/) to include a language such as
"http" (i.e., change the opening ``` to ```http) so the block is properly
annotated.

Response includes (among existing fields) the resolved pipeline config:

```json
{
"id": 7,
"pipeline_slug": "global_moths_2024",
"config": {
"include_features": true,
"taxa_allowlist": ["Lepidoptera", ...]
},
...
}
```

ADC fetches and caches this once per job (already does `_fetch_tasks` per-job; add a sibling `_fetch_job_meta`). `PipelineProcessingTask.config` becomes vestigial and can be deprecated in a follow-up after both sides have shipped.

The existing `AntennaJobsListResponse` only returns `id` and `pipeline_slug`; this would be a separate detail endpoint, not a change to the list endpoint.

### Push mode (sync HTTP `POST /process`)

The push path is request/response, not task-fetched, so there's no equivalent "fetch once" moment for the worker. Two reasonable options:

1. **Keep config on each `PipelineRequest`** (status quo). Simple. Wastes bandwidth on the wire, but most push-mode requests are small (single image or a handful), so the overhead is bounded. No worker-side change.

2. **Send config in a job-init handshake**. The push API would need a notion of a "job" that workers can register against, which they don't have today — push-mode services are stateless w.r.t. jobs. Adding job state to push-mode workers is a substantially bigger change (cache invalidation, eviction, multi-tenant memory growth) and not worth it for the current config sizes.

Recommendation: **(1) for push, (2) for pull.** Push-mode requests are already independent — there's no "session" to attach config to without inventing one. Pull-mode has a natural job boundary already; reuse it.

## Migration

Pull mode:
1. Antenna ships a `GET /api/v2/jobs/{id}/` endpoint that includes `config` in the response.
2. ADC adds `_fetch_job_meta()` and reads `config` from there; falls back to `task.config` if the meta endpoint returns 404 (older Antenna).
3. After ADC ≥ this version is the floor, Antenna removes the per-task `config` field.

Push mode: no change.

## Costs of doing it now vs. later

Doing it now: bigger PR than #1279, but the schema isn't fossilized yet — only one consumer (ADC) and zero data persisted with the per-task shape.

Doing it later: every external worker that adopts the per-task `config` field becomes a backwards-compat constraint. The longer the per-task shape is "the contract," the more painful the migration.

The audit log added in this PR (`ami/jobs/tasks.py:process_nats_pipeline_result`) becomes simpler under job-level config: compare once at job start, not on every result.

## Out of scope

- Authentication / permissions on the new job meta endpoint (use whatever ADC already uses for `/tasks/`)
- Schema versioning of `config` itself (separate problem; matters more once configs start carrying user-editable structures like taxa lists)
- UI for editing `ProjectPipelineConfig` (already exists in admin; no change needed)
22 changes: 22 additions & 0 deletions processing_services/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,28 @@ PipelineChoice = typing.Literal[
"new-pipeline-slug",
]
```
## NATS Pull-Mode (Async API) Contract

Processing services that operate in pull-mode (fetching tasks from Antenna via `POST /api/v2/jobs/{id}/tasks/`) receive `PipelineProcessingTask` objects. Each task now includes a `config` field carrying the pipeline configuration for that job:

```json
{
"id": "42",
"image_id": "42",
"image_url": "https://...",
"reply_subject": "antenna.results.job.7.img.42",
"config": {
"example_config_param": 3
}
}
```

`config` mirrors `PipelineRequest.config` from the synchronous HTTP path. It is derived from the pipeline's `default_config` merged with any per-project `ProjectPipelineConfig` override. It may be `null` if no config is set.

Workers should read `config` from each task and apply it to their processing. If `config` is absent or null, fall back to worker-level defaults (e.g. environment variables).
Comment on lines +95 to +97
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The contract says config “may be null if no config is set”, but with the current Antenna code path pipeline.get_config(...) always returns a dict (possibly empty) when a pipeline exists; tasks are not normally queued without a pipeline. Consider updating this to reflect actual behavior (empty object vs null), or clarify the specific scenario where null is expected.

Suggested change
`config` mirrors `PipelineRequest.config` from the synchronous HTTP path. It is derived from the pipeline's `default_config` merged with any per-project `ProjectPipelineConfig` override. It may be `null` if no config is set.
Workers should read `config` from each task and apply it to their processing. If `config` is absent or null, fall back to worker-level defaults (e.g. environment variables).
`config` mirrors `PipelineRequest.config` from the synchronous HTTP path. It is derived from the pipeline's `default_config` merged with any per-project `ProjectPipelineConfig` override. In the normal Antenna code path, it is an object; if no defaults or overrides are set, it may be an empty object.
Workers should read `config` from each task and apply it to their processing. If `config` is unexpectedly absent or `null` (for example, due to a malformed or legacy payload), fall back to worker-level defaults (e.g. environment variables).

Copilot uses AI. Check for mistakes.

When returning results, populate `PipelineResultsResponse.config` with the config that was actually used. Antenna logs a warning if the echoed config drifts from the pipeline's current configuration in the database (e.g. `ProjectPipelineConfig` was edited mid-job, or the worker is running stale config). Persisted audit trails are a future addition.

## Demo

## `minimal` Pipelines and Output Images
Expand Down
Loading