feat(apps): durable job runtime and the shared _jobs surface - #6682
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound foundation for a real, thrice-reported gap — but the new public contract ships undocumented, and its prose lags the final scope cut. Watch
[DESIGN-REVIEWED] d633f61 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of First-Principles-Verdict: CONCERNS A derived, honestly-framed foundation — but it ships stale security-posture text describing a sanitizer that no longer exists, plus dead API surface with zero consumers. What this change shipsIntent: give long app tasks a server-side record so navigating away no longer reports running work as stopped — an ADDITION (P1 foundation; fixes no user-visible behavior yet, by declared design).
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] d633f61 |
GPT 5.6 Review — ✅ human override acceptedHuman judgment by @chenmingwei23 overrides the GPT 5.6 finding for This comment is updated in place on each push. The model was not re-run because an authorized human decision supersedes it. False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsNon-blocking: the initial job-record write omits the discard guard, leaking an orphan record when a disable wins the start race. FINDING — src/kiro_crew/apps/job_sdk.py:585 — [OPUS-REVIEWED] d633f61 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
d98ae7c to
cc6e23c
Compare
Isolated-pod end-to-end pass: 9/9 green, and it found one real defectThe Manual verification section promised this and said not to merge until it landed. Run in an isolated pod against this branch (never the live gateway); pod taken down afterwards with zero residue. A throwaway test app was installed into the pod only -- nothing in this diff -- declaring
Both wiring points are the ones a unit test structurally cannot reach, which is why this pass existed. The defect it exposed, fixed in
|
cc6e23c to
0c820d7
Compare
GPT dispositions -- all six fixed in
|
Opus disposition -- the dedupe race, fixed in
|
0c820d7 to
4f4193d
Compare
Round 2: all five held, and four of them collapse into one restructure --
|
Opus disposition: the redaction floor was too shallow -- fixed in
|
4f4193d to
61533fe
Compare
Root fix: the launch window is now a named state and a guarded transition.Head What was actually wrong. What changed.
Also closing GPT's second finding at the same chokepoint: Still holding on GPT's third: a stubborn worker is reported but does not fail trust Verification. 108 tests, coverage 97% on both new modules; flake8, isort, the One test changed for a reason worth naming: |
The App SDK had no server-side representation of "a task of mine is running": the fact lived only in the component that started it, so navigating away destroyed the fact while the work kept going, and the UI then reported the task as stopped. This adds the runtime half. JobSDK binds a kind to its runner once, at app init, so a caller that cannot hold a Python callable -- the browser, and the startup reconciliation pass -- can address a run by kind alone. Runs are one JSON file each under the app's data dir: atomic_write gives crash-safety but not mutual exclusion, so separate paths remove the race instead of needing a lock the tree does not offer. start writes the initial record before handing off, the worker thread is the sole writer after that, and cancel writes nothing at all. Cancellation is cooperative and declared at register(), because a worker thread cannot be killed and the SDK cannot inspect a runner for checkpoints; a run that never declared it answers cancel() with False rather than pretending. Staleness is decided by a per-process origin token, not a pid, since a pid can be reused by the very process doing the reconciling. The _jobs routes are mounted once for every app, resolving the app from the URL, and must register before the app catch-all or aiohttp hands the request to the app's own dispatch table instead. They are owner-gated: start runs real work, and P1 ships with no consumer needing an app-token caller. Tests: 72 new, 99% coverage on job_sdk.py and 98% on job_routes.py.
3976537 to
d633f61
Compare
Two of three fixed. The third is a third raise and needs a maintainer ruling, not another patch.Head
Worth recording because it is the more useful half: my first version of that fixture Not fixed: trust revocation succeeding while a worker remains active. This is the The prescribed fix -- make Verified on this head: 108 tests, three consecutive runs; flake8 and isort clean over |
Disposition: the disable contract is decided semantics, not an unfixed defectHead The mechanism GPT describes is real, and I am not claiming otherwise. It is the intended contract, and the alternative was considered and rejected. The deadline is bounded on purpose. A worker thread cannot be killed -- Python offers The prescribed fix -- retain timed-out workers and propagate partial cleanup as a The residue is surfaced, not swallowed. This is what separates the current
So the honest summary of the state after a disable with a stubborn worker is: no new What would reopen this. If a runner could be interrupted safely -- a cooperative Realized as |
|
/ai-review override gpt d633f61: Maintainer ruling: this is the intended disable contract, not an unfixed defect. The join deadline is bounded on purpose so a runner that never polls its handle cannot make its own disable un-completable, and the residue is surfaced not hidden -- CleanupResult.still_running exists for it and disable reports "partial: N worker(s) still running". The prescribed fix lets disable FAIL, which redefines what disable means and was decided against. Sole blocking finding; Opus clean on this head. |
Human judgment recorded@chenmingwei23 marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
buluoray
left a comment
There was a problem hiding this comment.
Approving — sound foundation. Three asks, none needing another review round.
Reviewed d633f61199c20a95330db29cbc89a7d61e17a9d4 independently: read job_sdk.py and
job_routes.py in full plus all four backend diffs, ran the tests, and reproduced one of the
races. The design holds up. The scope cut — P1 records that a run exists and how it ended,
with no params, no progress, no result — is the decision that makes the rest defensible,
and it is the right response to what the five review rounds actually found: each round located a
different unsanitized field, so removing the arbitrary-payload channels removes the class instead
of patching the sixth instance. File-per-run to settle writer-vs-writer, read_bytes_with_retry
for reader-vs-writer on Windows, a per-process origin token instead of a pid for staleness, and
authorization re-reading the manifest rather than trusting the process registry — each is the
right call, and each is argued in the code rather than asserted.
Two of the three asks below are text-only, and the third is one word. I would like all three
landed before you merge, but none of them changes the shape of the change.
ASK 1 — a governed security file describes machinery this head removed
src/kiro_crew/security_posture.py:1246
The NON_EGRESS_REDACTION_MODULES justification for apps/job_sdk.py says _redact covers
"step, error and each progress line", and that "_json_safe recurses through nested
structures (dict KEYS as well as values)". None of that exists on this head: job_sdk.py has no
_json_safe (repo-wide, the only other hit is apps/builtins/md_notebook/notes.py), no step
field, and no progress channel. The registered-sink entry above it also cites "a dedupe key ...
carried out of the SDK's own exception"; no JobError message quotes the dedupe key.
This file is the inventory a security reviewer reads to decide whether a redaction call site is
classified correctly, so a stale justification is worse than a thin one — it asserts coverage the
code no longer provides. The classification itself is right; only the mechanism text needs to
match: error is the sole runner-supplied field, _redact runs on it inside _persist, and it
is truncated to 2000 chars.
ASK 2 — the new manifest field, SDK surface and route family ship undocumented
docs/app-kit/manifest-reference.md carries a permissions table and a dedicated
permissions.spawn — Background Agents section with an API pointer. jobs appears in neither,
and the example manifest block still lists only cron and spawn. The spec this PR names,
docs/system-specs/features/app-sdk-durable-jobs-and-view-state.md, is current-state analysis
only — grepping it for JobSDK, ctx.job, _jobs or P1 returns nothing, so the shipped design
is written down nowhere.
AGENTS.md § Specification management requires the spec to be updated in the same commit as a
schema change. Docs Lint passed because it checks index and link integrity rather than coverage,
which is why this needs a human. The PR body already acknowledges a docs follow-up owing "one
correction to the merged spec" — that is narrower than what is actually missing, since P2 and any
third-party app author would be working from documentation that does not mention the seam at all.
ASK 3 — the initial record write bypasses the discard guard (reproduced)
src/kiro_crew/apps/job_sdk.py:585
if not self._persist(run): omits handle, so _persist's handle.discarded.is_set() check is
skipped on that one path. Opus flagged this non-blocking; it holds. Reproduced against this head
by pausing the initial write so a disable lands in the window:
start raised: JobError
CleanupResult(removed=0, failed=0, still_running=0) is_clean: True
orphan record files left on disk: ['1a462c7378fa41a389fcd738d25d2237.json']
So a disable racing a start leaves a record behind and reports itself clean, which contradicts
the module's own "records stay deleted" contract. The leak is a starting record — non-terminal —
so list_active reports it as running work until the next reconcile flips it to interrupted:
a phantom run for work that never began, which is the symptom class this SDK exists to remove,
arriving from the SDK itself.
Opus's fix (pass handle) is correct and one word. Worth considering the cause-level variant
instead: check self._closed inside _persist's critical section, which covers every handle-less
writer at once rather than asking each call site to remember to pass a handle — the same "enforce
in one place, not per call site" reasoning this PR already applies to _redact. _closed is set
only by remove_all_async, and the module already declares disable terminal for the instance
("a re-enable builds a new one"), so refusing post-close writes is exactly the contract. I did not
check whether an existing test writes a record after close, so that variant needs a test run.
Non-blocking observations
- Two comments now overstate.
_persistholdsself._lockacrossself._store.write(), a
disk write, but__init__'s comment still says the lock "guards two small dicts with no awaits
inside" andstart's docstring still says it is "safe on the event loop: the only blocking work
is one smallatomic_write". Both were true before the guarded-writer restructure; now an
on-loopstartcan also block acquiring the lock while another run's terminal write is on disk.
No deadlock —remove_all_asyncreleases the lock before joining — just stale prose. - A disabled app can still run a full runner body.
_executewrites theRUNNINGtransition
through the guarded writer, ignores the result, then callsrunner.fn(handle)regardless. With
cancellable=False(the default), a runner whose disable landed just afterthread.start()
executes its whole side-effecting body. Reporting it asstill_runningis a consistent answer;
aif handle.discarded.is_set(): returnbefore thetrywould close the sub-window where the
runner has not begun. Your call. - Test count in the body. It says 95;
test_job_sdk.pyplustest_job_routes.pyis 108
passing locally in 10.4s. Presumably written on an earlier head. - Caller-supplied strings are treated differently on two paths.
_guardedaudits
request.pathbefore the kind is validated, so an arbitrary caller-chosen{kind}reaches SEL
unredacted, whilestartdeliberately keepsdedupe_keyout of the gateway log for exactly
that reason. Auditingrequest.pathis the repo convention, so this is consistent rather than a
new hole — noting only the asymmetry.
What I verified
- 66 checks green, 4 skipped, 0 non-green, nothing in flight. All five bots carry a verdict on
this head. - Route ordering:
register_job_routes(app)runs beforeRouteRegistry(app)/
ensure_catch_all()ininit_hooks_system, andactive/recentare registered before
{run_id}.init_hooks_systemhas one production call site (dashboard/server.py:3147), so
there is no duplicate-registration hazard on the aiohttp router. - Authorization does not rest on the registry:
_enabled_and_permittedre-reads the manifest
off the loop, and the revoked-grant 404 is pinned whileget_sdkstill returns the SDK. - 108 tests pass locally on this head.
- No missed frontend wiring.
AppPermissionsinwebsite/src/app-sdk/index.tscarries only
apiandevents, andAppDetailPage.tsxbadgesstorage/cron/network/memorybut
notspawneither — sojobsneeding no frontend surface is consistent with howspawnis
already handled, not an omission.
Not verified: mypy and flake8 locally (CI reports both green), and the isolated-pod 9/9 E2E
table, which I took at face value rather than re-running.
What is the problem?
The product has no server-side representation of "a task of mine is running".
A long task started from the dashboard -- AWS Control's backup, Dev Fleet's pull --
exists only as state inside the React component that started it. Navigate away and
that state is destroyed while the work keeps going, so the UI reports the task as
stopped. It was reported three times as three bugs; it is one app-wide
architectural gap, and no app can fix it alone because none of them owns a durable
place to record a run.
Why this issue matters to the user
The user cannot trust what the UI tells them about their own work. They either sit
on the page babysitting a backup, or they navigate away, see "stopped", and start it
again -- doing paid work twice against the same destination. There is also no way to
answer "did last night's run finish?", because nothing outlives the tab.
How our fix solves it
This is P1 of the merged spec
docs/system-specs/features/app-sdk-durable-jobs-and-view-state.md.It adds the durable run registry the gap needs, and ships with no consumers on
purpose -- migrating an app is P2, so P1 can be reviewed as a foundation rather
than as a feature.
ctx.job-- an app-scoped Job SDK (src/kiro_crew/apps/job_sdk.py), gated on anew
jobsmanifest permission. A runner is REGISTERED against a kind at app init,not passed per call, which is what lets a caller that cannot hold a Python callable
-- the browser, and the startup reconciliation pass -- address a run.
Shared
_jobs/*routes (src/kiro_crew/apps/job_routes.py), mounted once forevery app with
{app}as a path segment, so no app registers its own. Registeredbefore
RouteRegistry.ensure_catch_all()because aiohttp matches in registrationorder and the app catch-all would otherwise swallow every
_jobsrequest.Authorization does not trust the process registry: the guard re-reads the manifest
off the loop, so a grant revoked after enable refuses even while the SDK is still
published.
P1 records that a run EXISTS and how it ended -- nothing it produced. There is
no
paramsa caller passes in and noprogressorresulta runner reports out. Arecord holds identity, lifecycle, and one error string if it failed. This is the
scope decision that makes the rest defensible: those channels are arbitrary nested
data that must be sanitized before it can be written or served, they needed a
recursive sanitizer to do it, and P1 has no consumer that reads them. They return in
P2 designed against a real consumer, as types that are sanitized by construction
rather than by a rule each writer has to remember.
Four invariants, each enforced in ONE place rather than by convention at each call
site:
writer, because
erroris the only field a runner supplies. Everything else isminted by the SDK from a str, an int or a bool, so a record is JSON-safe by
construction and the terminal write cannot raise on serialization -- which is what
used to skip the live-table and dedupe-key cleanup and leak a claim forever.
share a path;
atomic_writegives crash-safety but no mutual exclusion, and thetree has no lock helper. Reads go through
atomic_write.read_bytes_with_retry,because file-per-run settles writer-vs-writer and NOT reader-vs-writer: on Windows
a reader racing the writer's rename fails with
PermissionError.asyncio.to_thread, and every route reads the record off the loop.remove_all_asyncreturnsa result that cannot hide a worker it failed to stop, and it closes the SDK under
the same lock
startclaims in -- so a start racing cleanup cannot spawn a workerafter the snapshot and leave a disabled app doing real work.
Reconciliation resolves records left non-terminal by a process that is gone.
Staleness is decided by a per-process origin token, not a pid, because a pid can be
reused by the very process doing the reconciling. It runs after the enable loop at
boot, and again after an app's startup hook registers its runners, so an app enabled
later in the gateway's life is not left reporting work that already stopped.
What tests we did
95 tests across
test/test_job_sdk.pyandtest/test_job_routes.py, at 98%coverage of
job_sdk.pyand 97% ofjob_routes.py.flake8,isortand thebaselined
blackgate are clean, andmypyis clean over 1178 files.Every behaviour that a review round established is pinned, and the pins are
mutation-verified -- reverting the fix turns its own test red, checked one fix at a
time:
startafter disable is refused, and no worker thread survives the attempt;reach the filesystem and surface
ENAMETOOLONGas a 500 where 404 is the answer;the enable;
round-trips as UTF-8 rather than in the host locale.
Verified by a real gateway boot in an isolated pod, 9 of 9, for the two wiring
points unit tests structurally cannot reach:
GET /_jobs/activereturned the jobhandler rather than a 404 from the app catch-all, proving route ordering; and
foreign-origin
runningrecords written while the pod was down flipped tointerruptedon boot with different text for a known versus an unknown kind,proving reconciliation runs after the enable loop.
Any other suggestions on the work
Two exceptions are deliberate. The dashboard owner-guard imports inside
job_routes._guardedstay function-local because module scope closes aboot-breaking import cycle -- the same exemption
cron_sdkdocuments formcp_cron. And gateway shutdown does not drain runs: these are daemon threads theinterpreter reaps at exit, and draining every app's runs would delay shutdown for
work nobody is waiting on.
Two items are accepted and tracked rather than fixed here. Revoking the
jobsgrantand then disabling skips live-worker cleanup, and a worker that outlives the join
deadline does not fail trust withdrawal; both are in
hooks_integration.pyon thedisable path, and both want the same fix, which is for cleanup to run off the grant
rather than off the permission read.
P2 owes the payload channels on typed constructors, and a docs follow-up owes one
correction to the merged spec: it says the routes are registered through the Route
Registry so the existing
permissions.apigate applies. Builtins register directlyon the aiohttp app, the Route Registry cannot register on another app's behalf, and
no new
permissions.apientry is needed because_app_owns_pathalready covers anapp's own namespace.