mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-20 02:17:24 +00:00
makeCronJobHandler builds the agent RunRequest with no provider/model, so cron jobs always run on the agent default provider. High-frequency scheduled jobs (e.g. triage) can't be routed to a cheaper model the way heartbeats already can via agent_heartbeats.provider_id/model. Add provider_id/model to cron_jobs (Postgres migration 000074; SQLite schema + migration v42→43, both mirroring agent_heartbeats) and resolve them into RunRequest.ProviderOverride/ModelOverride in the handler, reusing the existing per-run override plumbing that heartbeats use. NULL/unset → agent default, so existing jobs are unaffected. Settable today via UpdateJob patch (CronJobPatch.ProviderID/Model) or directly in the DB; tool/RPC/dashboard exposure left as a follow-up. Verified: go build ./... and go build -tags sqlite ./... (exit 0), go vet, gofmt, and cron unit tests on both pg and sqlite stores.
230 lines
8.4 KiB
Go
230 lines
8.4 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/agent"
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
|
|
"github.com/nextlevelbuilder/goclaw/internal/sessions"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// makeCronJobHandler creates a cron job handler that routes through the scheduler's cron lane.
|
|
// This ensures per-session concurrency control (same job can't run concurrently)
|
|
// and integration with /stop, /stopall commands.
|
|
// cronHeartbeatWakeFn holds the heartbeat wake function, set after ticker creation.
|
|
// Safe because cron jobs only fire after Start(), well after this is set.
|
|
var cronHeartbeatWakeFn func(agentID string)
|
|
|
|
func makeCronJobHandler(sched *scheduler.Scheduler, msgBus *bus.MessageBus, cfg *config.Config, channelMgr *channels.Manager, sessionMgr store.SessionStore, agentStore store.AgentStore, providerStore store.ProviderStore, providerReg *providers.Registry) func(job *store.CronJob) (*store.CronJobResult, error) {
|
|
return func(job *store.CronJob) (*store.CronJobResult, error) {
|
|
agentID := job.AgentID
|
|
if agentID == "" && agentStore != nil {
|
|
// Resolve real default agent from DB instead of using literal "default" string.
|
|
tenantCtx := store.WithTenantID(context.Background(), job.TenantID)
|
|
if defaultAgent, err := agentStore.GetDefault(tenantCtx); err == nil {
|
|
agentID = defaultAgent.AgentKey
|
|
} else {
|
|
agentID = cfg.ResolveDefaultAgentID()
|
|
}
|
|
} else if agentID == "" {
|
|
agentID = cfg.ResolveDefaultAgentID()
|
|
} else if id, err := uuid.Parse(agentID); err == nil && agentStore != nil {
|
|
// Resolve agentKey from UUID so session key uses agentKey
|
|
// (consistent with chat/WS/team paths, fixes cache invalidation mismatch).
|
|
cronCtx := store.WithTenantID(context.Background(), job.TenantID)
|
|
if ag, err := agentStore.GetByID(cronCtx, id); err == nil {
|
|
agentID = ag.AgentKey
|
|
}
|
|
} else {
|
|
agentID = config.NormalizeAgentID(agentID)
|
|
}
|
|
|
|
sessionKey := sessions.BuildCronSessionKey(agentID, job.ID)
|
|
channel := job.DeliverChannel
|
|
if channel == "" {
|
|
channel = "cron"
|
|
}
|
|
|
|
// Infer peer kind from the stored session metadata (group chats need it
|
|
// so that tools like message can route correctly via group APIs).
|
|
peerKind := resolveCronPeerKind(job)
|
|
|
|
// Resolve channel type for system prompt context.
|
|
channelType := resolveChannelType(channelMgr, channel)
|
|
|
|
// Build cron context so the agent knows delivery target and requester.
|
|
var extraPrompt string
|
|
if job.Deliver && job.DeliverChannel != "" && job.DeliverTo != "" {
|
|
extraPrompt = fmt.Sprintf(
|
|
"[Cron Job]\nThis is scheduled job \"%s\" (ID: %s).\n"+
|
|
"Requester: user %s on channel \"%s\" (chat %s).\n"+
|
|
"Your response will be automatically delivered to that chat — just produce the content directly.",
|
|
job.Name, job.ID, job.UserID, job.DeliverChannel, job.DeliverTo,
|
|
)
|
|
} else {
|
|
extraPrompt = fmt.Sprintf(
|
|
"[Cron Job]\nThis is scheduled job \"%s\" (ID: %s), created by user %s.\n"+
|
|
"Delivery is not configured — respond normally.",
|
|
job.Name, job.ID, job.UserID,
|
|
)
|
|
}
|
|
|
|
// Build context with tenant scope and timeout so agent loop events are
|
|
// scoped correctly and a hung agent can't block the cron scheduler forever.
|
|
jobTimeout := cfg.Cron.JobTimeoutDuration()
|
|
cronCtx, cancelCron := context.WithTimeout(context.Background(), jobTimeout)
|
|
defer cancelCron()
|
|
cronCtx = store.WithTenantID(cronCtx, job.TenantID)
|
|
if job.Payload.CredentialUserID != "" {
|
|
cronCtx = store.WithCredentialUserID(cronCtx, job.Payload.CredentialUserID)
|
|
}
|
|
|
|
// Reset session before each cron run to prevent tool errors from previous
|
|
// runs from polluting the context and blocking future executions (#294).
|
|
// Save() persists the empty session to DB so stale data won't reload after restart.
|
|
// Stateless jobs skip this — they intentionally carry no session history.
|
|
if !job.Stateless {
|
|
sessionMgr.Reset(cronCtx, sessionKey)
|
|
sessionMgr.Save(cronCtx, sessionKey)
|
|
}
|
|
|
|
// Resolve per-job provider/model override (mirrors heartbeat). Unset → agent default.
|
|
var providerOverride providers.Provider
|
|
if job.ProviderID != nil && providerStore != nil && providerReg != nil {
|
|
if provData, perr := providerStore.GetProvider(cronCtx, *job.ProviderID); perr == nil {
|
|
if prov, gerr := providerReg.GetForTenant(job.TenantID, provData.Name); gerr == nil {
|
|
providerOverride = prov
|
|
} else {
|
|
slog.Warn("cron.provider_not_in_registry", "job", job.ID, "provider_id", job.ProviderID, "error", gerr)
|
|
}
|
|
} else {
|
|
slog.Warn("cron.provider_not_found", "job", job.ID, "provider_id", job.ProviderID, "error", perr)
|
|
}
|
|
}
|
|
var modelOverride string
|
|
if job.Model != nil {
|
|
modelOverride = *job.Model
|
|
}
|
|
|
|
// Schedule through cron lane — scheduler handles agent resolution and concurrency
|
|
outCh := sched.Schedule(cronCtx, scheduler.LaneCron, agent.RunRequest{
|
|
SessionKey: sessionKey,
|
|
Message: job.Payload.Message,
|
|
Channel: channel,
|
|
ChannelType: channelType,
|
|
ChatID: job.DeliverTo,
|
|
PeerKind: peerKind,
|
|
UserID: job.UserID,
|
|
RunID: fmt.Sprintf("cron:%s", job.ID),
|
|
Stream: false,
|
|
ModelOverride: modelOverride,
|
|
ProviderOverride: providerOverride,
|
|
ExtraSystemPrompt: extraPrompt,
|
|
TraceName: fmt.Sprintf("Cron [%s] - %s", job.Name, agentID),
|
|
TraceTags: []string{"cron"},
|
|
})
|
|
|
|
// Block until the scheduled run completes or the timeout fires.
|
|
var outcome scheduler.RunOutcome
|
|
select {
|
|
case outcome = <-outCh:
|
|
case <-cronCtx.Done():
|
|
return nil, fmt.Errorf("cron job %s timed out after %s", job.Name, jobTimeout)
|
|
}
|
|
if outcome.Err != nil {
|
|
return nil, outcome.Err
|
|
}
|
|
|
|
result := outcome.Result
|
|
|
|
// If job wants delivery to a channel, send the agent response to the target chat.
|
|
if job.Deliver && job.DeliverChannel != "" && job.DeliverTo != "" {
|
|
if cronOutputContainsNoReplySentinel(result.Content) {
|
|
slog.Info("cron: suppressed delivery because output contained NO_REPLY",
|
|
"job_id", job.ID,
|
|
"job_name", job.Name,
|
|
"channel", job.DeliverChannel,
|
|
"to", job.DeliverTo,
|
|
"content_len", len(result.Content),
|
|
)
|
|
} else {
|
|
outMsg := bus.OutboundMessage{
|
|
Channel: job.DeliverChannel,
|
|
ChatID: job.DeliverTo,
|
|
Content: result.Content,
|
|
}
|
|
if peerKind == "group" {
|
|
outMsg.Metadata = map[string]string{"group_id": job.DeliverTo}
|
|
}
|
|
appendMediaToOutbound(&outMsg, result.Media)
|
|
msgBus.PublishOutbound(outMsg)
|
|
}
|
|
} else if job.Deliver {
|
|
slog.Warn("cron: delivery configured but channel/chatID missing — output discarded",
|
|
"job_id", job.ID, "job_name", job.Name, "channel", job.DeliverChannel, "to", job.DeliverTo)
|
|
}
|
|
|
|
cronResult := &store.CronJobResult{
|
|
Content: result.Content,
|
|
}
|
|
if result.Usage != nil {
|
|
cronResult.InputTokens = result.Usage.PromptTokens
|
|
cronResult.OutputTokens = result.Usage.CompletionTokens
|
|
}
|
|
|
|
// wakeMode: trigger heartbeat after cron job completes.
|
|
// Use original job.AgentID (UUID) — cronHeartbeatWakeFn expects UUID for ticker.Wake().
|
|
if job.WakeHeartbeat && cronHeartbeatWakeFn != nil {
|
|
cronHeartbeatWakeFn(job.AgentID)
|
|
}
|
|
|
|
return cronResult, nil
|
|
}
|
|
}
|
|
|
|
func cronOutputContainsNoReplySentinel(content string) bool {
|
|
text := strings.TrimSpace(content)
|
|
if text == "" {
|
|
return false
|
|
}
|
|
|
|
const token = "NO_REPLY"
|
|
for i := 0; i+len(token) <= len(text); i++ {
|
|
if !strings.EqualFold(text[i:i+len(token)], token) {
|
|
continue
|
|
}
|
|
beforeOK := i == 0 || !cronNoReplyAlphaNumByte(text[i-1])
|
|
after := i + len(token)
|
|
afterOK := after == len(text) || !cronNoReplyAlphaNumByte(text[after])
|
|
if beforeOK && afterOK {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func cronNoReplyAlphaNumByte(b byte) bool {
|
|
return (b >= 'a' && b <= 'z') ||
|
|
(b >= 'A' && b <= 'Z') ||
|
|
(b >= '0' && b <= '9')
|
|
}
|
|
|
|
// resolveCronPeerKind infers peer kind from the cron job's user ID.
|
|
// Group cron jobs have userID prefixed with "group:" or "guild:" (set during job creation).
|
|
func resolveCronPeerKind(job *store.CronJob) string {
|
|
if strings.HasPrefix(job.UserID, "group:") || strings.HasPrefix(job.UserID, "guild:") {
|
|
return "group"
|
|
}
|
|
return ""
|
|
}
|