Skip to content

feat(tasks): first-class CANCELLED terminal status - #1137

Open
kyle-compute wants to merge 1 commit into
stagingfrom
feat/task-cancelled-status
Open

feat(tasks): first-class CANCELLED terminal status#1137
kyle-compute wants to merge 1 commit into
stagingfrom
feat/task-cancelled-status

Conversation

@kyle-compute

@kyle-compute kyle-compute commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Semantic rule

Task status reflects the execution outcome; the verdict columns independently reflect the judgment.

  • CANCELLED = a person or quota stopped the run. It is terminal, and it is not FAILED (pipeline broke) nor COMPLETED (all trials finished).
  • A kept SUCCESS verdict + payload stays valid on a cancelled task — they are separate columns, so the "failed task with an accepted verdict" confusion that motivated the QA verdict: accept/reject labels, plain prompt, keep verdict until replaced, scoped rerun #1120 COMPLETED override disappears, and the override is deleted.
  • Pre-existing rows are not backfilled; they keep the old FAILED/COMPLETED encoding.

Producers

Site Before After
cancel_tasks_runs (oddish/src/oddish/queue.py), task in PENDING/RUNNING FAILED, then COMPLETED if verdict SUCCESS (failed_by_this_cancel hack) CANCELLED; override deleted, verdict columns untouched
cancel_tasks_runs, task in ANALYZING/VERDICT_PENDING same FAILED-or-COMPLETED COMPLETED (see QA-cancel decision)
cancel_task_qa_core (oddish/src/oddish/core/endpoints/qa.py), VERDICT_PENDING task COMPLETED if verdict SUCCESS else FAILED COMPLETED unconditionally
_reconcile_cancelled_tasks (oddish/src/oddish/core/quota_enforcement.py), exhausted task FAILED CANCELLED (message/error fields unchanged)

Cancel-path audit: git grep -n "USER_CANCELLED_MESSAGE\|QUOTA_CANCELLED_MESSAGE" -- '*.py' — every non-test hit audited; the only task-status writers on cancel paths are the three functions above (the qa.py hits are verdict/analysis columns, slack_notifications.py is a read that already excludes user-cancelled verdicts and is unchanged).

QA-cancel decision: a task only reaches VERDICT_PENDING when every trial is terminal (maybe_start_qa_stage / backfill_task_analysis_core both enforce pending_count == 0). Cancelling there stops the judgment, not the run — the run finished. So the execution outcome is COMPLETED, and the cancelled judgment is fully recorded in the verdict columns (verdict_status = FAILED + "Cancelled by user", or the kept SUCCESS verdict restored by cancel_verdict). Writing CANCELLED would falsely claim the trials were stopped; keeping the old FAILED arm would falsely claim the pipeline broke. cancel_tasks_runs applies the same stage split so oddish cancel and POST /tasks/{id}/qa/cancel agree on the identical situation.

Quota-cancel decision: quota reaching a cap stops work; nothing broke — CANCELLED. In practice reconcile only sees tasks whose active trials were just cancelled (PENDING/RUNNING); message/error fields are unchanged.

Consumer sweep

  • oddish/ — all 50 TaskStatus. references audited. Terminal-set membership extended (mirroring FAILED) in: trial retry (endpoints/trials.py reset-to-RUNNING), append (queue.py), trial import resurrection (core/ingest/trial_imports.py), post-trial hook guard (workers/queue/trial_handler.py), both cleanup-sweep SQL terminal lists (workers/queue/cleanup.py _reset_orphaned_trial_analysis). resolve_task_status (core/helpers.py) makes CANCELLED sticky — otherwise the all-trials-terminal display rule would flip a cancelled task back to COMPLETED. CLI: format_task_status color map plus every ("completed", "failed") terminal check in api.py / status.py / pull.py. Retrying a trial of a CANCELLED task resurrects it, exactly like FAILED. ACTIVE_TASK_STATUSES deliberately unchanged (CANCELLED is terminal; test-asserted).
  • backend/git grep -n "TaskStatus\." backend/0 hits; routers treat status as an opaque string (browse statuses CSV filter passes uppercase names straight to TaskModel.status.in_, so the new filter value works with no backend change). Uppercase-literal audit found one consumer: preview_seed.py _TERMINAL_TASK_STATUSES (+"CANCELLED"). 'failed'/'completed' string-literal audit: no task-status SQL in backend outside that file.
  • frontend/TaskStatus union in src/lib/types.ts (+"cancelled"); browse sidebar STATUS_OPTIONS in src/lib/tasks-filters.ts (+CANCELLED, uppercase enum-name form per the values_callable comment there). The dashboard STATUS_FILTER_OPTIONS "Failures" bucket is deliberately untouched: _experiment_row_passes_status_filter keys off verdict_failed + failed_trials counts, which are verdict/trial-level and unchanged by this PR (trial statuses are out of scope). git grep '"failed"' frontend/src → 24 hits audited; all others are trial/report/verifier contexts. Active-state helpers (job-status.ts, experiment-client.tsx ACTIVE_TASK_STATUSES) need no change — cancelled is terminal.

Deliberately unchanged, for the record: TrialStatus / harbor_stage='cancelled' sentinels (out of scope), GitHub PR-comment formatter (renders trial+verdict statuses only), Slack qa-failed alert (already excludes "Cancelled by user" verdict errors), deletion.py _clear_stale_task_pipeline_status (fires only on ANALYZING/VERDICT_PENDING survivors of a scoped deletion — not a cancellation).

Migration

oddish/alembic/versions/cancel01_add_cancelled_task_status.pycore stack, not backend/alembic/: the taskstatus type is created by core migrations and the repo precedent for adding task-status values is there (m8n9o0p1q2r3 added ANALYZING/VERDICT_PENDING/COMPLETED). Mechanism mirrors skip01/qa01: op.get_context().autocommit_block() + ALTER TYPE taskstatus ADD VALUE IF NOT EXISTS 'CANCELLED' (labels are the uppercase member names — default SQLEnum), value not used in the same migration, no-op downgrade. Verified alembic upgrade head from an empty Postgres 16 (core then backend, same order as the Migration Upgrade Check), single head preserved (cancel01_add_cancelled).

Rolling compat

Investigated: the CLI never parses responses through the TaskStatus pydantic enum — oddish status/watch/pull consume raw JSON dicts (response.json()), and TaskStatusResponse is server-side serialization only. Precedent: the SKIPPED trial-status rollout (#574) shipped the same way with no API versioning. Exposure for already-released CLIs against a server that emits "cancelled":

  • No crash / no ValidationError anywhere.
  • oddish run (watch), oddish status --watch, oddish pull --wait, and experiment watch treat only completed/failed as terminal, so if the watched task is cancelled from elsewhere the loop keeps polling every 2s until the user interrupts it; status renders as plain uncolored cancelled text via the style_map fallback.
  • One-shot commands (ls, status, pull without wait) just display the new string; old dashboards count such a task as neither done nor running in the summary line.

No versioning system introduced (per scope); the exposure is called out in the CHANGELOG entry.

Tests

  • Targeted: test_cancel_task_status.py (new — cancel-active → CANCELLED with kept verdict untouched; cancel VERDICT_PENDING → COMPLETED with verdict FAILED+"Cancelled by user"; cancel PENDING → CANCELLED; ACTIVE sets exclude CANCELLED), test_quota_enforcement.py (exhausted task → CANCELLED, both user- and org-scope), test_retry_trial_core.py (new: retry on CANCELLED task → RUNNING), test_cleanup_orphaned_analysis.py (SQL literal assertions), plus cancel-harvest/CLI-status/verdict-sync/backfill files: 60 passed against Postgres 16.
  • Full oddish/ suite vs a clean origin/staging baseline run on the same DB: failure set is identical to baseline (3009+ passed; baseline failures are environmental — quota/org tables live in the backend migration stack, absent from a core-only test DB — plus one order-dependent flake in test_local_runner_gate.py that flip-flops on baseline and branch alike).
  • Full backend/ suite: failure list byte-identical to baseline (1105 passed; 45 pre-existing environmental failures — Clerk/env-dependent).
  • backend/tests/e2e (the repo's only CI pytest gate): 2 passed.
  • Frontend: pnpm exec tsc --noEmit clean; pnpm lint clean.
  • oddish/scripts/load_only_guard.py: OK (no new columns surfaced on the compact path).

https://claude.ai/code/session_01MzJSdrHKEqJrJHU7RRJ758


Note

Medium Risk
Touches core task lifecycle (cancel, quota, status resolution, retries) and enum migration; behavior changes are well-tested but affect many code paths and older CLI polling semantics.

Overview
Introduces a terminal cancelled task status so user- or quota-driven stops are no longer encoded as failed (or the old “failed then COMPLETED if verdict SUCCESS” override). Execution status and verdict columns are treated separately: a kept accept verdict can remain valid on a cancelled task, and metrics/automation that key off failed no longer count cancellations.

Cancel paths now set CANCELLED for active runs (cancel_tasks_runs, quota reconciliation) and COMPLETED when every trial is already done and only QA/judgment was stopped (ANALYZING / VERDICT_PENDING, including QA cancel). The verdict-success → COMPLETED hack on cancel is removed. resolve_task_status makes CANCELLED sticky so “all trials terminal” cannot flip a cancelled task back to completed.

Consumers treat cancelled like other terminal statuses: CLI watch/pull/status, trial retry/resurrect (same as failed), imports/appends, post-trial hooks, and orphaned-analysis cleanup SQL. The task browse UI adds a Cancelled filter; types and formatting include the new status.

Migration cancel01_add_cancelled adds the Postgres enum value with no row backfill. Older CLIs keep polling on cancelled until upgraded (documented in CHANGELOG).

Reviewed by Cursor Bugbot for commit 419f47d. Bugbot is set up for automated code reviews on this repo. Configure here.

A stopped run used to be dishonest about why it ended: user cancellation
wrote FAILED (implying the pipeline broke) unless a kept SUCCESS verdict
forced COMPLETED (implying all trials finished), and quota cancellation
always wrote FAILED — silently polluting failure metrics and anything
automated on FAILED.

The rule is now: task status reflects the execution outcome, the verdict
columns independently reflect the judgment.

- TaskStatus.CANCELLED (terminal; PG enum label 'CANCELLED' added by core
  migration cancel01_add_cancelled via ADD VALUE IF NOT EXISTS in an
  autocommit block, mirroring skip01/qa01).
- cancel_tasks_runs: an active run cancelled in PENDING/RUNNING lands
  CANCELLED; the failed_by_this_cancel COMPLETED override is deleted — a
  kept SUCCESS verdict + payload simply stays on the cancelled task.
- Cancelling in ANALYZING/VERDICT_PENDING (all trials finished, only QA
  stopped) lands COMPLETED, same rule as cancel_task_qa_core, whose
  FAILED arm is dropped for the same reason.
- Quota enforcement (_reconcile_cancelled_tasks): exhausted tasks land
  CANCELLED — quota stopped them, nothing broke.
- Consumers: CANCELLED is terminal everywhere FAILED was (append/retry/
  import resurrection to RUNNING, post-trial hook guard, cleanup-sweep
  SQL terminal sets, CLI wait loops and rendering, preview seed); it is
  sticky in resolve_task_status; browse filter gains a Cancelled option;
  frontend TaskStatus union extended. Trial-level statuses are untouched.

Older installed CLIs parse the status as an opaque string (no client-side
enum validation), so they render "cancelled" as plain text and do not
crash; their watch/pull wait loops treat only completed/failed as
terminal and keep polling a cancelled task until interrupted.

Claude-Session: https://claude.ai/code/session_01MzJSdrHKEqJrJHU7RRJ758
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
oddish-app Ready Ready Preview Aug 9, 2026 1:05am

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Risk: medium. Approved — Cursor Bugbot passed with no findings requiring human review; Cursor Security Agent was not present. No reviewers assigned.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Router and Approver

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Oddish preview

Commit: 419f47d43fc611f55417d18992addf3c44aee21f

Surface Link Target
Frontend https://pr-1137.oddish.app Vercel preview for 419f47d
Backend oddish-pr-1137 oddish-pr-1137
Database project fjcildxibjkstyparghk project fjcildxibjkstyparghk

Vercel deployment URL: https://oddish-da4i1fx7i.oddish.app

Plan:

  • Frontend deploy: true
  • Backend deploy: true
  • Migrations: true

This comment is updated by the PR Preview workflow.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant