Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions apps/sidecar/src/services/automation/automation-schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
27 changes: 19 additions & 8 deletions apps/sidecar/src/services/memory-v2/background-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,13 @@ export interface BackgroundMemoryExtractionRequest {

interface ThreadQueueState {
running: boolean;
trailing?: BackgroundMemoryExtractionRequest;
/** 待提取队列(FIFO)。单槽 trailing 会覆盖中间轮次——每轮请求只带当轮 items,覆盖即永久丢证(#408)。 */
pending: BackgroundMemoryExtractionRequest[];
}

/** 提取失败的批次暂存,并入该线程下一次提取,避免静默丢轮(#408)。 */
const carriedBatches = new Map<string, LumeRunItem[][]>();

interface ExtractionCursor {
threadId: string;
cursor: number;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -101,10 +105,10 @@ async function runQueued(input: BackgroundMemoryExtractionRequest): Promise<void
} finally {
const state = queues.get(input.threadId);
if (!state) return;
const trailing = state.trailing;
if (trailing) {
state.trailing = undefined;
setTimeout(() => void runQueued(trailing), 0);
const next = state.pending.shift();
if (next) {
// FIFO 逐轮提取:每轮请求只含当轮 items,覆盖式单槽会丢中间轮(#408)
setTimeout(() => void runQueued(next), 0);
return;
}
queues.delete(input.threadId);
Expand All @@ -130,7 +134,12 @@ async function runExtraction(
signal: AbortSignal
): Promise<BackgroundExtractionResult> {
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 };
Expand Down Expand Up @@ -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",
Expand Down
18 changes: 15 additions & 3 deletions apps/sidecar/src/services/memory-v2/command-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
Expand Down Expand Up @@ -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
};
Expand Down Expand Up @@ -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 {
Expand Down
11 changes: 11 additions & 0 deletions apps/sidecar/src/services/routine/routine-date.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
4 changes: 3 additions & 1 deletion apps/sidecar/src/services/routine/routine-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DailyRoutine> {
Expand Down
5 changes: 4 additions & 1 deletion apps/sidecar/src/services/routine/routine-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion apps/sidecar/src/services/routine/routine-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,7 +15,8 @@ let syncTimer: ReturnType<typeof setInterval> | null = null
let runnerStarted = false

function today(): string {
return new Date().toISOString().slice(0, 10)
// 本地时区日期键,替代 UTC toISOString 键(#408)
return localDateKey()
}

function scheduleNextGeneration(): void {
Expand Down
Loading