fix(apps): three places the Job SDK asserted a fact it never verified - #7737
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound honesty fixes; the future carrier duck-types on Watch
Suggestions
[DESIGN-REVIEWED] 7a0f385 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsBoth candidates fail Step-1 falsification when I try to ground them to a concrete condition that occurs in practice. CANDIDATE 1 (re-read in CANDIDATE 2 (duck-typed No Step-2 finding grounds to 80+: the future branch asks No findings. [OPUS-REVIEWED] 7a0f385 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
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_sdk.py:305 -- False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All checks run. Composing the review. First-Principles-Verdict: CONCERNS Every fix targets a verified cause, but the two new API fields ship with zero counted consumers and a justifying consumer the diff itself disproves. What this change shipsIntent: make the Job SDK's durable run records state only facts it verified — a FIX.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 7a0f385 |
746f5eb to
2655568
Compare
2655568 to
b0b676c
Compare
b0b676c to
dc24096
Compare
dc24096 to
c09e55e
Compare
c09e55e to
70636a7
Compare
5cf2f41 to
370f2c5
Compare
370f2c5 to
8cf8af9
Compare
8cf8af9 to
e60373b
Compare
e60373b to
f0a0a5d
Compare
A durable run record is only worth having if what it says is true. Three paths in the P1 Job SDK wrote or served a claim the SDK had not checked, and each one is silent -- the record reads as an ordinary success or an ordinary failure, so nothing tells the reader the claim is wrong. register() accepted a coroutine function. _execute calls fn(handle) on a worker thread and discards the return value by design, so an "async def" runner returns a coroutine that is dropped un-awaited: no line of the runner's body executes, and the terminal write records "done". A run that reports success having done nothing is the worst outcome available here, and the only trace is an un-awaited-coroutine warning in the gateway log. It is refused at register(), where the answer is still cheap. Refusing is deliberately not supporting: driving a coroutine correctly means deciding which loop owns it, how cancellation crosses the thread boundary, and what a blocking runner does to that loop. Those are design questions, and this change answers none of them -- it answers only "did my runner run". reconcile() flattened three distinct events into one record. It assigned status over the top of the only evidence of how far a run had got, then chose error from whether a runner is registered -- so a run that died between claiming and launching was written "the gateway restarted while this was running", which is false by the module's own definition of starting. A consumer could not tell a run that may have committed side effects from one that provably committed none. Both facts are now on the record: interrupted_from (how far it got, from the existing status constants) and interrupt_cause (why it cannot resume, from a closed CAUSE_* set). They are independent because they describe different times -- past progress and present capability -- and a consumer reads them for different decisions: whether to warn about partial work, and whether a retry is even possible. interrupt_cause is a closed set rather than a bool because the not-registered case already covers two events, an app that was disabled and a kind that was removed, and a bool would freeze that conflation on the day the field lands. error is still populated, now composed at one site from a table keyed on cause with the progress interpolated; the process_gone + running cell reproduces the previous string byte for byte, because that was the only cell that was ever true. Both fields are served by _public_view: they are not host facts like origin and pid, they are the two facts only a client can act on, and a record that carries them behind an API that withholds them leaves the record honest and the API not. JobStore.read named two OSError subclasses and omitted a third that its own adjacent comment block is about. read_bytes_with_retry re-raises PermissionError once its budget is spent, and immediately on POSIX where it is a genuine access fault, so an unreadable record left read by raising, reached get(), and left the route as a 500 -- while the sibling scan twelve lines below already treated the same file as merely skippable. The two readers disagreed about identical damage. Both named members are OSError subclasses, so the tuple collapses rather than widening, and it collapses onto the exception the comment was already reasoning about. A record this method cannot read is one it does not have, which is the 404 _path's own docstring argues for. The module docstring and the feature spec both gain the SDK's process scope, in this commit rather than a later one. The SDK records the liveness of the GATEWAY process, so an app declaring backend.entryPoint -- spawned as its own OS process under popen_limited -- cannot use it honestly: register() binds a kind to a callable held in the gateway, so such an app has no registration path, and on a restart that _reap_stale_app_backends leaves its backend alive through, reconcile marks still-running work INTERRUPTED, a terminal state no later pass revisits. Code and spec move together because an invariant that lives in one of them is an invariant that drifts. Tests pin the fields and the behaviour, never the wording, since the wording is the part expected to change. Each fix was mutation-verified with a syntactically valid mutation applied one at a time: a plausible wrong predicate (isasyncgenfunction) and the guard placed after the table write; the prior status not preserved, the cause collapsed to one value, the message claiming a never-started run was running, and a second composition site introduced; and read's exception tuple restored exactly as it was. All seven mutants are killed. Two existing drift guards -- the serialized-field-set assertion and the sanitize invariant -- were updated rather than worked around, and both new fields are listed as SDK-minted because reconcile writes them from closed sets this module owns, so error remains the only runner-supplied field.
f0a0a5d to
7a0f385
Compare
iamwhatever
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (5 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: five silent Job SDK defects where the run record or the API asserted a fact the SDK never verified -- register() now refuses coroutine/async-generator/generator runners (whose body never executes, yet the record said done) with an execution-side check as the real safety property, reconcile() keeps interrupted_from/interrupt_cause instead of flattening three distinct events into one status, JobStore.read treats an undecodable record as absent rather than 500ing the lookup, _execute no longer discards the RUNNING _persist result, and the interruption cause is no longer inferred from runner registration; job_routes serves the two interruption fields a client needs to recover. Spec files changed as a ride-along (a minority of the diff on both file count and changed lines), not reviewed as a design decision: docs/system-specs/features/app-sdk-durable-jobs-and-view-state.md.
bolichen97
left a comment
There was a problem hiding this comment.
Description / code mismatch
One of the five change sections the Description presents as a code fix — D4 — makes no functional change to JobStore.read: the exception leg it claims to add is already in the base.
1. D4 is described as a code fix, but read's exception tuple is unchanged from main
The Description says —
D4 --
readtreats an unreadable record as absent. It caught
FileNotFoundErrorandNotADirectoryError, so a record whose bytes are not the
UTF-8 JSON this store writes crashed the lookup instead of reading as absent. The
fix addsValueError(which covers theUnicodeDecodeErrorfrom the explicit
decode, and the malformed id_pathrejects) and nothing else.
Problem / Motivation item 3 states the same premise:
JobStore.readcrashed on a record whose bytes it could not decode, so a damaged record took down the lookup instead of reading as absent.
And "Why it matters" presents it as a live, user-visible defect:
A 500 where a 404 is the honest answer is unactionable for a client, and the two readers of the same file disagreed about identical damage:
iter_runstwelve lines below already treated an unreadable record as merely skippable.
The code does — read catches (FileNotFoundError, NotADirectoryError, ValueError) at src/kiro_crew/apps/job_sdk.py:717, and that tuple is unchanged from the merge base: the ValueError leg has been in read since the Job SDK landed in #6682. An undecodable record therefore already read as absent on main, and read already agreed with the iter_runs scan about what damage means — the two readers were never in disagreement about it. The diff's contribution to D4 is the explanatory comment plus the TestAMalformedRecordIsAbsentAnUnreadableOneIsNot tests. The OSError widening the Description discusses is churn internal to this branch; withdrawing it lands back on exactly the base state, so no observable behaviour changes here.
Risk — A maintainer reading this PR concludes that a 500 where a 404 was the honest answer reached users and is fixed by this change. It did not exist. That misdirects review attention: the added test class and its two mutation rows characterise pre-existing behaviour rather than pinning anything this PR introduces, so a reviewer budgeting scrutiny by the Description's claimed scope spends it on the one section with no functional delta and less on the four that do change behaviour. The Pattern-harvest generalization inherits the error — it says the two readers "now disagree ON PURPOSE", making a pre-existing agreement read as a deliberate outcome of this change.
Required change — Correct the Description to state that read's exception tuple is unchanged from main, and that this PR's D4 contribution is documentation plus regression tests that pin the existing behaviour against the OSError widening an earlier revision attempted. Specifically:
- Drop or restate Problem / Motivation item 3, which asserts
readcrashed on an undecodable record. - Drop or restate the "Why it matters" 500-vs-404 bullet, which presents the 500 and the reader disagreement as live defects.
- Correct the Pattern-harvest sibling-form paragraph, which says
readand theiter_runsscan "now disagree ON PURPOSE" when they already disagreed identically on main. The later Pattern-harvest bullet about theOSErrorwidening is accurate as written — it correctly attributes the 404 defect to this PR's own earlier revision — and needs no change.
Alternatively, drop D4 from the PR's claimed scope. Note also that the title says "three places" while the body enumerates five, so whichever framing is chosen should make the title and the enumeration agree.
Problem / Motivation
Four places in the newly-landed Job SDK (#6682) wrote or served a claim the SDK
had never verified, and review found a fifth inside this PR's own new code. Each
is silent -- the record reads as an ordinary success or an ordinary failure, so
nothing tells the reader the claim is wrong.
register()accepted a coroutine function._executecallsfn(handle)on aworker thread and discards the return value by design, so an
async defrunner returns a coroutine that is dropped un-awaited: no line of the runner's
body executes, and the terminal write records
done.reconcile()flattened three distinct events into one record. It assignedstatusover the top of the only evidence of how far a run had got, thenchose
errorfrom whether a runner is registered -- so a run that diedbetween claiming and launching was written "the gateway restarted while this
was running", which is false by the module's own definition of
starting.JobStore.readcrashed on a record whose bytes it could not decode, so adamaged record took down the lookup instead of reading as absent.
_executesetstatus = RUNNING, called_persist, and discarded the return.A lost write left the record at
startingwhile the runner went on to commitreal work; if the process then died before the terminal write,
reconcilereadthat record and stated the run had never begun -- while side effects existed.
Found by review on this PR, in the code this PR added
interrupted_fromto.reconcilechose the interruption cause from whether a runner is registered,so a record minted by THIS process with no live worker was written "the gateway
restarted while this was running" -- while the gateway was still up. Found by
review, in the cause vocabulary this PR itself introduces.
Why it matters
A durable run record is only worth having if what it says is true. All three
failures are silent and all three mislead in the direction of false confidence:
available here. The only trace is an un-awaited-coroutine warning in the
gateway log, and an app author gets a green
donefor work that never ran.that provably committed none. Code Review Sage writes two different recovery
messages for exactly that distinction: a review interrupted from
startingposted nothing, while one interrupted from
runningmay have already deliveredcomments to a pull request.
two readers of the same file disagreed about identical damage:
iter_runstwelve lines below already treated an unreadable record as merely skippable.
What changed (motivation -> approach -> change)
One PR rather than three because there is one story: the record or the API
asserted a fact the SDK never verified. Each fix closes the gap at the point
where the answer is still cheap to get.
D1 -- a run that did no work is recorded
failed, notdone. The symptom isa
donerecord for work that never ran. There are two halves, and only one ofthem is the safety property.
The registration guard is a fast, friendly error.
register()refuses acallable whose invocation cannot do work: a coroutine function, an async
generator function, or a plain generator function -- called, each returns a lazy
object, the body never executes, and the record says
done. The check goesthrough
__call__as well as the object itself, because that is what invocationreaches:
inspectreports a callable instance as an ordinary object, so acheck written against bare functions lets an
async def __call__straightthrough. It runs BEFORE the runner-table write, so a refused kind leaves no
runner behind. Its value is timing and a clear message -- the app author hears
about it in their own
on_startup, not from a run record later.The execution check is the safety property. A shape check is a proxy and can
only refuse spellings somebody thought of. Measured, three forms and what each
guard sees:
async defrunnerasync def __call__def run(h): return _do_it(h)over anasync defRow three is the one that matters: a plain wrapper returning a coroutine is an
ordinary refactor, not an exotic construction. It is a plain function, so every
shape predicate says it is fine; it runs far enough to construct the coroutine,
hands it back,
_executediscards it by design, and the work never happens. Noguard that enumerates callable shapes can ever see it.
So after
runner.fn(handle)returns, if the result still needs driving -- anawaitable, or an async generator -- the run is recorded
failedwith an errornaming the cause, and the result is closed so it does not warn from the garbage
collector at a point far from the run that caused it. That makes the whole class
unreachable however the callable was written, including forms nobody has thought
of yet.
Two boundaries, both deliberate. The new branch sits inside the existing
tryand only sets fields -- the terminal write below is still the one writesite, because one-writer-per-run is what keeps a lost write from leaking a dedupe
claim (pinned by a test that counts the write calls in
_execute). And areturned plain generator is not treated as failure: generator functions are
refused at registration, while a generator object handed back by a runner that
did its work is ambiguous in a way an awaitable is not.
Deliberate scope line: driving coroutines properly is a design question,
refusing them is not. Doing it right means deciding which loop owns the
coroutine, how cancellation crosses the thread boundary, and what a blocking
runner does to that loop. This change answers none of those. It answers only the
question a caller cannot currently ask -- "did my runner run".
D2 -- record both axes of an interruption and derive the message. The root
cause is that one string was carrying two independent facts. They are
independent because they describe different times: how far the run got (past)
and whether the kind can be serviced now (present). Neither implies the other,
and a consumer reads them for different decisions -- whether to warn about
partial work, and whether a retry is even possible.
interrupted_fromholds the prior status, drawn from the existingQUEUED/STARTING/RUNNINGconstants. No new vocabulary.interrupt_causeis a small closed set (process_gone,runner_unregistered), not a bool:reconcile's own docstring already admitsthe not-registered branch covers two events -- the app was disabled, or the kind
was removed -- so a boolean would freeze that conflation on the day the field
lands. Adding the third value costs one constant and one table entry.
erroris still populated, composed at ONE site from a table keyed on causewith the progress interpolated. With two axes a conditional needs four arms and
grows multiplicatively; the table grows by one line per cause. The
process_gone+runningcell reproduces the previously shipped string bytefor byte, because that cell was the only one that was ever true.
D4 --
readtreats an unreadable record as absent. It caughtFileNotFoundErrorandNotADirectoryError, so a record whose bytes are not theUTF-8 JSON this store writes crashed the lookup instead of reading as absent. The
fix adds
ValueError(which covers theUnicodeDecodeErrorfrom the explicitdecode, and the malformed id
_pathrejects) and nothing else.It deliberately does NOT catch
PermissionError, and an earlier revision of thisPR got that wrong. That revision widened the tuple to
OSErroron the argumentthat
read's own adjacent comment is about the WindowsPermissionErrorcase, sonot catching it looked like an oversight. It was not. A sharing violation from a
concurrent
os.replaceis TRANSIENT --read_bytes_with_retryexists to outlastit -- so answering
Noneclaims "no such run" about a record that is present andintact, and a client reading 404 as "gone" drops live work. The same holds for the
rest of
OSError:EIO,EMFILEandEBUSYare facts about the host, not aboutwhether the run was started. Those reach the route as a 500, which honestly says
"ask again" instead of confidently saying the wrong thing.
That is the same defect this PR is named for, committed by one of its own fixes:
readasserted an absence it had not verified. It is now pinned from both sides --a mutant that restores the
OSErrorwidening is killed by this PR's own tests andindependently by
test_on_loop_read_propagates_permission_error_offloaded_read_retries,which #7703 added to keep that error propagating.
The RUNNING transition is now a precondition, not a notification.
_executecalled
self._persist(run, handle)and threw the boolean away, then ran the bodyas though it had succeeded. If the store cannot record that a run is running, the
SDK no longer runs it: the run is marked
failedwith a reason naming the write,and
runner.fnis never called.The alternative -- execute anyway and simply never claim "never started" -- is
unachievable across a crash, because the knowledge that the write failed dies with
the process. Only refusing to start keeps every surviving record's claim true.
State the cost plainly: a transient store error now fails a run that might have
succeeded. That is a real regression in availability, chosen deliberately. For an
SDK whose product is a durable record, a run nobody can account for is worse than a
run not attempted, and the failure is at least visible and retryable by the caller
rather than silently misdescribed later. Neither branch adds a write site -- both
set fields and let the single terminal write persist them, which the source ratchet
pins at exactly one
_persistand one_write_terminalinside_execute.The interruption cause no longer lets a proxy outrank a fact.
reconcilepicked between two causes on
known(is a runner registered for this kind), andboth of those causes say the gateway restarted. But
run.origin == _ORIGIN-- oneline above -- is positive evidence that it did not: this process minted the
record. So a record this process wrote, whose worker is gone from the live table,
was being told it had survived a restart that never happened. Not an unverified
claim but a counter-verified one, with the evidence in hand at the point of the
write.
Own origin is now tested first, because a verified fact must outrank a proxy, and
it selects a third cause:
terminal_write_lost. Reaching that branch means_write_terminalspent both attempts and logged that the run would be reconciled,so what is genuinely unknown is whether the work finished -- and the message says
exactly that rather than guessing either way. The
knownsplit survives, scoped toforeign origins where a restart really is the explanation.
This is reachable without any restart, which is what makes it a fix rather than
a rebuttal.
reconcile_allhas one call chain and aiohttp fires it once perprocess, so the obvious reading is "reaching reconcile implies a restart happened".
That reading is wrong:
reconcile_allruns AFTER the app loop, whileregister_sdkruns BEFORE each app'son_startuphook -- so an app's startup hookcan start a job in this same process, and a job whose terminal write is lost is
then reconciled by the process that minted it. The rebuttal that the call chain
alone suggests would have been false.
The write-lost message states what is known, per axis. An earlier revision gave
that cause ONE sentence ending "whether its work finished is not known", which is true
for a
runningrecord and false for astartingone: making the running transition aprecondition (above) means a record still at
startingwhose terminal write was lostproves the body never ran. The consequence clause is now DERIVED from the progress axis
-- exactly what the two-axis design is for -- so a
startingrecord says its bodynever ran and names the precondition, and only
runningsays the outcome is unknown.An unreadable status falls back to outcome-unknown, which is the honest default when
the axis itself cannot be read.
The cause is composed into a local and assigned once, so
interrupt_causekeepsthe single-assignment-site property the ratchet checks. That ratchet had to be
rewritten: it pinned the exact TEXT of the old conditional, so a third cause failed
it for the wrong reason while the invariant it existed to protect was intact. It
now asserts the invariant -- one assignment site, and every value the local can
hold is a
CAUSE_*constant.The classifier asks PROTOCOL questions, not type questions, and that is the
whole design. An earlier form enumerated types -- coroutine, async generator,
generator,
asyncio.Future-- and was extended once per review round, five roundsrunning, each fix producing the next finding. The last one was
concurrent.futures.Future: not awaitable, so it fell through the early return anda pending or failed one was recorded
DONE. A type list is open-ended. Two protocolquestions close the space:
done()-- both futureflavours,
Task, and anything written to the same protocol. Unsettled means thework is unfinished and nothing here will finish it; settled splits three ways,
because
done()answers "is it settled", not "did it succeed".*_CLOSEDcovers both a drained suspendable and one closed before it began, so the state is
refused as an answer and the kind alone decides.
Measured across every shape these rounds chased:
allowedbefore round 9)asyncio.Future, unsettleddone()Falseasyncio.Future, cancelledasyncio.Future, holds an exceptionasyncio.Future, holds a valueconcurrent.futures.Future, all fourOne protocol question, two carriers, answered on different grounds. The question
is "is this finished?" A future answers it, by
done()and then by how it settled, soits four verdicts are kept and deliberately not flattened. A suspendable does NOT answer
it --
*_CLOSEDconflates two histories -- so it is refused by kind, and its state isnever read. Keeping the future distinctions while collapsing the suspendable ones is the
rule: report what the object can tell you, and refuse what it cannot.
Why this is complete, which after nine review rounds is a fair thing to ask. Not
because cases stopped being found, but because the two carriers rest on different
arguments. Suspendables need no enumeration at all: every state reaches one verdict, so a
future Python adding a state cannot produce an unmapped case and there is no table to go
stale. The future protocol offers pending, cancelled, exception and result, and every one
is mapped. What remains true of the old argument is why an unreadable answer fails
rather than passes: for a durable record an uninterpretable state is precisely where
donemust not be asserted -- and that default is pinned by a mutant, after a firstattempt at pinning it survived.
The three frame verdicts are three because they are three different facts. "Never
began" says the runner returned instead of running. "Suspended partway" says it ran
and stopped, and since the SDK never drives what a runner returns, that frame never
resumes -- so
donewould assert a completion nobody observed. An earlier revision ofthis PR allowed suspended frames on the grounds that "the body ran", which was true
and insufficient.
(
CLOSEDalso covers a suspendable closed before it ever began, indistinguishablefrom a drained one because the frame is gone either way. Disclosed rather than
papered over: a runner that builds a generator, closes it unstarted and hands it back
is telling us nothing, and this reads it as finished.)
The test that this is a fix rather than another row: it would have caught
concurrent.futures.Futurebefore anyone reported it. That type needed noenumeration -- it answers question 1, so all four of its states are classified by the
same code path that handles
asyncio.Future. A reviewer can check that claim againstthe table above.
An async generator is answered ahead of both questions because its state does not
matter: driving one needs a running loop this worker thread does not have, so it is
undriven whatever it reports. (
inspect.getasyncgenstateis also 3.12+ while thismodule supports 3.10, so that state is not uniformly readable.) An awaitable
answering neither question is undriven by elimination. Anything answering neither and
not awaitable is an ordinary value, discarded by design.
One implementation detail is a trap worth naming, because the obvious form of this
check silently breaks on half the CI matrix. The uniform-looking way to ask "has
this body begun" is the frame attribute (
cr_frame/gi_frame/ag_frame) plus anf_lasticomparison.f_lastiis-1for an unstarted frame up to 3.10 and aninstruction offset (
0) from 3.11, and CI runs both 3.10 and 3.12 -- so a< 0testmatches NOTHING on 3.12 and every
async defrunner would be recordeddoneagain,which is the defect this PR exists to remove. Measured, not assumed: on 3.12.13 an
unstarted coroutine, generator and async generator all report
f_lasti == 0. Theinspect.get*statehelpers are exact and have been present since 3.2 and 3.5, sothose are used instead, and a test pins the distinction so the trap cannot be
reintroduced by someone simplifying the table.
The async-generator arm is the one place this cannot be state-checked at all:
inspect.getasyncgenstateis 3.12+, so on 3.10 the state is unreadable and aversion-dependent record would be its own defect. It is therefore unconditional, on
the independent ground that driving one needs a running loop this worker thread does
not have.
The cause remains unfixed and is filed as
#7804, with its other half as
#7814. The SDK still never
observes whether work happened; it inspects what came back. The runner surface is
cancelled,discardedandrun_id, with no progress channel --grep -c 'def progress'returns 0. The protocol fix makes the classifier right about what it cansee, which is not the same as observing the fact: a runner that returns a plain value
having done nothing is indistinguishable from one that did everything.
The tests are written as a TABLE of factory to expected verdict rather than a case per
type, because appending a branch per reported shape is the failure mode this design
replaced. Two of the rows assert properties rather than instances: that every object
needing to be driven answers at least one protocol question, and that a legitimate
value is not failed closed -- a classifier carrying only negative tests eventually
rejects something valid, and that error looks like a real failure, so it is harder to
notice than the one it replaced.
The generator case is worth keeping in view, because it is why the protocol
framing matters. An earlier revision allowed any returned generator, arguing the
runner's body
had already run and the generator was incidental. That argument is true for a
generator the runner stepped and false for one it merely created -- creating a
generator runs no body, so a never-started generator is the unawaited-coroutine case
wearing different clothes, which the same function correctly fails. One row, two
opposite truths, and a test pinning only the benign one.
It was never the judgement call it was argued to be, only an unmeasured one:
inspect.getgeneratorstatedistinguishesGEN_CREATEDfromGEN_SUSPENDEDandGEN_CLOSED, so this is the pending-versus-done rule already applied to futures,asked of a generator. Applying it removes the last hand-argued exception from the
classifier. (
GEN_CLOSEDdoes also cover a generator closed before it ever started,indistinguishable from a drained one and read as ok -- a runner that builds a
generator, closes it unstarted and hands it back is telling us nothing either way.)
Async generators are deliberately NOT state-checked: driving one needs a running
loop this worker thread does not have, so a returned async generator is undriven
whatever state it is in -- and
inspect.getasyncgenstateis 3.12+ while this modulesupports 3.10, so the state is not uniformly readable the way a sync generator's is.
The three future outcomes get three distinct reasons rather than one, because a
cancelled future and a future holding an exception are different facts and a record
that flattens them tells the next reader less than it knows.
_execute's messagealso stopped hardcoding "its body did not run to completion", which was true only
for the lazy rows and false for the two new ones.
One ordering detail is load-bearing and invisible once correct.
cancelled()must be asked before
exception(): on a cancelled future -- bothasyncio's andconcurrent.futures' --exception()RAISESCancelledErrorrather than returningit, and
asyncio.CancelledErroris aBaseException, so it would pass straightthrough
_execute'sexcept Exceptionand escape the error handling of the veryrun this function exists to describe. Verified rather than assumed, and pinned by a
mutant that neutralises the
cancelled()guard so a cancelled future reachesexception(). Retrieving the exception also marks it retrieved, which suppressesthe "never retrieved" warning
_close_quietlyalready reasons about for the pendingcase -- classification and quiet retirement are answering the same question here.
Worth recording that the reported form was narrower than described:
concurrent.futures.Futureis not awaitable at all, so a thread-pool runner neverreached this check. Pinned as its own row so the boundary stays explicit.
_public_viewserves the two new fields. D2's justification is a consumerrequirement, and the consumer is a future one -- stating that precisely, because
it matters: Code Review Sage demonstrates the requirement today, writing two
different recovery messages for exactly this distinction, but it does so in its
own
_RUNSstore and does not use the Job SDK at all. No app registers a JobSDKrunner yet. So the fields have no reader in-tree, and First Principles is right
to say so (see its CONCERNS below).
Given that, the reason to serve them rather than only store them is
consistency: a client is the only party that can act on either fact -- whether
the run may have committed side effects, and whether retrying is possible now --
so a record that carries them behind an API that withholds them is the same
record-honest/API-not split as D4. If the fields are worth persisting they are
worth serving; if they are not worth serving they are not worth persisting. They
stand or fall together, which is why they are not split.
Three pre-existing tests were updated, and here is why. Stating this plainly
because an author who silently edits a drift guard looks like one routing around it:
test_a_returned_value_is_not_persisted_anywhereasserts the exact serializedfield set.
test_error_is_the_only_runner_supplied_fieldassertserroris the onlyrunner-supplied field.
Both failed, both were working as designed -- they exist to stop the build until
somebody justifies a new field, and the second was written in P1 for precisely
this moment. Both new fields are listed as SDK-minted, because
reconcilewritesthem from closed sets this module owns and no runner or caller can reach either,
so
errorremains the only runner-supplied field and the one-line sanitizebackstop still has one input.
The third is different in kind and worth reading closely:
test_a_transient_terminal_write_is_retriedfailed the SECOND store write andcalled it the terminal one. The second write is the
starting->runningtransition, so the test never exercised a terminal write at all -- and it passed
only because a failed running transition was being ignored, which is the defect
fixed below. Its premise was the bug. It now targets the write by the STATUS
being written rather than by call index, so it tests the retry it is named for
and cannot silently re-encode the same assumption.
"SDK-minted" is only true if nothing runner-supplied can reach those fields, and
a sentence in a test docstring cannot enforce that. So a ratchet was added:
interrupted_fromandinterrupt_causeeach have exactly ONE assignment site,from a constant. That turns the claim into a checkable invariant.
Tests
Every test pins fields and behaviour, never wording -- a test on prose is a
test on wording, and the wording is the part expected to change.
TestCoroutineRunnerIsRefusedAtRegistration-- a coroutine function isrefused; a callable object with an
async def __call__is refused, with thepremise pinned (
iscoroutinefunction(instance)is False whileiscoroutinefunction(instance.__call__)is True, which is why the check needsboth); generator and async-generator runners are refused; the refused kind is
left unregistered (verified three ways:
kinds(),is_cancellable, andstart()raisingUnknownJobKind); an asyncfunctools.partialand an aliasedasync def are refused; ordinary callables (plain function, lambda, partial,
__call__object) still register; and a sync runner driving its own loop withasyncio.runstill works, since that is the path the refusal message names.TestInterruptionRecordsBothAxes-- the prior status is preserved for each ofqueued/starting/running; the cause distinguishes a lost process from amissing runner; all six (status, cause) combinations are separately
observable, which is the assertion the old single-string form could not
satisfy; a run that never started is not described as running; the message
still names the restart for all six; both fields survive a round trip through
disk (the point, since the writer is a process that has died); a normal run
leaves both fields empty; and composition tolerates a status or cause this
build does not know, so a record from a newer gateway cannot stop the pass.
TestAMalformedRecordIsAbsentAnUnreadableOneIsNot-- the two directions arepinned separately. Absent: a non-UTF-8 record, a missing file, a bad path and a
malformed id all answer
None. NOT absent: a real0o000record (skipped asroot, where mode bits do not bite), a patched
PermissionError, and anEIOOSErrorall propagate, and restoring the mode restores the record -- which isthe proof that
Nonewould have been a lie, since nothing about it ever changed.A damaged record still does not stop reconciliation, which is why the scan below
keeps its own broader guard while this lookup narrows.
test_the_interruption_fields_reach_the_client(routes) -- the two fields areserved over HTTP while
origin/pidstay withheld.TestAnUndrivenResultIsFailedNotDone-- the safety property. A sync wrapperreturning a coroutine reports
failedand its body provably never ran (with thepremise pinned:
_lazy_call_shape(wrapper)is"", so no registration checkcould have caught it); a returned async generator reports
failed; a returnedFuture reports
failed, which is why the predicate isisawaitableand notiscoroutine; an ordinary runner returning data is stilldone; a returnedplain generator is still
done, pinning the documented boundary; the undrivenresult is closed; and a source ratchet counts the write calls in
_executesothe new branch cannot become a second write site.
Mutation-verified. Each fix was broken with a syntactically valid mutation
applied one at a time, and re-verified after the rebase onto current main:
39 mutants, each confirmed to still parse (a mutation that does not compile
proves nothing), source restored to a matching sha256 after every run. Three rows
carry the most weight: the first, because reporting
donefor a run that didnothing is the defect this whole PR is about; the
OSErrorswallow, because thatwidening was in an earlier revision of this PR and is now pinned against by both
this PR's tests and main's; and the discarded
_persistreturn, because thatmutant restores the exact defect review found here.
One mutant SURVIVED on the first run of this round -- removing the
cancelpathfrom
_close_quietlybroke nothing, because the behaviour was asserted only in adocstring that was itself wrong about it. That is the harness earning its place: a
fix nothing can detect the absence of is not pinned. It is now covered from both
sides,
cancelfor a pending future andclosefor a coroutine.The class, enumerated
The reviewer found one more instance of the same class on each of three rounds. That
is a fact about the class, not about the rounds, so the answer is an enumeration
rather than another patch. Every assignment to a lifecycle-assertion field
(
status,interrupted_from,interrupt_cause,error,finished_at,origin)in
job_sdk.py: 18 sites across 4 functions._persisterrorstart(3)status,error,finished_at_executestatus = RUNNING_execute(2)status,error_persistreturned False_execute(2)status,error_executestatus = DONE_execute(2)status,error_executefinished_atreconcileinterrupted_fromreconcileinterrupt_causereconcilestatus = INTERRUPTEDreconcile(2)finished_at,errorSeventeen of the eighteen now record what they observed.
The eighteenth is a disclosed gap, deliberately left, and here is why.
status = CANCELLED if handle.cancelled.is_set() else DONEinfers that the workstopped early from the fact that a cancel was REQUESTED. Cancellation is
cooperative and polling is optional, so a runner that never reads the flag finishes
all of its work and is still recorded
cancelled. Verified end to end against thereal SDK: a runner that ignores the flag completed, and the record read
cancelled.It is left because closing it is a semantics change to shipped behaviour, not a
tightening of this PR's own code:
maintoday, and this PR's diffonly re-indents it while wrapping it in the new precondition branch.
level, so the HTTP surface's definition of
cancelledis part of the contract.One of them states in its own comment that its runner does not poll the flag, and
still asserts the run ends
cancelled.cancelledas "a cancel was requested and the run then ended",which the code implements faithfully. Redefining it as "the work was truncated"
changes an API meaning and would need its own change with its own review.
Making that judgement inside a PR whose subject is exactly this class, without
saying so, is the failure mode this section exists to prevent. Named here so the
next reader inherits the finding rather than rediscovering it.
Manual verification
N/A -- unit coverage sufficient. All three fixes are backend-only and every
observable was exercised in-process against the real modules, including the two
behaviours that needed execution rather than reading: that a coroutine runner's
body never runs, and that a record whose bytes cannot be decoded reads as absent
while an environmental fault still escapes.
Round 9: a returned suspendable is a contract violation, and this REVERSES an
earlier decision in this same change set
Earlier rounds read a returned suspendable's frame state and ALLOWED
CLOSED, on thereasoning that a closed frame ran to completion. A test pinned that as correct. Both are
now inverted, deliberately, and a reviewer who read those rounds should see the reversal
stated here rather than infer it from a flipped assertion.
The reason is this change set's own thesis turned on itself.
CLOSEDcovers asuspendable drained to completion AND one closed before it ever began. Allowing the
state recorded
DONEfor the second case -- a completion nobody observed, which is theexact class of claim the PR exists to remove. The distinction was then measured rather
than assumed: no public attribute,
dir()entry, code object,gi_frame,gi_runningor
gi_yieldfromseparates a drained generator from one closed unstarted.Rejected alternative, and this is the reason the tightening is a judgement rather than
a shrug. The distinction IS observable.
gc.get_referentsreturns('str', 'str')fora drained generator and
('code', 'function', 'str', 'str')for one closed unstarted --stable across five trials, both creation orders, an explicit
gc.collect(), and with orwithout a closure cell, with same-history controls matching each other. It is refused
anyway: it is an undocumented interpreter internal, generators were restructured in 3.11
with lazily-created frames, and CI runs 3.10 AND 3.12. A classifier that is right on one
interpreter and wrong on another writes false records with confidence, which is worse
than declining to classify. That is the same trap as the
f_lastitest rejected in anearlier round, and it was found by looking for it rather than by review.
So the inference is replaced by a rule: any returned suspendable is a runner-contract
violation and records FAILED, whatever its state. The reason string does not say the
body never ran, because for a drained generator it did -- that wording would swap a
false success for a false explanation, which is the same defect one layer down and the
reason an earlier round's proposed remedy was refused.
What this costs, stated rather than buried: a runner that drains a generator and returns
it did real work and is now recorded FAILED. Three things bound that cost, all measured
rather than argued. No runner is registered anywhere in
src/-- every.register(callis
atexit,selectors, or an MCP/workflow registry -- and no app declares thejobspermission, so there is no installed base to break. The reach is narrower than it
sounds:
itertools.chain,map,filter,zip,enumerate,reversed, list/set/dictviews,
rangeand file objects are not suspendables, so returning any of them isuntouched, and generator FUNCTIONS were already refused at registration. And a false
FAILED does not re-run work: there is no retry-on-failure in this module -- the only
"attempts" are
_write_terminal's two write attempts -- so the cost lands as a visiblerecord for an owner to judge, not as duplicated execution.
The tightening also removes code. The per-kind state tables and their three verdict
constants are gone, and the async-generator special case dissolves with them: it existed
only because
inspect.getasyncgenstateis 3.12+ while this module supports 3.10, and nostate is read any more. Completeness is now a stronger claim than a state map -- the
verdict cannot vary by state, so a future Python adding a fourth state cannot produce an
unmapped case.
Round 10: reconciliation could destroy a true terminal record
This is the most serious defect in the change set, and it is different in kind from
the others. Every earlier finding was about a record asserting something nobody
verified. This one deleted a fact that was already on disk and wrote a false one over
it.
reconcileskips terminal records -- but it testsis_terminalagainst a SNAPSHOT thatiter_runsdecoded from disk, and it writes later. A worker that finishes in the gaphas already written its terminal record and left
_live, and thelivecheck cannotclose the window because a worker leaves
_liveonly AFTER its terminal write lands. Sothe pass wrote
interruptedover a truedone. The cause it recorded wasterminal_write_lost: it claimed the terminal write had been lost when the write had infact succeeded and this pass had destroyed it.
Reproduced by driving the real interleaving, not by reasoning:
The fix enforces a rule this module already states rather than adding one.
TERMINAL_STATESare documented as never revisited, so a pass that overwrites a terminalrecord is not merely producing a wrong value -- it is violating the module's own stated
invariant. The write now goes through
_persist(only_if_not_terminal=True), whichre-reads inside the same lock acquisition it writes with. The placement is the fix:
self._lockis not reentrant, so_persistis the only place the re-read and the writeare one atomic step. A second check next to the decision would have narrowed the window
and left it open, and the worker's own terminal write goes through that same lock, so a
record that reads terminal there is finished. Cost is one file read per non-terminal
record on the boot pass, inside a lock already held across a file write.
The regression test asserts full-record equality with what the worker wrote -- not
that
reconcilereturned early. "Returned early" is a proxy for "did not corrupt", andsubstituting a proxy for the fact is the defect this PR exists to remove, so the test
must not commit it either. The ordering is driven rather than mocked: every step of
reconcileruns for real and only the worker's completion is scheduled, using thegenerator's own yield as the suspension point. Mutation-verified -- with the guard
removed the test fails on the record diff (
interrupted_from: 'running'against''),which is the record content, not a return value.
Whole-module prose audit
Because seven of the defects in this change set were prose a code change invalidated,
every claim the module makes was re-checked against the code as it now stands rather
than only the sentences a reviewer happened to flag.
Inventory, extracted mechanically from the AST rather than by eye: 472 behaviour
claims -- 190 sentences across 30 docstrings, plus 282 comment lines that assert what
happens rather than restate a line. 209 carry an absolute ("only", "never", "cannot",
"every") or name another callable, which is where prose goes stale.
13 of those are decidable by grep rather than by argument, and all 13 hold now:
_persistis the only record writer (1 call site);_interrupt_erroris the onlymessage-composition site (3
_INTERRUPT_MESSAGEreferences: def, lookup, fallback);_executehas exactly one_persistand one_write_terminal;interrupted_fromandinterrupt_causeeach have one assignment site;cancelled()precedesexception();getasyncgenstateis referenced only in prose, never called; nof_lasticomparisonexists; every
CAUSE_*has a template;_PROGRESS_PHRASEcovers all threepre-terminal statuses;
_WRITE_LOST_BODY_NEVER_RANis exactly the pre-execution set;the runner is called with the handle alone; and
grep 'def progress'returns 0.Two claims were still false, and both were falsified by THIS PR rather than
pre-existing.
_lazy_call_shape's docstring and theregisterdocstring both said alazy-returning runner's run "is recorded
done" in the present tense -- true of thedefect, false since the execution-side check records
failed. Both now state it as theprior behaviour and name
_undriven_resultas what makes the record honest, which alsocorrects the standing of the registration guard: it is the friendly early error, not
the safety property. Neither had been reported by any reviewer.
That was the audit's result at the time. It did not hold, and the way it failed is
the more useful finding.
The audit ran before round 9. Round 9's by-kind rule then created a FRESH stale claim in
_execute's docstring -- prose still saying a returned generator is classified bywhether its body has begun, which the ruling had just made false -- and a reviewer found
it, not the audit. So the audit was not a one-time repair. It is a step that any
behavioural change re-opens, and the honest statement is not "we cleaned up the drift"
but nothing checks prose, so drift returns with every change. The mechanically
checkable claims survived because a script checks them; every claim that rotted was one
only a reader could have caught.
That is also why the count matters less than the mechanism. Of the claims found false
across the whole change set, none were pre-existing defects in main -- every one was
prose that a change in THIS PR invalidated, including two created by the PR's own later
rounds. A prose claim has no test, so the only thing standing between it and a false
statement about behaviour is whoever happens to read it next.
Pattern harvest
Rule candidate: review-prompt
Pattern: a durable record asserts a lifecycle fact its writer never observed.
The sharper finding, arrived at after seven instances: every single one was prose
that a code change invalidated, and nothing checks prose. Seven claim-versus-code
defects surfaced across this change set -- a comment arguing the exception tuple
"collapses rather than widening" when it widened; a docstring saying futures expose a
synchronous
closewhen neither flavour has one; a claim thatbackend.entryPointleaves an app with no registration path when the context builder gates only on the
jobspermission; a note calling the returned-generator case ambiguous whengetgeneratorstatedecides it; a sentence sayinginterrupted_frommeasures progress"before its process died", falsified by the same-process write loss this PR added a
cause for; plus the two lifecycle records the PR opened with. One of the seven was
created by this PR's own round-5 fix, which is the part that matters: the mechanism
is ongoing, not historical. Code has tests; prose has nothing, so a fix silently
turns its own neighbouring comment into a lie.
That reframes the whole change set. It began as "three places the SDK asserted a fact
it never verified" and the real subject is larger: an assertion is an assertion
whether it lives in a record or in a comment, and only one of the two has a gate.
A repo that wants this class closed needs the prose adjacent to a change treated as
part of the change's blast radius -- the practical form being that editing a
conditional means re-reading the comment above it, because that comment was written
to describe the branch you just replaced.
All three defects are one failure mode, which is what makes them one PR. In each
case the writer substituted a proxy for the fact it recorded, and every proxy
failed silently in the direction of false confidence:
donecame from "the runner returned without raising", not from "the runnerran". Calling a coroutine function returns without raising and executes nothing.
registered for this kind", not from the run's own prior status -- which the same
line had just overwritten.
which was "this record cannot be read" -- that is, a 404.
Stated so a reviewer can apply it to unrelated code: when a writer records a
terminal or lifecycle outcome, ask what it observed. If the answer is "no
exception was raised" or "some adjacent state was true", it is asserting rather
than reporting. The tell is a proxy standing in for the fact, and the cost is
always a record that looks normal while being wrong.
One slice of this is mechanically checkable and worth a lint or semgrep rule:
a callback type whose return is
Anycannot reject a coroutine function.JobFn = Callable[["JobHandle"], Any]looks like it constrains the runner anddoes not. Verified rather than assumed -- mypy over an
async defboth assignedto
JobFnand passed toregister()reports no issues, becauseCoroutineisassignable to
Any. So a registry that will never await its callback cannot relyon the annotation. That travels to every
Callable[..., Any]registration pointwhose call site discards the return.
The rule caught this PR's own first fix, which is the strongest thing about
it. D1 originally shipped as a registration-time inspection of the callable's
shape -- and a registration-time shape check is exactly an adjacent-state proxy,
the thing the rule names. It failed the way the rule predicts, twice: a reviewer
found that an
async def __call__instance bypasses it, and then measuring theguard against a matrix of forms surfaced a second bypass nobody had named -- a
plain
def run(h): return _do_it(h)over anasync def, which defeats everypossible shape predicate. Applying the rule to the fix produced a different
design: observe the fact instead. "Did the runner do the work" is available at
exactly one place, what the call handed back, and checking there makes the class
unreachable rather than the enumerated spellings refused. The shape guard was
kept, demoted to what it is honestly good for -- telling the app author at
register()rather than in a run record later.That is the generalizable shape of the remedy, not just of the defect: when a
check inspects a proxy, ask where the fact itself is observable, and move the
check there. The proxy check can stay as a fast error; it must not be the
safety property.
What does not generalize, said plainly: D4's specific shape is too local to lint,
since narrowing an exception tuple is usually deliberate. What does travel is the
sibling form: two readers of the same resource disagreeing about what damage
means. Here
readand theiter_runsscan twelve lines below now disagree ONPURPOSE, and the reason is the generalizable part -- a scan that skips one file
still returns the others, so a partial result is honest, while a targeted lookup's
Noneasserts absence, and absence is not something an unreadable fileestablishes.
The rule has now caught this PR's own fixes three times, which is the strongest
evidence for it. Beyond D1's shape-versus-fact story above, review found two
more instances in this change set itself:
readto catchOSError, which madegetanswer 404for a transient Windows sharing violation --
readasserting an absence it hadnot observed, about a record that was present and intact. Main's Flaky: test_job_routes.py::test_get_existing_run_is_200 fails on Windows with PermissionError on the run JSON #7703 test
independently requires that error to propagate.
_executesetstatus = RUNNING, called_persist, and discarded thereturn, then ran the body on the assumption the write had succeeded. A lost
write left the record at
startingwhile the runner committed real work, andreconcilelater read that record and stated the run had never begun. Theproxy here is subtler than a lifecycle field: it is assumed success of an
operation whose result was available and ignored.
That last one sharpens the rule into a second checkable form: a write whose
return value reports failure must be checked before anything acts on its assumed
success. Mechanically greppable at any call site that discards a boolean-returning
persist. The remedy also follows the rule's own logic -- make the transition a
precondition for running the body, so no surviving record can claim a run did not
begin while its side effects exist. The correction that was NOT taken is worth
recording too: mapping stale
startingtorunningwould have made one lieuniversal to hide another, which is why the field exists in the first place.
And then the rule caught the fix to the fix, which is the part worth generalizing.
Exempting
done()futures from the undriven check was itself an unverifiedassertion:
done()means settled, not successful, so a future holding an exceptionand a cancelled future were both recorded
DONE. Two consecutive review rounds eachfound a defect inside the previous round's fix, in the same function, and the reason
is structural rather than careless --
_undriven_resultis the single place where"did the work happen" is decided, and every appended branch is another chance to
answer it wrongly for a shape nobody enumerated.
The remedy generalizes better than any of the individual fixes: stop discovering
the input shapes by being told about them, and enumerate the contract instead. The
function now carries a table of every result shape with its verdict, and one test per
row, so a shape nobody considered is a visibly missing row rather than a silent
donein a durable record. The tell that a function needs this treatment is asequence of appended
ifbranches, each added in response to a specific reportedinput -- patch-per-report is a growth pattern, and it converges on the enumerated set
only by accident.
Applied once more to the whole file, which is how the enumeration above exists.
After the third round found a third instance of the same class, the response was to
count the class rather than fix the report: 18 lifecycle-assertion write sites, each
classified by what it observes. That enumeration is what found the
cancelledgap --no reviewer reported it, and it is now disclosed rather than waiting to be found in a
fourth round. The rule generalizes past a single function: when a reviewer finds one
more instance of a class every round, enumerate the class and the rounds stop.
And the sharpest version, because the corrected enumeration still missed a state.
Moving from types to protocols was the right level, and the protocol table STILL had
only two suspendable outcomes -- unstarted and everything else -- so a suspended frame
was recorded as completed work. The abstraction was right; its content was borrowed
from how the problem had been discussed rather than from Python's own definition of
the states. The language defines three, the conversation had two, and the code
inherited the conversation. So: enumerate from the AUTHORITY, not from your own
framing of it. The check is mechanical -- name the source that defines the set
(a language reference, a protocol spec, a schema) and count its members against your
rows. If you cannot name that source, you are enumerating your own understanding, and
it will be missing whatever you had not thought to say out loud.
The step before that one still holds and is worth keeping: enumerate at the right
LEVEL, or the enumeration is just a longer list of instances. Writing down the
result shapes did
not stop the rounds, because the enumeration was of TYPES. Types are open-ended --
each round added a row and the next round found the type that row had missed. The
protocol questions close the space because the language defines them: three
suspendable kinds with a
*_CREATEDstate, one future protocol, and__await__mustwrap one of those. The tell that an enumeration is at the wrong level is that it
grows on contact with reality: if reviewers keep adding rows, the rows are instances
and there is a question underneath them that would cover the lot. The check is
whether a candidate abstraction would have caught the last thing reported without
being told about it -- here it would have, since
concurrent.futures.Futureneeds norow.
A related caution, learned the hard way in this PR's own tooling. The mutation
harness anchors on source text, so every one of these rewrites silently invalidated
anchors: four went stale in one round (matching 0 times, therefore proving nothing
while still reporting), then one more, then ten at once in the protocol rewrite; one
mutant was inert because it moved a
getattrrather than the call it was supposed toreorder; a rewritten ratchet failed for the wrong reason because it pinned an
expression's TEXT instead of the invariant; and a test asserting the word "await"
appeared in an error message broke when the message was reworded to name the protocol
instead of the type. A test that pins a literal string stops testing the moment the
string moves, and it fails in the direction that looks like a real defect, which
costs a round to diagnose.
And one more refinement of the enumeration rule, from the round that followed it.
Enumerating the result shapes did not by itself catch the generator problem, because
the enumeration inherited a row that was too coarse -- "generator object, ok" reads
like a decided case, so writing the table down did not expose that two opposite
truths were hiding inside it. An enumeration is only as good as the granularity of
its rows, and the tell for a row that needs splitting is a justification that
argues about the input's HISTORY rather than its type: "the runner already did its
work" is a claim about what happened before the value was returned, and if that claim
can be true or false for the same type, it is two rows. The check that settles it is
whether the distinction is observable -- here
inspect.getgeneratorstatemade itobservable, so what had been defended as a deliberate boundary was only an unmeasured
one.
A documentation instance of the same class is fixed in this push too: the module
docstring claimed an app declaring
backend.entryPoint"has no registration path",while
context.pygatesctx.jobonpermissions.jobsalone and the gateway hookspublish it regardless. The scope is per RUNNER, not per app -- such an app does get an
SDK, and what it cannot do is register a runner from its own process. A false claim
about the SDK's own scoping, in the module whose subject is writers asserting what
they never observed.
A second pattern, about process rather than code. Two pre-existing drift guards
(
test_a_returned_value_is_not_persisted_anywhereandtest_error_is_the_only_runner_supplied_field) failed on this change and bothwere right to: they exist to stop the build until somebody justifies a new field
on the record, and the second was written in P1 for exactly this moment. That is
a guard doing its job, and worth naming as a pattern to copy. The follow-on
lesson is that satisfying such a guard with prose is not enough -- "SDK-minted"
is only true if nothing runner-supplied can reach the field, and a sentence in a
test docstring cannot enforce that. The ratchet added here (each new field has
exactly ONE assignment site, from a constant) is what converts the claim into
something checkable. That guard-plus-ratchet pairing generalizes to any
invariant currently asserted only in a docstring.
A third, and the sharpest of the process patterns: a test can encode the very
defect it appears to guard against.
test_a_transient_terminal_write_is_retriedfailed the second store write and named it the terminal one. It was the
starting->runningtransition, so the test never exercised a terminal write,and it passed only because that transition's failure was being ignored. Green, and
evidence of nothing. The tell is a test that identifies its target POSITIONALLY
(call index, ordinal) rather than by a property of the thing itself -- when the
sequence changes, the assertion silently moves to a different subject. It now
selects the write by the status being written.
Notes
The
cancellingwork that landed with #6682 (cancelling_ids, and_public_view's requiredcancellingargument) is main's, not this PR's; therebase kept it intact and this PR only adds two keys to the same dict.
Related follow-ups from the same audit, none of which this PR claims to fix:
#7588 (no retention, and status-only liveness makes eviction unsafe), #7590 (P2
payload channels, with measured requirements from two consumers), #7591 (what
migrating Code Review Sage would require), plus #7582 and #7583. #7589 asked for
exactly the
cancellingmechanism that shipped in #6682.The AWS Control backup consumer PR depends on this one but is not blocked by
it: that branch carries its own
test_the_runner_is_not_a_coroutine_function,so it does not rely on D1 landing first.
Refs #6682