When a cron job fires at 13:00:00.014 and completes at 13:00:00.021,
computeNextRunAtMs was flooring nowMs to 13:00:00.000 and asking croner
for the next occurrence from that exact boundary. Croner could return
13:00:00.000 (same second) since it uses >= semantics, causing the job
to be immediately re-triggered hundreds of times.
Fix: Ask croner for the next occurrence starting from the NEXT second
(e.g., 13:00:01.000). This ensures we always skip the current/elapsed
second and correctly return the next day's occurrence.
This also correctly handles the before-match case: if nowMs is
11:59:59.500, we ask from 12:00:00.000, and croner returns today's
12:00:00.000 match.
Added regression tests for the spin loop scenario.
When an isolated cron session has no lastAccountId (e.g. first-run or
fresh session), the message tool receives an undefined accountId which
defaults to "default". In multi-account setups where accounts are named
(e.g. "willy", "betty"), this causes resolveTelegramToken() to fail
because accounts["default"] doesn't exist.
This change adds a fallback in resolveDeliveryTarget(): when the
session-derived accountId is undefined, look up the agent's bound
account from the bindings config using buildChannelAccountBindings().
This mirrors the same binding resolution used for inbound routing,
closing the gap between inbound and outbound account resolution.
Session-derived accountId still takes precedence when present.
Fixes#17889
Related: #12628, #16259
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Addresses Greptile review comment: when !isNewSession, the spread already
copies all entry fields. The explicit entry?.field assignments were
redundant and could cause confusion. Simplified to only override the
core fields (sessionId, updatedAt, systemSent).
When a webhook or cron job provides a stable sessionKey, the session
should maintain conversation history across invocations. Previously,
resolveCronSession always generated a new sessionId and hardcoded
isNewSession: true, preventing any conversation continuity.
Changes:
- Check if existing entry has a valid sessionId
- Evaluate freshness using configured reset policy
- Reuse sessionId and set isNewSession: false when fresh
- Add forceNew parameter to override reuse behavior
- Spread existing entry to preserve conversation context
This enables persistent, stateful conversations for webhook-driven
agent endpoints when allowRequestSessionKey is configured.
Fixes#18027
When a cron job fires and completes within the same wall-clock second it
was scheduled for, the next-run computation could return undefined or the
same second, causing the scheduler to re-trigger the job hundreds of
times in a tight loop.
Two-layer fix:
1. computeJobNextRunAtMs: When computeNextRunAtMs returns undefined for a
cron-kind schedule (edge case where floored nowSecondMs matches the
schedule), retry with the ceiling (next second) as reference time.
This ensures we always get the next valid occurrence.
2. applyJobResult: Add MIN_REFIRE_GAP_MS (2s) safety net for cron-kind
jobs. After a successful run, nextRunAtMs is guaranteed to be at
least 2s in the future. This breaks any remaining spin-loop edge
cases without affecting normal daily/hourly schedules (where the
natural next run is hours/days away).
Fixes#17821
When an agent config specifies `model: { primary: "..." }` without
an explicit `fallbacks` array, the existing code replaced the entire
model object from `agents.defaults`—discarding the default fallbacks.
This caused cron jobs (and agent sessions) to have only one model
candidate (the pinned model) plus the global primary as a final
fallback, skipping all intermediate fallback models.
The fix merges the agent model override into the existing defaults
model object using spread, so that keys like `fallbacks` survive
when the agent only overrides `primary`. Agents can still explicitly
override or clear fallbacks by providing their own `fallbacks` array.
Reproduction scenario:
- `agents.defaults.model = { primary: "codex", fallbacks: ["opus", "flash", "deepseek"] }`
- Agent config: `model: { primary: "codex" }`
- Cron job pins: `model: "flash"`
- Before fix: fallback candidates = [flash, codex] (3 models lost)
- After fix: fallback candidates = [flash, opus, deepseek, ..., codex]
Addresses Greptile review feedback: locks in behavior when an agent
has skills: [] (explicit empty list), ensuring skillFilter: [] is
forwarded to buildWorkspaceSkillSnapshot to filter out all skills.
Isolated cron sessions called buildWorkspaceSkillSnapshot without
the skillFilter parameter, causing all skills to be included even
when an agent had a restricted skills list via agents.list[].skills.
Resolves the filter using resolveAgentSkillsFilter and passes it
through, aligning isolated cron with main session behavior.
Fixes#10804