fix(cron): give a job's first run its tab — and its caller identity — at run start - #9030
Conversation
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of The claims check out end-to-end: Design-Verdict: PASS Root-cause fix at the right seam: one shared bind core for both creator paths keeps the link/hydration invariant single-writer, with failure semantics matching the proven fire-time-gate contract. [DESIGN-REVIEWED] 847defd |
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of All claims verified against the base. One finding: the new docstring's "only writer" claim is contradicted by a second hand-rolled cron-slot bind in First-Principles-Verdict: CONCERNS Sound cause-level fix, but the "only writer of What this change shipsIntent: let a brand-new cron job's first run be recognized by the dashboard (identity + routing) while it runs — a FIX.
Watch
[FIRST-PRINCIPLES-REVIEWED] 847defd |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed |
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsFINDING -- src/kiro_crew/slack/gateway.py:975 -- Silent/deduplicated runs require |
… at run start
A cron's cron-{job_id} dashboard slot was created only by the result
injection, which runs after a turn completes. During a brand-new job's
FIRST run the tab therefore did not exist: session-control caller
identity resolves by walking live slot links (caller_slot_key matching
the presented cron:{job_id}), so every verb refused caller_unidentified
— on exactly the run a person watches after creating a job (kirodotdev#8335 made
crons legitimate session-control callers). The dashboard-surface
registry had the same first-run hole for sub-agent event routing,
completion injection, and widget/question/approval delivery, and the
executor's silent/dedup delivery paths gate on has_slot, so a silent
job's first result never reached its tab either. From run 2 onward the
previous delivery's slot masked all of it.
Fix: ensure_cron_slot pre-creates the tab at run start for a job that
will get one at delivery anyway (persistent_session and not
hide_in_chat), placed after the fire-time gate in the executor
callback. The link/hydration invariant the issue names moves with it:
_bind_cron_slot is now the single shared core for BOTH creator paths,
linking and hydrating (prefetch_cron_history, off-loop) in the same
step, so the injection's unlink guard stays an idempotent no-op
whichever path created the slot. Ineligible jobs (per-run session,
hidden) keep the fail-closed no-tab/no-identity/no-dispatch contract.
Decided explicitly, as kirodotdev#8336 asks: a first run that starts and then
fails now leaves an empty tab where none appeared before. Gating the
pre-create on the run reaching injection would recreate the hole this
fixes — identity must exist DURING the run.
Fixes kirodotdev#8336
…ume the run it serves The pre-create added for kirodotdev#8336 awaited ensure_cron_slot bare in the pre-dispatch window. A review lane convicted the chain: _execute clears run_never_started before the callback and its except arm never re-arms it, so a transcript/store failure (or the wake deadline cancelling AT the await) propagated out of a window where the retention marker was down — and the delete site then consumed a delete_after_run one-shot, the default at-scheduled shape, for a run that never dispatched. _pre_create_cron_slot now wraps the await in the fire-time gate's proven contract: run_never_started armed for exactly the duration of the await (CancelledError escapes except Exception with the marker standing, so the one-shot is retained), ordinary failures contained with a warning (the run proceeds without the pre-created tab — losing first-run identity for that run only, which is the status quo the feature improves on), and a linear clear before dispatch so a healthy one-shot is still consumed. record_failure() deliberately not called: an unminted tab is not a defect of the job. Four pins in TestPreCreateGuard mirror cron.py's delete_owed expression verbatim: raise-contained, cancel-retains-one-shot, healthy bind through the guard, ineligible job untouched (no new deny path). Owning doc updated with the best-effort contract.
2bbaf4c to
847defd
Compare
|
Prior-art note for reviewers: #8441, an earlier attempt at #8336, was self-closed with the rationale that session-control policy refuses cron callers on every run — which would make a first-run tab pointless. That rationale does not hold at current main: |
bolichen97
left a comment
There was a problem hiding this comment.
Tech Lead review — approved.
Caller identity at run start is correct and not spoofable. Both the slot name (cron-{job.id}) and the identity it carries (linked_session_key = "cron:{job.id}") are computed inside the helper from the host-held CronJob.id; no job-controlled string reaches either. The only job-supplied value that reaches the slot is job.name → slot.title, display-only and redacted in the same order as before (_safe_job_name preserves URL-pass-then-credential-pass). And caller_slot_key resolves by matching the MCP process's authenticated session key against live slot links rather than accepting a key as an argument, so a job cannot present another job's cron:<id>.
No race can misassign it. _bind_cron_slot is fully synchronous — get_or_create → title → link+hydrate → _sync_dashboard_slots, no await between them — so on the single event loop it is atomic against every other coroutine. The one await in ensure_cron_slot is prefetch_cron_history, before the bind; a second caller that slipped past the same get_slot check re-enters _bind_cron_slot, finds linked_session_key already set, and no-ops. Worst case is a redundant transcript read, never a duplicated hydration or a wrong identity. Placement after the fire-time gate and before dispatch leaves no window where a dispatched run lacks the slot delivery would later have created, and the run_never_started arm/clear correctly mirrors _await_cron_fire_time_gate: CancelledError is a BaseException, escapes the except Exception, and leaves the marker armed so a cancelled pre-create retains a delete_after_run one-shot. For an ineligible job ensure_cron_slot returns without awaiting, so the marker is never observable.
Scope is tight and correct for a fix: — 5 files, backend + two owning specs, no frontend, no workflow files, one new test module.
Two advisory findings verified as factually correct, neither behavioral:
- First Principles is right that the new
inject_cron_result_to_dashboarddocstring's "the only writer oflinked_session_keyfor a cron slot is_bind_cron_slot" ships false —handlers/cron.py:1413(the deleted-job branch ofapi_cron_to_chat) is a second hand-rolled writer. That branch has noCronJobso consolidation is genuinely out of shape; the claim should read "the only run-path writer." - GPT is right that
_pre_create_cron_slot's "delivery's own bind still creates the tab afterwards" is false for the silent/dedup paths, which gate onhas_slot(f"cron-{job.id}")atgateway.py:4752/4777/4833— for a silent job a failed pre-create means the first result never reaches a tab. That is the pre-existing behavior, so no regression, but the comment states the opposite of the case it exists to justify.
Both are comment text, not code, and both lanes rated them non-blocking. I am not holding a verified root-cause fix on two docstring sentences — and I cannot amend them here without becoming last-pusher and voiding this approval. Filing them as a follow-up instead.
Merging.
… at run start (#9030) * fix(cron): give a job's first run its tab — and its caller identity — at run start A cron's cron-{job_id} dashboard slot was created only by the result injection, which runs after a turn completes. During a brand-new job's FIRST run the tab therefore did not exist: session-control caller identity resolves by walking live slot links (caller_slot_key matching the presented cron:{job_id}), so every verb refused caller_unidentified — on exactly the run a person watches after creating a job (#8335 made crons legitimate session-control callers). The dashboard-surface registry had the same first-run hole for sub-agent event routing, completion injection, and widget/question/approval delivery, and the executor's silent/dedup delivery paths gate on has_slot, so a silent job's first result never reached its tab either. From run 2 onward the previous delivery's slot masked all of it. Fix: ensure_cron_slot pre-creates the tab at run start for a job that will get one at delivery anyway (persistent_session and not hide_in_chat), placed after the fire-time gate in the executor callback. The link/hydration invariant the issue names moves with it: _bind_cron_slot is now the single shared core for BOTH creator paths, linking and hydrating (prefetch_cron_history, off-loop) in the same step, so the injection's unlink guard stays an idempotent no-op whichever path created the slot. Ineligible jobs (per-run session, hidden) keep the fail-closed no-tab/no-identity/no-dispatch contract. Decided explicitly, as #8336 asks: a first run that starts and then fails now leaves an empty tab where none appeared before. Gating the pre-create on the run reaching injection would recreate the hole this fixes — identity must exist DURING the run. Fixes #8336 * fix(cron): make the first-run tab pre-create best-effort — never consume the run it serves The pre-create added for #8336 awaited ensure_cron_slot bare in the pre-dispatch window. A review lane convicted the chain: _execute clears run_never_started before the callback and its except arm never re-arms it, so a transcript/store failure (or the wake deadline cancelling AT the await) propagated out of a window where the retention marker was down — and the delete site then consumed a delete_after_run one-shot, the default at-scheduled shape, for a run that never dispatched. _pre_create_cron_slot now wraps the await in the fire-time gate's proven contract: run_never_started armed for exactly the duration of the await (CancelledError escapes except Exception with the marker standing, so the one-shot is retained), ordinary failures contained with a warning (the run proceeds without the pre-created tab — losing first-run identity for that run only, which is the status quo the feature improves on), and a linear clear before dispatch so a healthy one-shot is still consumed. record_failure() deliberately not called: an unminted tab is not a defect of the job. Four pins in TestPreCreateGuard mirror cron.py's delete_owed expression verbatim: raise-contained, cancel-retains-one-shot, healthy bind through the guard, ineligible job untouched (no new deny path). Owning doc updated with the best-effort contract. (cherry picked from commit 717b46b)
Problem / Motivation
A cron job's
cron-<job_id>dashboard tab is created only when a run's result is injected, which happens after the turn has finished (inject_cron_result_to_dashboardwas the only creator site). During a brand-new job's first run the tab therefore does not exist, and session-control caller identity resolves by walking live slot links (caller_slot_keyinsrc/kiro_crew/dashboard/session_control.pymatches the presentedcron:<job_id>against each slot's identity), so every session-control verb the run calls refuses withcaller_unidentified. From the second run onward the previous delivery's slot masks the whole problem.Reproduced at base
0d65dc969with a probe importing only existing symbols (attack + mask):Why it matters
Since #8335 a cron is a legitimate session-control caller, and the first run is exactly the run a person watches after creating a new job — the refusal reads as an authorization problem, so the natural (wrong) next steps are checking
agent.session_controland the agent's tools. Two more consumers share the dependency: every "does this session have a tab?" gate reads the dashboard-surface registry (sub-agent event routing, completion injection, widget/question/approval delivery), and the executor's silent/dedup delivery paths gate injection onhas_slot(f"cron-{job.id}")(gateway.py), so a silent job's first result never reaches its tab either.What changed (motivation → approach → change)
Symptom: first-run-only refusals and lost first-run routing. Root cause: the tab's only creator ran after the turn. Change: create it before the turn dispatches, for exactly the jobs that would get it at delivery anyway.
ensure_cron_slot(new,cron_inject.py): pre-creates the tab at run start. Eligibility (job.persistent_session and not job.hide_in_chat) lives inside the helper, so ineligible jobs keep the deliberate fail-closed no-tab / no-identity / no-dispatch contract. Cheap from run 2 onward: an existing linked slot returns before any transcript I/O.if not slot.linked_session_key:hydration guard silently false. So_bind_cron_slot(extracted) is now the single shared core for BOTH creator paths — it links and hydrates (prefetch_cron_history, off-loop) in the same step, and the injection's unlink guard stays an idempotent no-op whichever path created the slot. Neither invariant half can drift because there is one writer.gateway.py: the executor callback callsensure_cron_slotafter the fire-time gate (a denied run dispatches nothing a tab could serve) and before dispatch.learn-cron-dashboard.md(slot-creation timing) andsession-control.md(minting site) updated in the same commit — both documented the injection as the only creator.Tests
test/test_cron_first_run_tab.py(new, 8 tests):caller_slot_keyresolvescron:<id>on the first run (with an unresolvable-before control)hide_in_chat=Truemints nothing (contract control)persistent_session=Falsemints nothing (contract control)Fails-before: at base, the behavioral probe above convicts both the defect and the run-2 mask; the committed suite's collection fails at base on the new helper import. After: 8 passed. Neighbor suites (
test_dashboard_cron_to_chat,test_cron_channel_delivery,test_session_control,test_cron_session_control,test_cron): 404 passed.Manual verification
N/A — unit coverage sufficient: the mock state pins
get_slot/has_slotas real functions over one dict (a bareMagicMockwould auto-return truthy mocks and make every assertion unfalsifiable), and the silent-path test exercises the executor's own gate expression shape.Screenshots / video
Why no screenshot: backend-only diff (two Python modules, one Python test, two docs) — zero frontend files, no rendered surface changes. The visible consequence (a tab appearing at run start instead of at delivery) is a timing change to an existing surface, decided and documented in the issue's own terms.
Related Issues
Fixes #8336
Pattern harvest
Rule candidate: review-prompt
Pattern: "identity/registry row minted only by a delivery path that runs after the turn — any consumer that needs it DURING execution fails on the first run, and the previous run's leftover masks the defect from run 2 onward; check every
get_or_create_*that only runs post-turn."Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)Contribution License Agreement
Per the template placeholder (CLA text pending): offered under the same terms as my prior merged contributions to this repository (#8835).