fix(jobs): let a requested cancel survive a fresh read of the run - #7680
Conversation
JobSDK.cancel sets an in-memory threading.Event and writes nothing, and _public_view served no field for it -- so the only report of a requested cancel was the response to the cancel call itself. Any client that re-read the run (a reload, a second tab, the fresh mount the SDK exists to serve) saw `running` and no evidence the button had worked, for as long as the worker took to reach its next checkpoint. Persisting the request from cancel() would fix the symptom by breaking the rule that makes the record trustworthy: the worker is the sole writer of that run's file, and atomic_write gives crash-safety but not mutual exclusion, so a second writer silently drops the loser. So the fact is DERIVED on read instead. JobSDK.cancelling_ids reads the live table -- the same table reconcile already derives liveness from -- and _public_view serves `cancelling` from it. Nothing new is written. A terminal run is never `cancelling`: the worker writes the record before _execute drops the live entry, so without that guard a read landing between the two would report `status: cancelled` alongside `cancelling: true`. The snapshot is taken once per response, off the loop (_persist holds the same lock across a disk write), and after the record read so a cancel arriving mid-request is reported rather than missed. Closes #7589
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS A read-side derivation from state the process already holds, correctly refusing the second-writer alternative; additive contract, coherent restart story, races reasoned in the safe direction. [DESIGN-REVIEWED] 8eee286 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsNo candidates were produced by the discovery pass, and my own inspection confirms the change is sound: the No findings. [OPUS-REVIEWED] 8eee286 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All mechanical checks are done: the First-Principles-Verdict: CONCERNS The fix is real and cause-level, but its one new public field has zero counted consumers — the defect stays human-invisible until an unbuilt pill lands. What this change shipsIntent: make a requested cancel visible to any later read of a job run, not just the cancel response — a FIX.
Watch
[FIRST-PRINCIPLES-REVIEWED] 8eee286 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- src/kiro_crew/apps/job_routes.py:149 -- False positive or not applicable? A repository writer can comment: |
bolichen97
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (4 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: fix with a clear root cause -- a requested cancel must survive a fresh read of the run, derived on read from the live table rather than persisted, preserving the single-writer rule.
iamwhatever
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (4 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: fix -- a requested cancel was invisible to any later read because cancel deliberately writes nothing (single-writer rule), so the fix derives it on read via the new cancelling_ids() snapshot and surfaces it as a cancelling field, with one snapshot per response and terminal runs masked; 2 source files plus their 2 test files, no auth/input-parsing/trust-boundary/sandbox/gate/secret surface touched.
What is the problem?
JobSDK.cancelsets an in-memorythreading.Eventon the live handle andwrites nothing (
src/kiro_crew/apps/job_sdk.py)._public_viewserved no fieldfor that request (
src/kiro_crew/apps/job_routes.py), so the ONLY report of arequested cancel was the body of the cancel call itself:
_handle_cancel's{"cancelling": true, ...}.Every later read said nothing.
GET /_jobs/{run_id}andGET /_jobs/activeboth returned the run's status unchanged - still
running- until the workerreached its next checkpoint. For a runner that checkpoints minutes apart, that
window is minutes.
Why this issue matters to the user
Surviving navigation is the Job SDK's entire thesis. Its own module docstring
says the SDK exists because "a task of mine is running" had no server-side
representation: the fact lived in the React component that started it, so
navigating away destroyed the fact while the work kept going.
cancellingwas the one fact still living only in that component. A UI couldshow "cancelling..." for exactly as long as the tab that asked stayed mounted. A
reload, a navigation away and back, a second tab, or the fresh mount the SDK was
built to serve all saw
runningand no evidence the button had worked - so theuser's own action was the one thing the durable record could not tell them about.
How our fix solves it
The obvious fix is the wrong one, and that is the whole shape of this change.
Writing
cancel_requested_atfromcancel()would make it a second writeron that run's file. One-writer-per-run is load-bearing rather than incidental:
atomic_writegives crash-safety but not mutual exclusion, so two writers onone path silently drop the loser's update. That would be a worse bug than the
one being fixed.
So the fact is derived on read instead of written down:
JobSDK.cancelling_ids()returns the run ids whose handle has itscancelledevent set, read from the live table under the SDK's own lock. This is the same
table
reconcilealready derives liveness from - no new state, no new writer,single-writer rule untouched.
_public_viewservescancelling: boolfrom that snapshot, so every read path(
start,active,recent,{run_id},cancel) carries it.Chained from symptom to cause: the symptom is a re-read that shows
running; thecause is that the request was never readable anywhere but the cancel response;
the constraint is that it must not become writable; the remedy is a read-side
derivation from the process that already holds the fact.
Three details the chain forced:
cancelling. The worker writes the record and then_executedrops the live entry, so a read landing between those two wouldotherwise serve
status: cancelledtogether withcancelling: true- the samecancel both finished and still outstanding.
_persistholds that same lockacross a disk write, so acquiring it on the loop could park the gateway for the
length of that write.
_read_with_cancellingpairs the snapshot with the storeread that was already off-loop work, so no hop is added.
let two rows of a list disagree about a cancel that landed mid-render; taking
it after the record means a cancel arriving during the request is reported
rather than missed.
cancellingis a required argument to_public_view, not a defaulted one:a future call site that forgets it is a type error rather than a response that
quietly says
false.Not durable across a restart, deliberately - a restarted gateway has no live
table and
reconcilehas already resolved the run tointerrupted, so there isno pending cancel left to report.
What tests we did
Six new tests, all verified red on base (
KeyError: 'cancelling'on theroutes;
AttributeError: 'JobSDK' object has no attribute 'cancelling_ids'onthe SDK) by reverting only the two source files and re-running:
test_requested_cancel_is_visible_to_a_later_read_of_the_runGET {run_id}after the cancel reportscancelling: trueANDstatus: running- the reload / second-tab casetest_requested_cancel_is_visible_in_the_active_listGET /active, the list a fresh mount adopts, carries it tootest_a_run_nobody_cancelled_reads_not_cancellingfalsetest_a_recorded_cancel_is_no_longer_cancellingcancellingisfalse(the terminal guard)TestCancellingIds::test_request_is_reported_until_the_worker_records_itrunning, gone once the worker recordsCANCELLEDTestCancellingIds::test_a_refused_cancel_reports_nothingThe route tests use a runner whose next checkpoint is deliberately far away -
it does not poll
handle.cancelleduntil the test releases it - so the windowunder test is held open rather than raced. Each release happens in a
finally,so an assertion failure cannot park a worker for the rest of the session.
Gates run locally:
test_job_routes.py+test_job_sdk.py114 passed,flake8clean,mypyclean on both changed modules,black --checkclean onall four files, and
scripts/check_black_formatting.pypasses in scope.Any other suggestions on the work
ships with no
_jobsconsumer (grepfinds none underwebsite/src), so thismakes the fact available; the "cancelling..." pill is the consumer's job.
Code Review Sage already renders exactly that shape from its own
cancel_requested_at, which is the pattern to copy.docs/system-specs/features/app-sdk-durable-jobs-and-view-state.mdwasdeliberately not touched. It is the pre-feat(apps): durable job runtime and the shared _jobs surface #6682 problem statement and still
says the App SDK "has no job service" - already stale from that merge, not
from this change, and correcting it belongs with whoever owns that spec rather
than in a bug fix's diff. The authoritative field list is the
_public_viewdocstring, which this change updates, along with the module docstring's
one-writer-per-run section so it names the derived read.
the sole writer and this derivation's terminal guard is one of the places that
will need re-reading against the new write pattern.
Pattern harvest
Rule candidate: review-prompt
Pattern: a mutation handler reports state it just changed with a response key
that the same resource's READ serializer does not carry. The acknowledgement
then lives only in the caller that made the call, so any client that re-reads
instead of holding that response cannot see the change. Here it was
_handle_cancelreturningcancelling: truewhile_public_viewhad no suchfield.
Detectable signature, cheap enough for a reviewer to run: a key that appears in
a mutation response body and in no read serializer for the same resource. It is
a review-prompt rather than a semgrep rule because deciding "same resource"
needs the route family, which the AST does not carry. The narrower half IS
mechanizable and worth filing separately if the class recurs: within one routes
module, flag a response literal key that no
_public_view-style function emits.Generalizes in this repo rather than being one file's accident: the SAME question
has three different answers on main today. Code Review Sage persists it
(
cancel_requested_aton the run record, which it can afford because it ownsboth writers). Dev Fleet overlays it at request time
(
_with_live_run_pointers). The Job SDK did neither, which is the defect. Anynew durable-run surface faces the same fork, and the constraint that picks the
answer is who is allowed to write the record -- worth asking at review time
rather than after the fact.
Closes #7589