Add typed jevdecide HTTP client (circuit, metrics) and coordinator DecideAssist wiring behind JEV_DECIDE_MODE (default off). Offline eval and synthetic soak evidence gate realtime/profile before assist rollout.
559 lines
17 KiB
Go
559 lines
17 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"chat-agent-system/internal/agent"
|
|
"chat-agent-system/internal/app/chatflow"
|
|
"chat-agent-system/internal/app/docflow"
|
|
"chat-agent-system/internal/app/docnotify"
|
|
"chat-agent-system/internal/app/intentflow"
|
|
"chat-agent-system/internal/app/stageflow"
|
|
"chat-agent-system/internal/app/uow"
|
|
"chat-agent-system/internal/artifactvault"
|
|
"chat-agent-system/internal/auth"
|
|
"chat-agent-system/internal/authz"
|
|
"chat-agent-system/internal/bizdev"
|
|
"chat-agent-system/internal/config"
|
|
"chat-agent-system/internal/coordinator"
|
|
"chat-agent-system/internal/ctxmgr"
|
|
"chat-agent-system/internal/docgov"
|
|
"chat-agent-system/internal/docrevision"
|
|
"chat-agent-system/internal/events"
|
|
"chat-agent-system/internal/freedoc"
|
|
"chat-agent-system/internal/intent"
|
|
"chat-agent-system/internal/jevdecide"
|
|
"chat-agent-system/internal/kb"
|
|
"chat-agent-system/internal/notification"
|
|
"chat-agent-system/internal/observability"
|
|
"chat-agent-system/internal/projectinstance"
|
|
"chat-agent-system/internal/projectworkflow"
|
|
"chat-agent-system/internal/storage"
|
|
"chat-agent-system/internal/sysadmin"
|
|
"chat-agent-system/internal/taskrun"
|
|
"chat-agent-system/internal/traceimpact"
|
|
"chat-agent-system/internal/workflowdef"
|
|
"chat-agent-system/internal/workflowscope"
|
|
"chat-agent-system/internal/workspace"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
const defaultReminderPollInterval = 15 * time.Minute
|
|
|
|
// Runtime is the unique composition root. cmd/server, tests and workers
|
|
// construct it once; HTTP adapters must not re-wire domain services.
|
|
type Runtime struct {
|
|
Cfg *config.Config
|
|
Biz *bizdev.Service
|
|
Engine *coordinator.CoordinatorEngine
|
|
Auth *auth.Service
|
|
PW *projectworkflow.Service
|
|
TaskRuns *taskrun.Manager
|
|
Authorizer *authz.Authorizer
|
|
Authz *authz.Service
|
|
WFDef *workflowdef.Service
|
|
Inst *projectinstance.Service
|
|
Notify *notification.Service
|
|
Resolver *workspace.Resolver
|
|
WSCap *workspace.Capability
|
|
Commands *taskrun.Commands
|
|
Rec intent.Recognizer
|
|
Stageflow *stageflow.UseCase
|
|
DocNotify *docnotify.Subscriber
|
|
ChatLog chatflow.ChatLog
|
|
Assembler chatflow.ContextAssembler
|
|
Docflow *docflow.Commands
|
|
IntentPlans *intentflow.Plans
|
|
Pool *pgxpool.Pool
|
|
Gov *docgov.GovernanceEngine
|
|
Rev *docrevision.Service
|
|
Trace *traceimpact.Service
|
|
Ctx *ctxmgr.ContextManager
|
|
CAS *storage.CAS
|
|
FreeDoc *freedoc.Service
|
|
KB *kb.Client
|
|
SysAdmin *sysadmin.Service
|
|
|
|
mu sync.Mutex
|
|
running bool
|
|
subscribers int
|
|
starts int
|
|
notifyBound bool
|
|
hookBound bool
|
|
reminderCancel context.CancelFunc
|
|
}
|
|
|
|
func New(cfg *config.Config) (*Runtime, error) {
|
|
return Assemble(cfg, nil)
|
|
}
|
|
|
|
// Assemble wires domain services. svc may be injected by tests.
|
|
func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
|
|
if cfg == nil {
|
|
cfg, _ = config.Load("")
|
|
}
|
|
if cfg != nil {
|
|
observability.InitLogger(cfg.Env)
|
|
}
|
|
if svc == nil {
|
|
var err error
|
|
svc, err = bizdev.NewService(cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
rec := intent.NewRecognizer()
|
|
backendName := ""
|
|
if cfg != nil {
|
|
backendName = cfg.AgentBackend
|
|
}
|
|
dialogueBackend := agent.NewBackend(backendName, cfg)
|
|
coordEngine := coordinator.NewCoordinatorEngineWithDialogue(nil, dialogueBackend, cfg.DataDir)
|
|
if cfg != nil && strings.TrimSpace(cfg.PiAgentRunnerURL) != "" {
|
|
coordEngine.SetRealtimeSearcher(&agent.PiRunnerSearcher{
|
|
URL: cfg.PiAgentRunnerURL,
|
|
Token: cfg.PiAgentRunnerAuthToken,
|
|
})
|
|
}
|
|
// jev-service typed decide: default off; shadow/assist inject client. Fail-open on errors.
|
|
if jevdecide.Enabled() {
|
|
endpoint, key := "", ""
|
|
if cfg != nil {
|
|
endpoint = strings.TrimSpace(cfg.JevBaseURL)
|
|
key = strings.TrimSpace(cfg.JevAPIKey)
|
|
}
|
|
var jc *jevdecide.Client
|
|
if endpoint != "" || key != "" {
|
|
jc = jevdecide.NewClient(endpoint, key)
|
|
if endpoint == "" {
|
|
jc = jevdecide.NewClientFromEnv()
|
|
}
|
|
} else {
|
|
jc = jevdecide.NewClientFromEnv()
|
|
}
|
|
if jc != nil && jc.Endpoint != "" {
|
|
coordEngine.SetDecideAssist(jc)
|
|
slog.Info("jevdecide wired", "mode", string(jevdecide.CurrentMode()), "endpoint", jc.Endpoint)
|
|
}
|
|
}
|
|
parts := bizdev.Snapshot(svc)
|
|
if ctxmgr.SemanticSummaryShadowEnabled() && parts.Ctx != nil && backendName == "pi-agent" {
|
|
model := ""
|
|
dataDir := ""
|
|
if cfg != nil {
|
|
model = cfg.PiAgentModel
|
|
dataDir = cfg.DataDir
|
|
}
|
|
parts.Ctx.SetSemanticSummarizer(&PiSemanticSummarizer{Backend: dialogueBackend, Model: model, DataDir: dataDir})
|
|
}
|
|
if docrevision.PIChangeReportNarrativeEnabled() && parts.Rev != nil && backendName == "pi-agent" {
|
|
model, dataDir := "", ""
|
|
if cfg != nil {
|
|
model = cfg.PiAgentModel
|
|
dataDir = cfg.DataDir
|
|
}
|
|
narr := &PiChangeReportNarrator{Backend: dialogueBackend, Model: model, DataDir: dataDir}
|
|
parts.Rev.NarrativeFetcher = narr.Fetch
|
|
}
|
|
if intentflow.CurrentFeatureFlags().U9Segment && backendName == "pi-agent" {
|
|
model, dataDir := "", ""
|
|
if cfg != nil {
|
|
model = cfg.PiAgentModel
|
|
dataDir = cfg.DataDir
|
|
}
|
|
intentflow.SetLLMSegmentProposer(&PiSegmentProposer{Backend: dialogueBackend, Model: model, DataDir: dataDir})
|
|
}
|
|
pool := parts.Pool
|
|
stores, storeErr := auth.NewStores(context.Background(), pool, cfg)
|
|
if storeErr != nil {
|
|
return nil, fmt.Errorf("auth store: %w", storeErr)
|
|
}
|
|
authSvc := auth.NewService(stores.Auth, stores.Sessions, cfg)
|
|
pwSvc := parts.PW
|
|
if pwSvc == nil {
|
|
pwSvc = projectworkflow.NewService(projectworkflow.NewStore(nil), nil)
|
|
}
|
|
|
|
authzSvc := authz.NewService(pool)
|
|
wfDefSvc := workflowdef.NewService(pool, parts.CAS)
|
|
instStore := projectinstance.NewStore(pool)
|
|
var instSvc *projectinstance.Service
|
|
if wfDefSvc.Available() {
|
|
docChecker := projectinstance.NewDocGovChecker(parts.Gov)
|
|
instSvc = projectinstance.NewService(instStore, wfDefSvc.Store(), pwSvc.Store(), docChecker)
|
|
if pool != nil {
|
|
instSvc.SetActiveMemberLookup(projectinstance.NewPGMemberLookup(pool))
|
|
}
|
|
if parts.Gov != nil {
|
|
instSvc.SetDocumentDutyProjector(&documentDutyProjectorAdapter{gov: parts.Gov})
|
|
instSvc.SetTemplateDocMaterializer(&templateDocMaterializerAdapter{gov: parts.Gov})
|
|
}
|
|
}
|
|
taskMgr := taskrun.NewManager(pwSvc, parts.Ctx)
|
|
if pool != nil {
|
|
taskMgr.SetStore(taskrun.NewPGStore(pool))
|
|
}
|
|
if instSvc != nil && parts.Gov != nil {
|
|
instSvc.SetDocTaskBusyChecker(&docTaskBusyCheckerAdapter{gov: parts.Gov, taskRuns: taskMgr})
|
|
}
|
|
wsCap := workspace.NewCapability(cfg.DataDir, workspace.NewLeaseTable())
|
|
cmds := &taskrun.Commands{
|
|
Ctx: parts.Ctx,
|
|
PW: pwSvc,
|
|
Env: cfg.Env,
|
|
StorageIdentity: "env:" + cfg.Env,
|
|
}
|
|
if pool != nil {
|
|
cmds.UoW = uow.NewPG(pool)
|
|
cmds.Fav = &artifactvault.Ingest{Env: cfg.Env}
|
|
}
|
|
taskMgr.SetCommands(cmds)
|
|
|
|
stages := stageflow.New(svc)
|
|
coordEngine.SetStageTaskRunner(stages)
|
|
coordEngine.SetContextServices(parts.Ctx, parts.KB)
|
|
if instSvc != nil && instSvc.Available() {
|
|
coordEngine.SetInstanceService(instSvc)
|
|
}
|
|
traceSvc := traceimpact.Open(pool)
|
|
traceSvc.SetStageReopener(traceStage{inst: instSvc})
|
|
traceSvc.SetDocUnlocker(traceUnlock{gov: parts.Gov})
|
|
bindRecheckChat(traceSvc, parts.Ctx)
|
|
if instSvc != nil {
|
|
instSvc.SetRecheckGate(traceRecheck{svc: traceSvc})
|
|
}
|
|
coordEngine.SetImpactActions(traceChat{svc: traceSvc})
|
|
if pool != nil {
|
|
access := authz.NewAuthorizer(pool)
|
|
wireDutySlotWriter(access)
|
|
coordEngine.SetProjectAccess(func(ctx context.Context, projectID, userID int64) error {
|
|
return access.RequireProjectAccess(ctx, projectID, userID)
|
|
})
|
|
}
|
|
if parts.Rev != nil {
|
|
wireDocRevisionPorts(parts.Rev)
|
|
parts.Rev.CoverageResolver = func(ctx context.Context, docID string) string {
|
|
projectID := projectIDFromControlledDoc(docID)
|
|
if projectID == 0 {
|
|
return traceimpact.ImpactIndexPending
|
|
}
|
|
return traceSvc.CoverageForDoc(ctx, projectID, docID)
|
|
}
|
|
}
|
|
|
|
authorizer := authz.NewAuthorizer(pool)
|
|
wireDutySlotWriter(authorizer)
|
|
rt := &Runtime{
|
|
Cfg: cfg,
|
|
Biz: svc,
|
|
Engine: coordEngine,
|
|
Auth: authSvc,
|
|
PW: pwSvc,
|
|
TaskRuns: taskMgr,
|
|
Authorizer: authorizer,
|
|
Authz: authzSvc,
|
|
WFDef: wfDefSvc,
|
|
Inst: instSvc,
|
|
Notify: notification.NewService(pool),
|
|
Resolver: workspace.NewResolver(cfg.DataDir, pool),
|
|
WSCap: wsCap,
|
|
Commands: cmds,
|
|
Rec: rec,
|
|
Stageflow: stages,
|
|
Pool: pool,
|
|
Gov: parts.Gov,
|
|
Rev: parts.Rev,
|
|
Trace: traceSvc,
|
|
Ctx: parts.Ctx,
|
|
CAS: parts.CAS,
|
|
FreeDoc: parts.FreeDoc,
|
|
KB: parts.KB,
|
|
}
|
|
rt.SysAdmin = sysadmin.NewService(cfg, sysadmin.Options{
|
|
Pool: pool,
|
|
Status: svc,
|
|
KB: parts.KB,
|
|
TaskStoreReady: func(ctx context.Context) bool {
|
|
return taskrun.TablesReady(ctx, pool)
|
|
},
|
|
FeatureValues: sysadminFeatureValues(cfg),
|
|
})
|
|
if instSvc != nil {
|
|
instSvc.SetAuthorOnlyPolicySource(&sysadminAuthorOnlyPolicyAdapter{svc: rt.SysAdmin})
|
|
svc.SetInstanceDutySource(&projectInstanceDutyAdapter{inst: instSvc})
|
|
}
|
|
rt.DocNotify = &docnotify.Subscriber{Notify: rt.Notify, Docs: parts.Gov, Chat: parts.Ctx}
|
|
rt.DocNotify.SkipWrite = func(ctx context.Context) bool {
|
|
return rt.WriterLive(ctx, events.ConsumerNotify)
|
|
}
|
|
envName := ""
|
|
if cfg != nil {
|
|
envName = cfg.Env
|
|
}
|
|
rt.Docflow = &docflow.Commands{
|
|
Gov: parts.Gov, Rev: parts.Rev, Auth: docflow.DutyAuthorizer(),
|
|
Environment: envName,
|
|
}
|
|
if pool != nil {
|
|
rt.Docflow.UoW = uow.NewPG(pool)
|
|
rt.Docflow.Events = events.PGSink{}
|
|
livePI := cfg != nil && strings.TrimSpace(cfg.PiAgentRunnerURL) != ""
|
|
taskOwner := NewTaskRunAdapter()
|
|
taskOwner.Starter = LiveTaskStarter(taskMgr, livePI)
|
|
queryOwner := NewQueryOwner(projectAccess(rt), parts.KB)
|
|
pflags := intentflow.CurrentFeatureFlags()
|
|
liveOwners := intentflow.LiveOwners{}
|
|
if pflags.U10Mutate {
|
|
liveOwners.Mutate = NewMutateOwner(projectAccess(rt), projectBlackboardPort(pool, parts.Ctx))
|
|
}
|
|
if pflags.U11DocGov {
|
|
liveOwners.DocGov = NewDocGovOwner(parts.Gov, projectAccess(rt), nil)
|
|
}
|
|
if pflags.U12RunControl {
|
|
rc := NewRunControlOwner(projectAccess(rt), pwSvc, taskMgr)
|
|
rc.ProjectName = projectNameLookup(pool)
|
|
liveOwners.RunControl = rc
|
|
}
|
|
rt.IntentPlans = intentflow.NewPlans(rt.Docflow.UoW, intentflow.NewRepository(), intentflow.Options{
|
|
Owner: intentflow.BuildOwnerMuxWithLive(
|
|
intentflow.QueryGuard{Inner: queryOwner},
|
|
taskOwner,
|
|
liveOwners,
|
|
),
|
|
Events: events.PGSink{},
|
|
ResourceVersionBinder: newResourceVersionBinder(projectBlackboardPort(pool, parts.Ctx), parts.Gov),
|
|
})
|
|
}
|
|
svc.SetAgentMaterializer(rt.Docflow)
|
|
rt.bindDocumentNotify()
|
|
rt.bindWorkflowApprovalHook()
|
|
return rt, nil
|
|
}
|
|
|
|
func (r *Runtime) bindDocumentNotify() {
|
|
if r == nil || r.notifyBound {
|
|
return
|
|
}
|
|
// Durable notify is live; do not assign OnOutbox in production.
|
|
r.notifyBound = true
|
|
}
|
|
|
|
func (r *Runtime) bindWorkflowApprovalHook() {
|
|
if r == nil || r.hookBound {
|
|
return
|
|
}
|
|
// Durable workflow_advance is live; do not register AddOnApprovedHook in production.
|
|
r.hookBound = true
|
|
}
|
|
|
|
// sysadminFeatureValues 在组合根解析各业务包公开 flag 的生效值,供 sysadmin 聚合层
|
|
// 只读消费;sysadmin 不 import 业务包。
|
|
func sysadminFeatureValues(cfg *config.Config) func() map[string]string {
|
|
return func() map[string]string {
|
|
iflags := intent.CurrentFeatureFlags()
|
|
pflags := intentflow.CurrentFeatureFlags()
|
|
sflags := ctxmgr.CurrentSummaryFlags()
|
|
values := map[string]string{
|
|
"INTENT_V2_SHADOW": strconv.FormatBool(iflags.IntentV2Shadow),
|
|
"INTENT_V2_DECISION": strconv.FormatBool(iflags.IntentV2Decision),
|
|
"INTENT_V2_DECISION_TIER": string(iflags.V2DecisionTier),
|
|
"WORKFLOW_INTENTS_ENABLED": strconv.FormatBool(iflags.WorkflowIntentsEnabled),
|
|
"MULTI_INTENT_ENABLED": strconv.FormatBool(iflags.MultiIntentEnabled),
|
|
"MULTI_INTENT_PLAN_TIER": string(pflags.PlanTier),
|
|
"INTENT_PLAN_PROJECT_ALLOWLIST": formatProjectAllowlist(pflags.ProjectAllowlist),
|
|
"MULTI_INTENT_U9_SEGMENT": strconv.FormatBool(pflags.U9Segment),
|
|
"MULTI_INTENT_U10_MUTATE": strconv.FormatBool(pflags.U10Mutate),
|
|
"MULTI_INTENT_U11_DOCGOV": strconv.FormatBool(pflags.U11DocGov),
|
|
"MULTI_INTENT_U12_RUN_CONTROL": strconv.FormatBool(pflags.U12RunControl),
|
|
"WORKFLOW_SCOPE_MODE": string(workflowscope.CurrentMode()),
|
|
"SEMANTIC_SUMMARY_SHADOW": strconv.FormatBool(sflags.Shadow),
|
|
"SEMANTIC_SUMMARY_REPLACE": strconv.FormatBool(sflags.Replace),
|
|
"PI_CHANGE_REPORT_NARRATIVE": strconv.FormatBool(docrevision.PIChangeReportNarrativeEnabled()),
|
|
}
|
|
if cfg != nil {
|
|
values["AGENT_BACKEND"] = cfg.AgentBackend
|
|
values["MAX_CONCURRENT_AGENTS"] = strconv.Itoa(cfg.MaxConcurrentAgents)
|
|
values["PI_AGENT_RUNNER_URL"] = cfg.PiAgentRunnerURL
|
|
values["ALLOW_IN_MEMORY"] = strconv.FormatBool(cfg.AllowInMemory)
|
|
values["AUTH_REGISTRATION_ENABLED"] = strconv.FormatBool(cfg.AuthRegistrationEnabled)
|
|
values["AUTH_REQUIRE_APPROVAL"] = strconv.FormatBool(cfg.AuthRequireApproval)
|
|
values["FAV_PUBLISHER_ENABLED"] = strconv.FormatBool(cfg.FAVPublisherEnabled)
|
|
values["FAV_DEGRADED_READ"] = strconv.FormatBool(cfg.FAVDegradedRead)
|
|
}
|
|
return values
|
|
}
|
|
}
|
|
|
|
func projectAccess(rt *Runtime) ProjectAccess {
|
|
return func(ctx context.Context, projectID, userID int64) error {
|
|
if rt == nil || rt.Authorizer == nil {
|
|
return intentflow.ErrPlanActionForbidden
|
|
}
|
|
return rt.Authorizer.RequireProjectAccess(ctx, projectID, userID)
|
|
}
|
|
}
|
|
|
|
func formatProjectAllowlist(ids []int64) string {
|
|
if len(ids) == 0 {
|
|
return ""
|
|
}
|
|
parts := make([]string, 0, len(ids))
|
|
for _, id := range ids {
|
|
if id == intentflow.AllowAllProjects {
|
|
return "*"
|
|
}
|
|
parts = append(parts, strconv.FormatInt(id, 10))
|
|
}
|
|
return strings.Join(parts, ",")
|
|
}
|
|
|
|
// WriterLive reports whether the durable consumer already owns the visible write.
|
|
func (r *Runtime) WriterLive(ctx context.Context, consumer string) bool {
|
|
if r == nil {
|
|
return false
|
|
}
|
|
env := ""
|
|
if r.Cfg != nil {
|
|
env = r.Cfg.Env
|
|
}
|
|
return events.WriterLive(ctx, r.Pool, env, consumer)
|
|
}
|
|
|
|
func (r *Runtime) onWorkflowDocumentApproved(ctx context.Context, doc *docgov.DocumentRecord) {
|
|
if r.WriterLive(ctx, events.ConsumerWorkflowAdvance) {
|
|
return
|
|
}
|
|
if doc == nil || doc.TaskKey == "" || doc.ProjectID <= 0 || r.Inst == nil {
|
|
return
|
|
}
|
|
result, err := r.Inst.HandleDocumentApproved(ctx, doc.ProjectID, doc.TaskKey)
|
|
if err != nil || result == nil || !result.StageReady {
|
|
return
|
|
}
|
|
if r.Notify == nil || !r.Notify.Available() {
|
|
return
|
|
}
|
|
if r.PW == nil {
|
|
return
|
|
}
|
|
project, err := r.PW.GetProject(ctx, doc.ProjectID, 0)
|
|
if err != nil || project == nil || project.CreatorUserID <= 0 {
|
|
return
|
|
}
|
|
_, _ = r.Notify.EmitStageReadyToAdvance(ctx, project.CreatorUserID, notification.StageReadyPayload{
|
|
ProjectID: doc.ProjectID,
|
|
WorkflowInstanceID: result.InstanceID,
|
|
StageInstanceID: result.StageInstanceID,
|
|
StageKey: result.StageKey,
|
|
StageName: result.StageName,
|
|
ConfigRevision: result.ConfigRevision,
|
|
RequiredDocCount: result.RequiredDocCount,
|
|
ApprovedDocCount: result.ApprovedDocCount,
|
|
})
|
|
}
|
|
|
|
// Start launches background workers. Repeat calls on a running runtime are no-ops.
|
|
func (r *Runtime) Start(_ context.Context) error {
|
|
if r == nil {
|
|
return fmt.Errorf("app: nil runtime")
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.running {
|
|
return nil
|
|
}
|
|
r.bindDocumentNotify()
|
|
r.bindWorkflowApprovalHook()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
r.reminderCancel = cancel
|
|
go r.runReminderLoop(ctx)
|
|
r.running = true
|
|
r.starts++
|
|
if r.subscribers == 0 {
|
|
r.subscribers = 1
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Stop cancels workers. A later Start recreates the reminder loop without
|
|
// double-registering document subscribers.
|
|
func (r *Runtime) Stop(_ context.Context) error {
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if !r.running {
|
|
return nil
|
|
}
|
|
if r.reminderCancel != nil {
|
|
r.reminderCancel()
|
|
r.reminderCancel = nil
|
|
}
|
|
r.running = false
|
|
return nil
|
|
}
|
|
|
|
func (r *Runtime) SubscriberCount() int {
|
|
if r == nil {
|
|
return 0
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.subscribers
|
|
}
|
|
|
|
func (r *Runtime) StartCount() int {
|
|
if r == nil {
|
|
return 0
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.starts
|
|
}
|
|
|
|
func (r *Runtime) Running() bool {
|
|
if r == nil {
|
|
return false
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.running
|
|
}
|
|
|
|
func (r *Runtime) runReminderLoop(ctx context.Context) {
|
|
r.runStageAdvanceReminders(ctx)
|
|
ticker := time.NewTicker(defaultReminderPollInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
r.runStageAdvanceReminders(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *Runtime) runStageAdvanceReminders(ctx context.Context) {
|
|
if r.Notify == nil || !r.Notify.Available() {
|
|
return
|
|
}
|
|
n, err := r.Notify.ProcessStageAdvanceReminders(ctx)
|
|
if err != nil {
|
|
slog.Warn("stage advance reminder scan failed", "error", err)
|
|
return
|
|
}
|
|
if n > 0 {
|
|
slog.Info("stage advance 24h reminders sent", "count", n)
|
|
}
|
|
}
|