fix(cron): wire task-route lease into isolated cron delivery-target (closes #92460, stacked on #95352)#95362
Conversation
|
Codex review: needs real behavior proof before merge. Reviewed June 21, 2026, 1:48 PM ET / 17:48 UTC. Summary PR surface: Source +800, Tests +1121, Generated +44, Other +706. Total +2671 across 22 files. Reproducibility: yes. source-level: current main allows same raw runId across scoped task records, while this PR stores and resolves route leases by raw run_id alone. The PR body also includes real SQLite terminal repro output for the intended cron path, but not live webchat delivery proof. Review metrics: 3 noteworthy metrics.
Stored data model Root-cause cluster Members:
Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything. Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Proof guidance:
Mantis proof suggestion Risk before merge
Maintainer options:
Next step before merge
Security Review findings
Review detailsBest possible solution: Land or accept the scoped task-route lease foundation first, then rebase this cron wiring onto that API, pass the matching scope through acquire/update/lookup/settle, and add live transport proof for the isolated-cron completion path. Do we have a high-confidence way to reproduce the issue? Yes, source-level: current main allows same raw runId across scoped task records, while this PR stores and resolves route leases by raw run_id alone. The PR body also includes real SQLite terminal repro output for the intended cron path, but not live webchat delivery proof. Is this the best way to solve the issue? No as written. A route lease is the right general repair direction, but this PR needs the scoped lease foundation and cron-scope plumbing before it is the narrow maintainable fix. Full review comments:
Overall correctness: patch is incorrect AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against eea777c9fc9c. Label changesLabel justifications:
Evidence reviewedPR surface: Source +800, Tests +1121, Generated +44, Other +706. Total +2671 across 22 files. View PR surface stats
Security concerns:
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
|
6a7164f to
9eb248b
Compare
|
Closing this PR in favor of the scoped foundation in #95481. The P1 blocker (not just "needs more proof"): ClawSweeper flagged that this branch keys
The P2 (no hard-delete GC for settled/expired rows) and P3 (re-acquire does Why I closed without going wider: This PR is stacked on the now-closed foundation #95352. The active replacement The follow-up shape (when #95481 lands): A new PR (working name: #95362-v2) will:
Thanks: Thanks to ClawSweeper for the precise P1 finding (file + line + confidence) Closing PR: #95362 |
|
Closing in favor of scoped foundation #95481; see comment for full rationale: #95362 (comment) |
What Problem This Solves
Isolated cron completion delivery drops explicit
delivery.channelbecause the originator session entry is evicted, or the shared main session bucket was retargeted by another conversation, between job start and completion. Today:lastChannel/lastTocan point to a different conversation's room when the cron completes.job.deliveryconfig may not survive to the announce step becauseresolveDeliveryTargetresolves the target from the session entry, not fromjob.deliverydirectly.The reported case is the worst combination: a manual cron with
delivery.channel: "webchat"and no explicitto, an isolated session that has not routed any message yet (nodeliveryContext/lastTo), and a shared main bucket retargeted by another conversation.Closes #92460.
Stacking
This PR is the cron-side wiring of a 2-PR split. The infrastructure half (the generic
task_route_leasesSQLite module + lifecycle helpers) is in #95352, which should merge first.feat/task-route-lease-moduletask_route_leasesSQLite table + generic lease module + tests + lifecycle repro + executor hooksfix/92460-task-route-leaseUntil #95352 merges, this PR's diff-vs-
mainshows the union (lease module + cron wiring = 22 files / +2685 lines), because GitHub doesn't allow a fork branch as a PR base. After #95352 lands, this branch will be rebased onto #95352's merge commit, dropping the 3 lease-module commits, and the diff-vs-mainwill shrink to the 16 cron files (+1721 lines).Design
A SQLite-backed
task_route_leasesrow keyed byrunId, with full lifecycle hooks that fix the reported path across both manual and scheduled cron entry points:tryCreateCronTaskRun): explicit acquire, lease keyed bycron:<jobId>:<startedAt>(the internal task ledger id).tryCreateManualTaskRun): explicit acquire, lease keyed byparams.runIdwhen the caller passes one (themanual:...id fromenqueueRun), otherwise the samecron:task ledger id as the scheduled path.finishPreparedManualRunpassesprepared.runId(themanual:id when present) as therunIdarg toexecuteJobCoreWithTimeout. The pipeline then plumbs it through torunIsolatedAgentJob→resolveCronDeliveryContext→resolveDeliveryTarget→getActiveTaskRouteLease(jobPayload.runId). The resolver's lease lookup hits the same key thattryCreateManualTaskRunacquired the lease under.resolveDeliveryTargetreturnsok: true(resolveCronDeliveryContextinsrc/cron/isolated-agent/run.tscallsupdateResolvedTaskRouteLeasewith the resolved(channel, to, threadId, accountId)). Fills in the rest of the target when the cron job'sdeliveryis channel-only (the reported case).resolveDeliveryTarget: the first higher-precedence source that resolves to a routable(channel, to)pair wins; if none is routable, the existing presence-based chain is used so the existing "no target" / "no channel" error path is preserved.tryFinishCronTaskRun): explicit settle (settledon success/skip,retiredon failure).tryFinishManualTaskRun): explicit settle using theleaseRunId(typically themanual:...id).start()arms a dedicatedleaseGcTimer(1h cadence,unref) that callsexpireStaleTaskRouteLeases.stop()clears it. Runs independently of the cron scheduling timer so subagent/codex/ACP leases are still GC'd when the cron scheduler is disabled.The auto-settle hook in
setDetachedTaskDeliveryStatusByRunIdretires the lease on any terminalTaskDeliveryStatustransition (delivered/session_queued→ settled;failed→ retired) for the generic detached-task path; cron setsdeliveryStatus: "not_applicable"which that hook skips, so cron settles explicitly above.Evidence
The full structured after-fix proof is in the Real behavior proof section below. Quick summary:
pnpm tsgo:core) exit 0manual:runId flows throughexecuteJobCoreWithTimeout→runIsolatedAgentJob→resolveDeliveryTargetso the resolver-sidegetActiveTaskRouteLease(runId)recovers the lease even when the session entry is evicted; the resolver is a routability-aware fallback so the lease is consulted only when higher-precedence sources are unroutable.Real behavior proof
Behavior addressed: an isolated manual cron with
delivery.channel: "webchat"and no explicitto, whose originating session entry was evicted (or whose shared main session bucket was retargeted) before completion fires, still delivers to the resolved target via the task-route lease. The lease is keyed by themanual:run id, and the execution pipeline (executeJobCoreWithTimeout→runIsolatedAgentJob→resolveDeliveryTarget) forwards the samemanual:runId to the resolver. Stale leases from process crashes are GC'd within an hour byleaseGcTimer.Real environment tested: Linux, Node 22.19, real on-disk SQLite via
openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: tempDir } })againstfs.mkdtempSynctemp dirs. Human ran all repros and tests on a clean checkout.Exact steps or command run after this patch:
pnpm exec tsx scripts/repro/issue-92460-channel-only-empty-session.mts— 7 PASSpnpm exec tsx scripts/repro/issue-92460-cron-completion-leases.mts— 6 PASSpnpm exec tsx scripts/repro/issue-92460-task-route-lease-lifecycle.mts— 7 PASS (covered by feat(tasks): add generic task-route lease module for cross-crash delivery origin recovery #95352; reproduced here as smoke test)pnpm exec tsx scripts/repro/issue-92460-manual-cron-lease.mts— 4 PASS (the reported [Bug]: Isolated cron completion announcer drops explicit delivery.channel on final controller return #92460 shape)node scripts/run-vitest.mjs src/tasks/task-route-lease.test.ts— 13 passednode scripts/run-vitest.mjs src/cron/isolated-agent/delivery-target.issue-92460.test.ts src/cron/isolated-agent/run.issue-91613-preflight.test.ts— 13 passednode scripts/run-vitest.mjs src/cron/service/ops.issue-92460-manual-lease.test.ts— 6 passednode scripts/run-vitest.mjs src/cron/service/ops.test.ts— 19 passed (no regression)node scripts/run-oxlint.mjson touched files — exit 0Evidence after fix — manual cron runId alignment (current head):
Evidence after fix — channel-only cron + empty session entry:
Observed result after fix: every cron entry path (manual + scheduled + startup catch-up) acquires a lease keyed by the run id the resolver will look up; the resolver consults the lease as a routability-aware fallback; the manual
runIdflows throughexecuteJobCoreWithTimeout→runIsolatedAgentJob→ resolver; leases are settled on terminal status;expireStaleTaskRouteLeasesruns hourly viastart()'sleaseGcTimer.What was not tested: no live Telegram / webchat channel send path is exercised in this PR (gated by the resolver's
ok: truereturn, which is proved by the unit tests). The lease-only path is proved end-to-end against real on-disk SQLite with arunIsolatedAgentJobmock that records the runId arg.Verification
pnpm tsgo:core— exit 0node scripts/run-oxlint.mjson touched files — exit 0Commits
The branch
fix/92460-task-route-leaseis stacked onfeat/task-route-lease-module(PR #95352):Related
AI assistance
AI-assisted (Claude Code). All repro scripts and unit tests were run by the human on a clean checkout of the branch.