Repository navigation
Conversation
Add a nullable, operator-facing `failure_reason` to `TaskHistory`, populated at each failure site that already determines a reason and serialized on `TaskHistoryResponse`, so a failed run says why without the operator opening its log. `TaskHistory.set_failure_reason` is the single write path: it collapses a reason to one line and bounds it at `MAX_FAILURE_REASON_LENGTH`, mapping blank to `None`. `TaskHistoryStatusEnum.operator_summary` is the single prose source, so the alert summary and the stored reason cannot drift. Reasons are composed from structural facts only — step name and exit code for Nomad, exception type and resolved callable path for Celery, canned prose for lost / stale / unlaunchable. The traceback and the executor's own output keep going to the stderr log and are never used as the reason. Also repair `POST /history/`, which could not have served a request: it logged `task.name` on a `TaskHistory` (`AttributeError`), and its response model needed `task` and `execution_request`, which `save` re-defers. It now re-reads the saved row with both loaded. Found while covering the new normalization on that write path. Existing rows read NULL and are not backfilled, so a missing reason means "unknown", not "did not fail".
Read the failing producing step and its state in one pass over the allocation's task states, rather than re-reading the whole mapping per step through `_alloc_step_state`. Drop the optional `:type` directives from `TaskHistoryBase`'s docstring — the annotations are the source of truth — and promote the duplicated `tasks_alembic_config` fixture into a `conftest.py` for the Tasks-track migration tests. Give the downgrade test a positive control so it cannot pass by asserting absence against a table that is not there.
`_failed_step_reason` returned the first failed producing step in the allocation's serialization order. Nomad's JSON encoder emits map keys sorted, so a run whose payload failed and whose cleanup then also failed was reported as `Step 'clean-up' failed` — blaming SEP's own cleanup for the payload's failure. Walk the steps through `_alloc_step_sort_key`, the ordering the module already uses elsewhere, so the earliest failure wins. Render the enum's status prose as a standalone sentence rather than prefixing it with "The run". The fragments are verb phrases written for the mid-sentence slot in `alert_for_status`, where "execution tracking lost" reads correctly; "The run execution tracking lost." does not. Clear the reason on the Celery executor's success arm too, so "a non-failed status carries no reason" holds at every terminal writer rather than only the Nomad ones. Add the changelog fragment owed for the `POST /history/` repair, and correct the bound constant's comment, which claimed every composed reason sits well under it — the payload-resolution reason embeds a filesystem path and is unbounded when composed.
There was a problem hiding this comment.
🟡 Changes recommended
Failure-reason semantics permit contradictory records and potentially expose unvalidated callable data.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds normalized failure reasons to task-history payloads, backed by an additive Tasks migration and comprehensive executor/API coverage. It also repairs task-history creation serialization.
Changes:
- Persists bounded failure reasons for Nomad, Celery, and dispatch failures.
- Exposes the field through Tasks and SEP APIs.
- Repairs
POST /history/and updates schemas, clients, tests, and changelogs.
File summaries
| File | Description |
|---|---|
app/tasks/models.py |
Defines failure-reason storage and summaries. |
app/tasks/routes.py |
Persists and returns normalized reasons. |
app/tasks/celery.py |
Saves pre-dispatch failure reasons. |
app/tasks/execution/models.py |
Clears reasons on forced stops. |
app/tasks/execution/executors/nomad/models.py |
Composes Nomad failure reasons. |
app/tasks/execution/executors/celery/models.py |
Composes Celery failure reasons. |
app/tasks/migrations/versions/2026_09_07_1500-c4b8e1f7a2d9_add_taskhistory_failure_reason.py |
Adds the nullable column. |
tests/app/tasks/test_routes.py |
Covers Tasks API serialization and creation. |
tests/app/tasks/test_models.py |
Covers normalization and status summaries. |
tests/app/tasks/test_celery.py |
Covers persistence through worker flows. |
tests/app/tasks/migrations/conftest.py |
Provides Tasks migration fixtures. |
tests/app/tasks/migrations/test_taskhistory_failure_reason.py |
Tests upgrade and downgrade. |
tests/app/tasks/execution/test_models.py |
Tests stop behavior. |
tests/app/tasks/execution/executors/nomad/test_models.py |
Tests Nomad reason composition. |
tests/app/tasks/execution/executors/celery/test_models.py |
Tests Celery reason composition. |
tests/app/sep/api/routes/test_task_history.py |
Tests SEP proxy propagation. |
frontend/packages/api/specs/tasks.json |
Updates Tasks OpenAPI schema. |
frontend/packages/api/specs/sep.json |
Updates SEP OpenAPI schema. |
frontend/packages/api/src/generated/tasks.ts |
Regenerates Tasks types. |
frontend/packages/api/src/generated/sep.ts |
Regenerates SEP types. |
changelog.d/SEP-2000.added.md |
Documents failure reasons. |
changelog.d/SEP-2000.fixed.md |
Documents the create-route repair. |
Review details
- Files reviewed: 20/22 changed files
- Comments generated: 3
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
Follow-up — out of scope for this PR, surfaced while reviewing it.
SEP-1656 established Traced statically, not executed. Nothing in this PR touches |
Automated QA — PASSVerified the Tested items against a fresh instance on the PR head, exercising the Tasks execution framework end to end with a real Nomad node and Celery workers.
Observations — pre-existing, out of scope
|
…llers The helper's explicit TaskHistory return type sharpened inference on these call sites, surfacing that BaseSQLModel.id is int | None while both the helper and maybe_record_run take int. Every id here comes off a row that is already persisted, so the cast records that invariant rather than widening either contract.
Coverage reportClick to see where and how coverage changed
This report was generated by python-coverage-comment-action |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Tracked 2 follow-ups from this PR:
|













Summary
A failed run now says why on the task-history payload, so an operator does not have to open its log to learn anything.
Adds a nullable
failure_reasoncolumn toTaskHistoryvia one additive migration on the tasks Alembic track, with no backfill. It is declared onTaskHistoryBase, so it reaches the table model andTaskHistoryResponsetogether and flows through the Tasks API list/retrieve payloads and the SEP proxy that re-serves them with no serialization change.Two single points of enforcement:
TaskHistory.set_failure_reason()is the only write path. It collapses whitespace runs (including newlines) to single spaces, maps a blank value toNonerather than"", and truncates throughshorten_textatMAX_FAILURE_REASON_LENGTH(500). The column is declared as plainstr/TEXT rather thanVARCHAR(500)deliberately: PostgreSQL enforces a declared length and SQLite does not, so a bound declared in the schema would fail in production in a way the test suite structurally cannot observe. The bound is enforced at write time and pinned by a test instead.TaskHistoryStatusEnum.operator_summary()is the only prose source. The four operator-facing fragments move offalert_for_statusonto the enum, and both the alert summary and the stored reason now read them from there, so the two cannot drift. The four strings are byte-identical to the literals they replace;alert_for_status's dedup keys, severities, alert classes,SUCCESSresolve arm and final payload are untouched, and its existing tests pass unmodified.Reasons are composed only from structural facts SEP already owns:
_persist_failed_dispatch(unhealthy target, unresolvable payload)lost/stale/unlaunchableThe exit code is read off the step's last
Terminatedevent, so a step Nomad restarted reports the termination the allocation ended on rather than its first attempt's. The failing step itself is chosen by execution order, through the same_alloc_step_sort_keythe module already uses, not by the order Nomad serialized the task states in. Nomad's JSON encoder emits map keys sorted, so picking the first key would reportStep 'clean-up' failedfor a run whose payload failed and whose cleanup then failed after it — blaming SEP's own cleanup for the payload's failure.The enum's fragments are verb phrases sized for
alert_for_status's mid-sentence slot, so the stored reason renders them as standalone sentences (Execution tracking lost.) rather than giving them a subject —The run execution tracking lost.is not a sentence.The Celery path takes the exception type and callable path rather than
str(exc). That path resolves an arbitrary dotted callable fromtask.data["callable"]and runs it in-process, so a rendered exception can carry a DSN with credentials or quoted row data, and this field has no masking.Task.datais a raw JSON column with no validator behind it, so the callable path itself is echoed only when it is astrunderCELERY_CALLABLE_ALLOWED_PREFIX; any other stored value composes the genericTask execution raised <Type>.The traceback still goes to the stderr log unchanged. For the same reason the Nomad composer reuses only the step name and exit code, never the Nomad event's own description text, which can carry a driver-supplied exit message._apply_terminal_statussets a reason on every arm,Noneincluded, so "a non-failed status carries null" is an invariant of the writer rather than an absence.BaseExecutor.stop_taskclears the reason only on its forced-STOPPED arm — a run the sync had already resolved as failed keeps the reason explaining why, which is what that method's docstring requires.Also in this diff:
POST /history/is repairedUnrelated to the feature, found while covering the new normalization on that write path. The route could not have served a request on
main:task.nameon aTaskHistory, which has nonameattribute —AttributeErroron every call.response_modelrequirestaskandexecution_request, andTaskHistoryManager.savere-defersexecution_request, so serializing the save's own return value attempts lazy IO from an async context (MissingGreenlet).It now logs
task_idand re-reads the saved row withtaskjoined andexecution_requestundeferred. Two tests cover it, and it ships its ownfixedchangelog fragment. No in-repo caller uses this route — every other/history/reference is a GET — which is why the breakage went unnoticed.No authorization or identity change anywhere in this diff.
create_task_historykeepsIsAuthenticatedDep; no guard, principal or role requirement is added, removed or altered. An authenticated caller can still supply arbitrary text infailure_reason— normalization bounds the shape, not the content — exactly as that route already acceptsexecution_request,statusandexecuted_by. Tightening the create contract is a separate concern.Historic rows read
NULL, and aNULLon afailedrow means "unknown", not "did not fail". The migration docstring and the changelog fragment both say so.Scope note
The route repair above is delivered beyond what any acceptance criterion names; it contradicts none of them. Recorded here rather than left silent so the ticket and the diff do not disagree.
Tested
file://payload reference is missing; confirm the history row reports that the payload could not be resolved and names the task.task_names(the merge and passthrough paths); confirm the reason appears on both.Checklist
make makemigrations)