diff --git a/apps/sidecar/src/services/automation/automation-schedule.ts b/apps/sidecar/src/services/automation/automation-schedule.ts index 8dca853f7..34a6b44c2 100644 --- a/apps/sidecar/src/services/automation/automation-schedule.ts +++ b/apps/sidecar/src/services/automation/automation-schedule.ts @@ -113,6 +113,27 @@ export function validateCronExpression(expr: string): void { }); } +/** + * 日/月组合是否至少存在一个可命中的日期(闰年上界保守放行 2/29)。 + * 字段各自合法但组合永假(如 `0 9 31 2 *` 二月无 31 日)会让逐分钟扫描 + * 空转满一年才返回 null,创建/改期时同步卡顿数秒(#408)。 + * dom 与 dow 同时受限时 cron 取 OR 语义——dow 自身总能提供命中日期,直接可行。 + */ +export function cronDateFeasible(expr: string): boolean { + const parts = expr.trim().split(/\s+/); + if (parts.length !== 5) return true; + const domField = parts[2]!; + const monthField = parts[3]!; + const dowField = parts[4]!; + if (domField.trim() === "*" || monthField.trim() === "*" || dowField.trim() !== "*") return true; + const domSpec = CRON_FIELD_SPECS[2]!; + const monthSpec = CRON_FIELD_SPECS[3]!; + const days = Array.from({ length: 31 }, (_, i) => i + 1).filter((d) => cronFieldMatches(domField, domSpec, d)); + const months = Array.from({ length: 12 }, (_, i) => i + 1).filter((m) => cronFieldMatches(monthField, monthSpec, m)); + const daysInMonthLeap = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + return months.some((m) => days.some((d) => d <= daysInMonthLeap[m - 1]!)); +} + export function matchCronExpression(expr: string, date: Date, timezone?: string): boolean { const parts = expr.trim().split(/\s+/); if (parts.length !== 5) return false; @@ -125,6 +146,9 @@ export function matchCronExpression(expr: string, date: Date, timezone?: string) export function getNextCronRunAt(expr: string, fromMs: number, timezone?: string): number | null { validateCronExpression(expr); + // 永假组合直接短路返回 null(scheduleJob 对 null 即不排程), + // 不再逐分钟空转满年(#408) + if (!cronDateFeasible(expr)) return null; const resolvedTimezone = resolveTimezone(timezone); const candidate = new Date(fromMs); candidate.setSeconds(0, 0); @@ -153,6 +177,10 @@ export function validateAutomationSchedule(schedule: AutomationSchedule): void { throw new Error("cron 类型任务缺少 cronExpr"); } validateCronExpression(schedule.cronExpr); + // 创建/改期即拒绝永假组合,而非静默永不执行(#408) + if (!cronDateFeasible(schedule.cronExpr)) { + throw new Error(`cron 表达式的日/月组合永不可能命中: ${schedule.cronExpr}`); + } resolveTimezone(schedule.timezone); validateMisfirePolicy(schedule.misfirePolicy); return; diff --git a/apps/sidecar/src/services/memory-v2/background-extractor.ts b/apps/sidecar/src/services/memory-v2/background-extractor.ts index 2440a60d7..158718bc1 100644 --- a/apps/sidecar/src/services/memory-v2/background-extractor.ts +++ b/apps/sidecar/src/services/memory-v2/background-extractor.ts @@ -39,9 +39,13 @@ export interface BackgroundMemoryExtractionRequest { interface ThreadQueueState { running: boolean; - trailing?: BackgroundMemoryExtractionRequest; + /** 待提取队列(FIFO)。单槽 trailing 会覆盖中间轮次——每轮请求只带当轮 items,覆盖即永久丢证(#408)。 */ + pending: BackgroundMemoryExtractionRequest[]; } +/** 提取失败的批次暂存,并入该线程下一次提取,避免静默丢轮(#408)。 */ +const carriedBatches = new Map(); + interface ExtractionCursor { threadId: string; cursor: number; @@ -69,9 +73,9 @@ interface BackgroundExtractionResult { export function enqueueBackgroundMemoryExtraction(input: BackgroundMemoryExtractionRequest): void { if (!getMemoryRuntimeConfig().backgroundExtraction) return; if (input.threadType === "subagent" || input.chatType === "group" || input.chatType === "channel") return; - const state = queues.get(input.threadId) ?? { running: false }; + const state = queues.get(input.threadId) ?? { running: false, pending: [] }; if (state.running) { - state.trailing = input; + state.pending.push(input); queues.set(input.threadId, state); return; } @@ -101,10 +105,10 @@ async function runQueued(input: BackgroundMemoryExtractionRequest): Promise void runQueued(trailing), 0); + const next = state.pending.shift(); + if (next) { + // FIFO 逐轮提取:每轮请求只含当轮 items,覆盖式单槽会丢中间轮(#408) + setTimeout(() => void runQueued(next), 0); return; } queues.delete(input.threadId); @@ -130,7 +134,12 @@ async function runExtraction( signal: AbortSignal ): Promise { signal.throwIfAborted(); - const sources = extractionSources(input.items); + // 失败批次的 items 并入本轮:请求只带当轮 items,cursor 又停在旧位, + // 不回放则失败轮的证据永久缺席(#408) + const carried = carriedBatches.get(input.threadId) ?? []; + carriedBatches.delete(input.threadId); + const mergedItems = [...carried.flat(), ...input.items]; + const sources = extractionSources(mergedItems); const cursor = readCursor(input); if (cursor.lastRunId === input.runId && (cursor.status === "completed" || cursor.status === "skipped")) { return { scannedItems: 0, changedItems: 0 }; @@ -233,6 +242,8 @@ async function runExtraction( }); return { scannedItems: sources.length, changedItems: changed.length }; } catch (error) { + // 本批 items 暂存待下次提取:cursor 不前进,但请求式扫描不会重放旧轮(#408) + carriedBatches.set(input.threadId, [...(carriedBatches.get(input.threadId) ?? []), input.items]); writeCursor(input, { ...cursor, status: "failed", diff --git a/apps/sidecar/src/services/memory-v2/command-service.ts b/apps/sidecar/src/services/memory-v2/command-service.ts index 014eb2ccf..724d01025 100644 --- a/apps/sidecar/src/services/memory-v2/command-service.ts +++ b/apps/sidecar/src/services/memory-v2/command-service.ts @@ -74,7 +74,7 @@ export class MemoryCommandService { runId: input.runId, recordIds: input.evidenceRefs?.flatMap((ref) => ref.id ? [ref.id] : []), sourcePaths: input.evidenceRefs?.flatMap((ref) => ref.path ? [ref.path] : []), - quote: input.evidenceRefs?.find((ref) => ref.quote)?.quote + quote: sanitizeEvidenceQuote(input.evidenceRefs?.find((ref) => ref.quote)?.quote) }, evidenceRefs: input.evidenceRefs }; @@ -147,7 +147,7 @@ export class MemoryCommandService { evidence: { recordIds: input.evidenceRefs?.flatMap((ref) => ref.id ? [ref.id] : []), sourcePaths: input.evidenceRefs?.flatMap((ref) => ref.path ? [ref.path] : []), - quote: input.evidenceRefs?.find((ref) => ref.quote)?.quote + quote: sanitizeEvidenceQuote(input.evidenceRefs?.find((ref) => ref.quote)?.quote) }, evidenceRefs: input.evidenceRefs }; @@ -606,7 +606,19 @@ function legacyKindForRole(role: MemoryV2SemanticRole): MemoryV2Kind { } function containsSecret(content: string): boolean { - return /(?:api[_-]?key|token|password|密码|验证码|secret)\s*[:=]\s*\S+|\bsk-[A-Za-z0-9_-]{16,}\b/i.test(content); + return /(?:api[_-]?key|token|password|密码|验证码|secret)\s*[:=]\s*\S+|\bsk-[A-Za-z0-9_-]{16,}\b/i.test(content) + || /\b(?:ghp|gho|ghu|ghs)_[A-Za-z0-9]{20,}\b/.test(content) + || /\bgithub_pat_[A-Za-z0-9_]{20,}\b/.test(content) + || /\bAKIA[0-9A-Z]{16}\b/.test(content) + || /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(content) + || /\bBearer\s+eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\./i.test(content) + || /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/.test(content); +} + +/** 证据原文先过密钥过滤:statement 滤了但整条源消息 quote 会明文进 frontmatter 并注入 prompt(#408)。 */ +function sanitizeEvidenceQuote(quote: string | undefined): string | undefined { + if (!quote) return undefined; + return containsSecret(quote) ? "[证据原文含疑似密钥,已省略]" : quote; } function strongerConfidence(left: MemoryV2Confidence, right: MemoryV2Confidence): MemoryV2Confidence { diff --git a/apps/sidecar/src/services/routine/routine-date.ts b/apps/sidecar/src/services/routine/routine-date.ts new file mode 100644 index 000000000..b538e10ed --- /dev/null +++ b/apps/sidecar/src/services/routine/routine-date.ts @@ -0,0 +1,11 @@ +/** + * 本地时区日期键(YYYY-MM-DD)。 + * 此前用 toISOString().slice(0,10) 取 UTC 键:UTC-5 时区 19:00 后当日 routine + * 即失联、晨间生成会落到昨日键(#408)。日程语义一律按本地日历日。 + */ +export function localDateKey(date = new Date()): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} diff --git a/apps/sidecar/src/services/routine/routine-executor.ts b/apps/sidecar/src/services/routine/routine-executor.ts index 9127bf449..cd4d8f6d8 100644 --- a/apps/sidecar/src/services/routine/routine-executor.ts +++ b/apps/sidecar/src/services/routine/routine-executor.ts @@ -6,10 +6,12 @@ import { getActivityExecutor } from "./routine-activities" import { readRoutine, writeRoutine, appendRoutineRun, listAutomationRunsForJob, getLatestAssistantResponse } from "./routine-store" import { createLogger } from "../infra/logger" +import { localDateKey } from "./routine-date" const log = createLogger("routine") function today(): string { - return new Date().toISOString().slice(0, 10) + // 本地时区日期键,替代 UTC toISOString 键(#408) + return localDateKey() } export async function scheduleRoutineEntries(routine: DailyRoutine): Promise { diff --git a/apps/sidecar/src/services/routine/routine-generator.ts b/apps/sidecar/src/services/routine/routine-generator.ts index 5580da9c4..98ee52682 100644 --- a/apps/sidecar/src/services/routine/routine-generator.ts +++ b/apps/sidecar/src/services/routine/routine-generator.ts @@ -4,12 +4,15 @@ import { getApplicableActivities } from "./routine-activities" import { writeRoutine, readRoutine } from "./routine-store" import { generateRoutinePlanWithLlm, type LlmRoutinePlan } from "./routine-llm-adapter" import { createLogger } from "../infra/logger" + +import { localDateKey } from "./routine-date" import { getPlanningTodoStore } from "../planning/planning-todo-store" const log = createLogger("routine") function today(): string { - return new Date().toISOString().slice(0, 10) + // 本地时区日期键,替代 UTC toISOString 键(#408) + return localDateKey() } function collectRoutineContext(): RoutineContext { diff --git a/apps/sidecar/src/services/routine/routine-runner.ts b/apps/sidecar/src/services/routine/routine-runner.ts index f83a05093..88ca5e90c 100644 --- a/apps/sidecar/src/services/routine/routine-runner.ts +++ b/apps/sidecar/src/services/routine/routine-runner.ts @@ -4,6 +4,7 @@ import { syncRoutineStatus } from "./routine-executor" import { readRoutine } from "./routine-store" import { createLogger } from "../infra/logger" +import { localDateKey } from "./routine-date" const log = createLogger("routine") const ROUTINE_GENERATE_HOUR = 8 @@ -14,7 +15,8 @@ let syncTimer: ReturnType | null = null let runnerStarted = false function today(): string { - return new Date().toISOString().slice(0, 10) + // 本地时区日期键,替代 UTC toISOString 键(#408) + return localDateKey() } function scheduleNextGeneration(): void {