Skip to content

fix(api): approve exactly the named tasks instead of the whole backlog (#1146) - #1152

Merged
frankbria merged 2 commits into
mainfrom
fix/1146-approve-inclusion-shape
Aug 11, 2026
Merged

fix(api): approve exactly the named tasks instead of the whole backlog (#1146)#1152
frankbria merged 2 commits into
mainfrom
fix/1146-approve-inclusion-shape

Conversation

@frankbria

Copy link
Copy Markdown
Owner

Closes #1146.

The bug, reproduced

ApproveTasksRequest was exclusion-shaped — excluded_task_ids, no task_ids — and Pydantic drops unknown fields by default. So the intuitive payload:

POST /api/v2/tasks/approve
{"task_ids": ["<one-task>"]}

returned 200 and transitioned every backlog task to READY. The exact inverse of the request, in silence.

Reproduced against the pre-fix router by running this PR's test on main's tasks_v2.py:

assert [statuses[t.id] for t in three_tasks[1:]] == [BACKLOG, BACKLOG]
E   AssertionError: assert [<TaskStatus.READY>, <TaskStatus.READY>] == [<TaskStatus.BACKLOG>, ...]

Ask for one task, get three.

Why it went unnoticed

Found while writing #1068's API lifecycle driver, which did exactly this. Its test "covered" scoped approval and passed anyway — the chosen task was READY, and so was everything else. A wrong result that looks right, which is the same shape as #1066, #1077 and #1085.

No production caller hits this route today (neither the web UI nor the CLI), which is why it was cheap to fix now and would have been expensive after a client existed.

The fix — options 1 and 2 from the issue, together

task_ids is a real field meaning "approve exactly these". The semantics live in runtime.approve_tasks(included_task_ids=...), not the router, so the CLI and any other surface inherit them — core-first.

model_config = ConfigDict(extra="forbid"), so the next mis-shaped payload is a 422 rather than a silent reinterpretation. {"taskIds": [...]} no longer approves the backlog.

Two ambiguities now refuse rather than resolve into a mutation:

Request Before Now
{"task_ids": [a]} 200, approves a+b+c 200, approves a
{"task_ids": [a], "excluded_task_ids": [b]} 200, approves a+b+c 422, nothing changes
{"task_ids": [a, "nope"]} 200, approves a+b+c 422, nothing changes
{"taskIds": [a]} 200, approves a+b+c 422, nothing changes

The unknown-id case matters on its own: approving fewer tasks than named, quietly, is the same class of bug. Each of those rows has a test asserting no task changed status, not just the code.

The exclusion shape is untouched — this adds, it does not replace. Two tests pin that, including the empty-body "approve the whole backlog" behaviour.

Option 3 — not taken here

The issue offers a repo-wide sweep setting extra="forbid" on every v2 request model. That is a much larger change with its own risk (any client sending a stray field starts getting 422s), and it deserves its own PR and its own audit of what currently gets dropped. Not filed as a follow-up yet — worth deciding whether the sweep is wanted before creating an issue for it.

Testing

  • tests/ui/test_task_approval_shape_1146.py — 12 tests; verified the key one fails on main's router (transcript above) and the extra="forbid" pair fails when that line is removed
  • tests/ui/test_batch_execution_offload.py — two stub lambdas had to widen. Worth noting: a non-autospec stub is exactly what let the signature drift stay invisible until the call failed
  • Full backend suite + ruff reported below

#1146)

`ApproveTasksRequest` was exclusion-shaped — `excluded_task_ids`, no
`task_ids` — and Pydantic drops unknown fields by default. So the intuitive
inclusion payload

    POST /api/v2/tasks/approve  {"task_ids": ["<one-task>"]}

returned 200 and transitioned EVERY backlog task to READY: the exact inverse of
the request, in silence. Reproduced against the pre-fix router — asking for one
task leaves the other two READY where the caller expected BACKLOG.

Found while writing #1068's API lifecycle driver, which did exactly this. Its
test "covered" scoped approval and passed anyway, because the chosen task *was*
READY — and so was everything else.

Options 1 and 2 from the issue, together:

- `task_ids` is now a real field meaning "approve exactly these". The semantics
  live in `runtime.approve_tasks(included_task_ids=...)`, not the router, so the
  CLI and any other surface inherit them (core-first).
- `model_config = ConfigDict(extra="forbid")`, so the NEXT mis-shaped payload is
  a 422 rather than a silent reinterpretation. `{"taskIds": [...]}` no longer
  approves the backlog.

Two ambiguities refuse rather than resolve into a mutation: both lists at once
is 422, and an id that is not an approvable BACKLOG task is 422 — approving
fewer tasks than named, quietly, is the same class of bug this fixes. Both
assert that nothing changed status.

The exclusion shape is untouched; this adds, it does not replace.

tests/ui/test_batch_execution_offload.py stubbed approve_tasks with a lambda
whose signature had to widen. Worth noting that a non-autospec stub is what let
the drift be invisible until the call actually failed.
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 4m 58s —— View job


GLM Precision Review — PR #1152

Reviewed for concrete defects only (logic, security, data-loss, contract breaks). Style/architecture/naming/coverage are covered by the general review and intentionally not addressed here.

  • Gather PR context (gh pr view, gh pr diff, existing comments)
  • Read changed files + surrounding code in checked-out repo
  • Confirm each suspicious hunk against real context (callers/callees)
  • Post inline comment for confirmed finding
  • Post summary table

Findings

Severity Location Finding
major codeframe/core/runtime.py:1271 list_tasks(status=BACKLOG) keeps the default limit=100; the new inclusion path validates named IDs against that capped set, so a real BACKLOG task beyond position 100 is falsely reported "unknown/not in BACKLOG" and returns 422 — it can never be approved. Fix: pass limit=None (the convention used by the sibling call at runtime.py:1402 and dependency_analyzer.py:224, per #743).

Notes on what I checked and cleared

  • 422→500 masking: the inner except ValueError → HTTPException(422) propagates correctly — the outer block has except HTTPException: raise before the catch-all 500. No masking.
  • Atomicity of the unknown-id rejection: approve_tasks raises before the transition loop, so a request naming a bad ID changes no task statuses (confirmed by the PR's own "not a partial approval" test).
  • Mutual-exclusion guard: router (task_ids is not None and excluded_task_ids) and core (excluded_task_ids and included_task_ids) agree; task_ids=[] + non-empty excluded is rejected at the model, and excluded=[] + inclusion resolves to inclusion correctly.
  • api_error(str(e), "INVALID_TASK_IDS"): valid — code is typed str, so the literal is accepted (minor: inconsistent with the ErrorCodes.X convention used elsewhere, out of scope here).
  • extra="forbid": does not regress known callers — {} and {"excluded_task_ids": [...]} still parse; only unknown fields 422.
  • Edge {"task_ids": null}: resolves to exclusion mode (approves all backlog), consistent with the documented empty-body behavior — not a defect.
  • Confirmed no other caller of approve_tasks exists (only the router), so the new ValueError/signature can't break a CLI/v1 path.

@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review

Solid, well-scoped fix. The core logic is correct and the test coverage is genuinely good — it pins the "wrong result that looks right" failure mode (scoped approval where the untouched tasks happened to already be READY), not just the happy path.

Correctness checks I traced through:

  • ApproveTasksRequest.excluded_task_ids defaults to [] (not None) via default_factory=list, so _validate_selection's self.excluded_task_ids truthiness check and runtime.approve_tasks's if excluded_task_ids and included_task_ids are consistent — both only fire the "ambiguous" error on a non-empty exclusion list, matching intent.
  • {"task_ids": []} (explicit empty list, not omitted) takes the inclusion branch and approves nothing (excluded = approvable - wanted = approvable). That matches the stated "approve exactly these and nothing else" semantics, though it's a slightly surprising way to ask for a no-op — worth a code comment if it ever surprises a caller, not blocking.
  • The outer except HTTPException: raise in approve_tasks_endpoint correctly re-raises the inner 422 before the catch-all turns it into a 500 — easy thing to get wrong with nested try/except and it's handled properly.
  • excluded_task_ids field name doubling as both a request field and the ApprovalResult attribute reads fine in context; no actual collision.

Minor nit (non-blocking): codeframe/ui/routers/tasks_v2.py:703 uses a bare string literal "INVALID_TASK_IDS" for the error code instead of a constant on ErrorCodes (response_models.py). Every other api_error(...) call in this file (17 of them) uses ErrorCodes.XXX. Either add INVALID_TASK_IDS to that class, or reuse ErrorCodes.VALIDATION_ERROR/INVALID_REQUEST if a new constant feels like overkill for one call site.

Design call I agree with: declining to do the repo-wide extra="forbid" sweep here and flagging it as a separate, riskier PR (Option 3) is the right scope discipline per this file's own precedent — this PR touches exactly the one router that had a live bug.

Test coverage: thorough — inclusion, exclusion (unchanged), both-lists-ambiguous, unknown-id, unknown-field, and the core-level runtime.approve_tasks tests. The test_batch_execution_offload.py stub-signature widening is a correct, minimal follow-on (and the PR description's own callout — that a non-autospec stub let this kind of drift go unnoticed — is a good observation for that file, not something this PR needs to fix).

No security or performance concerns; this is a pure request-validation/semantics fix with no new I/O paths.

Comment thread codeframe/core/runtime.py Outdated
Review finding, and the exclusion path had it too. `tasks.list_tasks` defaults
to `limit=100` (#743) and `approve_tasks` took that default — so "approve
everything" silently approved the first 100 of a larger backlog, and the new
inclusion path would 422 a perfectly valid id that sorted past the cap.

That is the same failure this PR exists to remove: quietly doing less than
asked, with a 200.

Three tests over a 105-task backlog, all three verified to fail without
`limit=None`. My own `_statuses` helper had the identical bug — it read a
single page, so the "everything is READY" assertion was true of 100 tasks out
of 105.
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 3m 33s —— View job


GLM Precision Review — PR #1152 (re-review after `04ccd65)

Reviewed for concrete defects only (logic, security, data-loss, contract breaks). Style/architecture/naming/coverage are covered by the general review and intentionally not addressed here.

  • Gather PR context (gh pr diff, existing comments)
  • Verify the limit=100 fix in newest commit 04ccd65
  • Read changed files + surrounding code in checked-out repo
  • Confirm each suspicious hunk against real context (callers/callees)
  • Post inline comment for any confirmed finding — none
  • Post summary table

Outcome

GLM review: no new defects found. The single previously-reported finding is resolved.

Prior finding — FIXED & verified. The list_tasks(status=BACKLOG) call at codeframe/core/runtime.py:1275 now passes limit=None. Confirmed against tasks.list_tasks (tasks.py:497: limit_clause = "" if limit is None else "LIMIT ?"), which is the documented opt-out for uncapped bulk reads (#743). The fix covers both paths — the inclusion path that would have 422'd a valid id past position 100, and the pre-existing exclusion path that was silently approving only the first 100 of a larger backlog. Three new tests over a 105-task backlog pin it, and the author also fixed the identical bug in the test's own _statuses helper.

What I re-verified and cleared (no action needed)

  • Inclusion math (runtime.py:1277-1285): for BACKLOG={a,b,c}, task_ids=["a"]wanted={a}, unknown={}, excluded={b,c} → exactly a approved, b,c left BACKLOG. Traced correct for the empty-list, all-named, unknown-id, and duplicate-id cases.
  • Mutual-exclusion guard is consistent across both layers: model _validate_selection (task_ids is not None and excluded_task_ids) and core (excluded_task_ids and included_task_ids) use the same non-empty-truthy logic, so task_ids=[...] + empty excluded_task_ids=[] correctly resolves to inclusion at both levels.
  • 422 vs 500: the inner except ValueError → HTTPException(422) is re-raised by the outer except HTTPException: raise (tasks_v2.py:750-753) before the catch-all 500 — no masking.
  • Atomicity of rejection: the unknown-id ValueError raises before the transition loop, so a request naming a bad id changes no task statuses (asserted by the PR's own test_an_unknown_task_id_is_422_not_a_partial_approval).
  • extra="forbid" contract break: none. Grep confirms no production caller (web UI or CLI) hits POST /api/v2/tasks/approve today — the only matches are docstrings/messages — so no client can be broken by the stricter parsing.
  • Signature change: backward-compatible (included_task_ids defaults to None); tasks_v2.py:695 is the sole caller (the runtime.py:1259 hit is the docstring example).

No security, data-loss, or contract concerns. This is a pure request-validation/semantics fix; the newest commit cleanly closes the one real defect the prior review raised.

@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review

I reviewed the full current diff (both commits, including the limit=None fix pushed after the earlier reviews on this PR). Confirming prior findings and their resolution rather than repeating detail already covered:

GLM's major finding (limit=100 truncation) is fixed. The second commit (04ccd65) adds limit=None to the tasks.list_tasks(...) call in runtime.approve_tasks (codeframe/core/runtime.py:1271), matching the sibling convention at runtime.py:1402 / dependency_analyzer.py:224 per #743. I traced tasks.list_tasks's limit docstring (codeframe/core/tasks.py:487-488) to confirm None really means uncapped, not zero. The new tests in TestABacklogLargerThanThePageSize (105-task backlog) cover exactly this, and the PR description states all three were verified to fail without the fix — credible given the identical bug was independently found in the _statuses test helper too.

My earlier correctness checks still hold against the current code: the mutual-exclusion guard (router _validate_selection + core if excluded_task_ids and included_task_ids) agree on truthy-list semantics; the inner except ValueErrorHTTPException(422) is correctly re-raised before the outer catch-all in approve_tasks_endpoint; only one caller of runtime.approve_tasks exists (the router), so the new required-keyword and ValueError behavior can't silently break another surface.

Still open, still non-blocking: codeframe/ui/routers/tasks_v2.py:703 uses the bare string literal "INVALID_TASK_IDS" instead of an ErrorCodes constant — the one inconsistency in an otherwise ErrorCodes.XXX-only file. Worth a one-line follow-up (add INVALID_TASK_IDS to ErrorCodes in response_models.py) whenever this file is touched again; not worth blocking on for a bug-fix PR.

Scope discipline: declining the repo-wide extra="forbid" sweep (Option 3) and fixing only the router with a live bug is the right call — it avoids introducing 422s on every other v2 endpoint's undocumented-but-tolerated extra fields without an audit.

Tests: the new tests/ui/test_task_approval_shape_1146.py suite is thorough and, notably, asserts the negative space (untouched tasks stay BACKLOG) that let the original bug hide behind a passing test — exactly the failure mode described in the PR. The test_batch_execution_offload.py stub-signature widening is minimal and correct.

No new security or performance concerns beyond what's already been discussed on this thread. This looks ready to merge from a correctness standpoint.

@frankbria

Copy link
Copy Markdown
Owner Author

Review finding — a second, older bug in the same shape

codex review (P2, correct):

When a workspace has more than 100 BACKLOG tasks, tasks.list_tasks() uses its default limit=100, so the new task_ids inclusion path only treats the first 100 tasks as approvable.

Right — and the exclusion path had it all along. approve_tasks took the default limit=100 (#743), so POST /approve with an empty body silently approved the first 100 tasks of a larger backlog and reported success. That is the same defect this PR exists to remove: quietly doing less than asked, with a 200.

Fixed with limit=None, and three tests over a 105-task backlog:

Test Fails without the fix
approving everything reaches past the page size
a task past the page size can be named
the excluded count covers the whole backlog

All three verified failing against the un-fixed runtime.py.

My own _statuses test helper had the identical bug — it read a single page, so set(statuses.values()) == {READY} was true of 100 tasks out of 105 while looking like a whole-backlog assertion. Caught because the named-task test then KeyError'd on a task the helper could not see.

Checks

Full backend suite 6496 passed, 49 skipped, 463s
uv run ruff check . clean
CI all 14 green
Third-party review codex review, one P2, fixed above

Demo — the inversion, before and after

Three BACKLOG tasks, asking for one:

# main's router
POST /api/v2/tasks/approve {"task_ids": ["<task-0>"]}   → 200
statuses: [READY, READY, READY]     ← asked for one, got three

# this branch
POST /api/v2/tasks/approve {"task_ids": ["<task-0>"]}   → 200
statuses: [READY, BACKLOG, BACKLOG]
POST /api/v2/tasks/approve {"taskIds": ["<task-0>"]}    → 422, nothing changes
POST /api/v2/tasks/approve {"task_ids": ["<task-0>", "nope"]} → 422, nothing changes

@frankbria
frankbria merged commit 0c60fca into main Aug 11, 2026
14 checks passed
@frankbria
frankbria deleted the fix/1146-approve-inclusion-shape branch August 11, 2026 05:53
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.

[P2.38] POST /api/v2/tasks/approve silently approves everything when sent an inclusion-shaped payload

1 participant