fix(cron): use per-attempt abort controller so model fallback chain survives timeout#38149
fix(cron): use per-attempt abort controller so model fallback chain survives timeout#38149Sid-Qin wants to merge 1 commit intoopenclaw:mainfrom
Conversation
…urvives timeout The cron timeout handler aborts a shared AbortController. When the first model attempt times out, subsequent fallback attempts receive an already- aborted signal and fail immediately. Create a fresh AbortController for each fallback attempt and forward the outer abort to it, so new attempts start with a non-aborted signal while still being cancellable. Closes openclaw#37505
🔒 Aisle Security AnalysisWe found 1 potential security issue(s) in this PR:
1. 🔵 AbortSignal listener leak and ignored pre-aborted signal in runCronIsolatedAgentTurn attempt runner
DescriptionIn Impacts:
Vulnerable code (listener added, but removed only on one successful path): const attemptController = new AbortController();
const attemptSignal = attemptController.signal;
const forwardAbort = () => attemptController.abort(abortSignal?.reason);
if (abortSignal && !abortSignal.aborted) {
abortSignal.addEventListener("abort", forwardAbort, { once: true });
}
...
if (isCliProvider(providerOverride, cfgWithAgentDefaults)) {
const result = await runCliAgent({ ... });
...
return result; // <- listener is never removed
}
const result = await runEmbeddedPiAgent({ ..., abortSignal: attemptSignal, ... });
...
abortSignal?.removeEventListener("abort", forwardAbort);
return result;Why this can repeat:
RecommendationEnsure listener cleanup and correct abort propagation on all exit paths by using Suggested pattern: const attemptController = new AbortController();
const attemptSignal = attemptController.signal;
const forwardAbort = () => attemptController.abort(abortSignal?.reason);
if (abortSignal?.aborted) {
attemptController.abort(abortSignal.reason);
} else if (abortSignal) {
abortSignal.addEventListener("abort", forwardAbort, { once: true });
}
try {
if (isCliProvider(providerOverride, cfgWithAgentDefaults)) {
return await runCliAgent({ /* ... */ });
}
const result = await runEmbeddedPiAgent({
/* ... */
abortSignal: attemptSignal,
});
return result;
} finally {
abortSignal?.removeEventListener("abort", forwardAbort);
}This guarantees:
Analyzed PR: #38149 at commit Last updated on: 2026-03-06T17:02:08Z |
Greptile SummaryThis PR correctly fixes a bug where the shared The fix: Creates a fresh Why it works: Each attempt gets its own controller. When the outer signal aborts, the Tests: Well-structured coverage of signal isolation (confirms per-attempt controller is not the outer signal) and abort forwarding behavior. Confidence Score: 5/5
Last reviewed commit: b02874f |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b02874ff92
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (abortSignal && !abortSignal.aborted) { | ||
| abortSignal.addEventListener("abort", forwardAbort, { once: true }); |
There was a problem hiding this comment.
Abort per-attempt signal when outer timeout is already set
This logic only forwards cancellation when abortSignal is not yet aborted, so if the cron timeout fires between fallback attempts (or the signal is already aborted on entry), the new attemptSignal stays live and the next model attempt still runs. In executeJobCoreWithTimeout (src/cron/service/timer.ts), that outer signal is the job deadline mechanism, so this change can let timed-out jobs keep executing model/tool work (and potentially send side effects) after the scheduler has already treated the run as timed out.
Useful? React with 👍 / 👎.
Summary
AbortController. When the first model attempt times out, subsequent fallback attempts immediately fail because they receive an already-aborted signal.runcallback passed torunWithModelFallback, create a freshAbortControllerfor each attempt. Forward the outer signal's abort event to the per-attempt controller, so new attempts start with a non-aborted signal while remaining cancellable. Added 2 unit tests.timer.tstimeout logic,runWithModelFallbackloop, CLI agent path (uses its own timeout), outer abort semantics.Change Type (select all)
Scope (select all touched areas)
Linked Issue/PR
User-visible / Behavior Changes
Security Impact (required)
NoNoNoNoNoRepro + Verification
Environment
Steps
Expected
Actual
Evidence
Human Verification (required)
Compatibility / Migration
YesNoNoFailure Recovery (if this breaks)
src/cron/isolated-agent/run.tsonce: true), abort forwarding could be delayedRisks and Mitigations
Low — the change is scoped to the run callback closure. The outer abort is still forwarded, so cancellation semantics are preserved. The only behavior change is that new fallback attempts get a fresh signal.