Skip to content
Closed
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
81 changes: 69 additions & 12 deletions src/core/dream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export type DreamState = {
last_run: number;
/** Human-readable ISO timestamp of the last completed dream run. */
last_run_at?: string;
/** Unix millisecond timestamp of the last time a dream was started (success or failure). */
last_started?: number;
/** "idle" when no dream is running, "running" while one is active. */
status: "idle" | "running";
};
Expand All @@ -39,6 +41,7 @@ export type DreamState = {
const DREAM_INTERVAL_MS = 12 * 60 * 60 * 1000; // 12 hours
const DREAM_STATE_FILE = pathFiles.dreamState;
const DREAM_TIMEOUT_MS = 10 * 60 * 1000; // 10-minute max
const DREAM_ABORT_GRACE_MS = 30 * 1000; // wait up to 30s for SDK to honour abort
const DREAM_LOGS_DIR = resolve(dirs.logs, "dreams");

// ── State ────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -96,24 +99,29 @@ export async function forceDream(): Promise<void> {
async function executeDream(trigger: "auto" | "forced"): Promise<void> {
const state = readDreamState();
const now = Date.now();
const previousLastRun = state?.last_run ?? 0;

dreaming = true;
writeDreamState({ last_run: now, status: "running" });
// Preserve last_run from the previous completed run so a crash between here
// and completion doesn't reset the 12-hour interval to the start of the
// failed run (which would suppress the next dream for up to 12h from the
// crash). Use a separate last_started to track when this attempt began.
writeDreamState({ last_run: previousLastRun, last_started: now, status: "running" });
log(
"dream",
`${trigger === "forced" ? "Force-triggering" : "Triggering"} memory consolidation (last run: ${state?.last_run ? new Date(state.last_run).toISOString() : "never"})`,
`${trigger === "forced" ? "Force-triggering" : "Triggering"} memory consolidation (last run: ${previousLastRun ? new Date(previousLastRun).toISOString() : "never"})`,
);

try {
const dreamLogPath = await runDreamAgent(state?.last_run ?? 0);
writeDreamState({ last_run: Date.now(), status: "idle" });
const dreamLogPath = await runDreamAgent(previousLastRun);
writeDreamState({ last_run: Date.now(), last_started: now, status: "idle" });
log(
"dream",
`Memory consolidation complete (${trigger}), log: ${dreamLogPath}`,
);
} catch (err) {
logError("dream", `Memory consolidation failed (${trigger})`, err);
writeDreamState({ last_run: Date.now(), status: "idle" });
writeDreamState({ last_run: previousLastRun, last_started: now, status: "idle" });
if (trigger === "forced") throw err;
} finally {
dreaming = false;
Expand Down Expand Up @@ -195,6 +203,8 @@ If commands fail, log the error and continue — this stage is optional.`
`**Prompt:**\n\`\`\`\n${prompt}\n\`\`\`\n\n---\n`,
);

const abortController = new AbortController();

const options = {
model,
systemPrompt: configRef.mempalace
Expand All @@ -203,6 +213,7 @@ If commands fail, log the error and continue — this stage is optional.`
cwd: workspace,
permissionMode: "bypassPermissions" as const,
allowDangerouslySkipPermissions: true,
abortController,
...(configRef.claudeBinary
? { pathToClaudeCodeExecutable: configRef.claudeBinary }
: {}),
Expand All @@ -213,12 +224,18 @@ If commands fail, log the error and continue — this stage is optional.`
disallowedTools: [...DISALLOWED_TOOLS_BACKGROUND],
};

let timeoutFired = false;
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const timeoutPromise = new Promise<never>((_, reject) => {
const t = setTimeout(
() => reject(new Error("Dream agent timed out")),
DREAM_TIMEOUT_MS,
);
const t = setTimeout(() => {
timeoutFired = true;
try {
abortController.abort();
} catch {
/* ignore */
}
reject(new Error("Dream agent timed out"));
}, DREAM_TIMEOUT_MS);
t.unref(); // Don't prevent Node.js from exiting cleanly during shutdown
timeoutHandle = t;
});
Expand All @@ -240,13 +257,33 @@ If commands fail, log the error and continue — this stage is optional.`
try {
await Promise.race([agentPromise, timeoutPromise]);
} catch (err) {
const wasTimeout = timeoutFired;
if (timeoutHandle) {
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
appendDreamLog(
dreamLogFile,
`\n---\n**Dream FAILED at ${new Date().toISOString()}:** ${err}\n`,
);
// On timeout, wait for the agent to actually finish before releasing the
// dreaming lock to prevent overlapping dream runs
await agentPromise.catch(() => {});
if (wasTimeout) {
// Give the SDK a bounded grace window to honour the abort signal.
// If it still hasn't settled we release the dreaming lock anyway —
// better to allow the next dream than hang forever.
const settled = await raceWithTimeout(
agentPromise.catch(() => "settled" as const),
DREAM_ABORT_GRACE_MS,
);
if (settled === "timed_out") {
logWarn(
"dream",
`Dream SDK ignored abort after ${DREAM_ABORT_GRACE_MS}ms — releasing lock`,
);
}
} else {
// Non-timeout failure — agentPromise has already settled.
await agentPromise.catch(() => {});
}
throw err;
} finally {
if (timeoutHandle) clearTimeout(timeoutHandle);
Expand Down Expand Up @@ -379,3 +416,23 @@ function writeDreamState(state: DreamState): void {
logError("dream", "Failed to write dream state", err);
}
}

// ── Async helpers ────────────────────────────────────────────────────────────

async function raceWithTimeout<T>(
p: Promise<T>,
ms: number,
): Promise<T | "timed_out"> {
let t: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
p,
new Promise<"timed_out">((resolve) => {
t = setTimeout(() => resolve("timed_out"), ms);
t.unref();
}),
]);
} finally {
if (t) clearTimeout(t);
}
}
4 changes: 2 additions & 2 deletions src/core/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
const classified = classify(err);
if (!classified.retryable) {
// Wrap in AbortError to prevent further retries
throw new AbortError(classified);
throw new AbortError(classified.message);
}
const delayMs =
classified.retryAfterMs ?? 1000 * Math.pow(2, attempt - 1);
Expand Down Expand Up @@ -189,7 +189,7 @@ export class Gateway {
// String-id routing (Teams) — must match an active context.
chatId = this.findContextByStringId(rawChatId);
}
if (!chatId) {
if (chatId == null) {
return { ok: false, error: "No active chat context" };
}

Expand Down
8 changes: 6 additions & 2 deletions src/frontend/telegram/callbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
enablePulse,
isPulseEnabled,
} from "../../core/pulse.js";
import { handleCallbackQuery } from "./handlers.js";
import { handleCallbackQuery, isAccessAllowed } from "./handlers.js";
import { escapeHtml } from "./formatting.js";
import {
renderSettingsText,
Expand Down Expand Up @@ -279,7 +279,11 @@ export function registerCallbacks(
return;
}

// Forward other callbacks to the AI backend
// Forward other callbacks to the AI backend — access-controlled
if (!(await isAccessAllowed(ctx, bot))) {
await ctx.answerCallbackQuery().catch(() => {});
return;
}
handleCallbackQuery(ctx, bot, config);
});
}
1 change: 1 addition & 0 deletions src/frontend/telegram/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -837,7 +837,7 @@
async function processAndReply(params: ProcessAndReplyParams): Promise<void> {
const {
bot,
config,

Check warning on line 840 in src/frontend/telegram/handlers.ts

View workflow job for this annotation

GitHub Actions / Code Quality

eslint(no-unused-vars)

Variable 'config' is declared but never used. Unused variables should start with a '_'.
chatId,
numericChatId,
replyToId,
Expand Down Expand Up @@ -875,7 +875,7 @@
trackDmUser(senderId, senderName, senderUsername);
}

const result = await execute({

Check warning on line 878 in src/frontend/telegram/handlers.ts

View workflow job for this annotation

GitHub Actions / Code Quality

eslint(no-unused-vars)

Variable 'result' is declared but never used. Unused variables should start with a '_'.
chatId: String(chatId),
numericChatId,
prompt,
Expand Down Expand Up @@ -1162,6 +1162,7 @@
): Promise<void> {
if (!ctx.message || !ctx.chat || !shouldHandleInGroup(ctx)) return;
if (!(await isAccessAllowed(ctx, bot))) return;
if (ctx.from?.id && isUserRateLimited(ctx.from.id)) return;

const chatId = String(ctx.chat.id);
const isGroup = ctx.chat.type === "group" || ctx.chat.type === "supergroup";
Expand Down
5 changes: 4 additions & 1 deletion src/storage/cron-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,12 @@ export function validateCronExpression(
try {
const cron = new Cron(expr, { timezone: timezone ?? undefined });
const nextDate = cron.nextRun();
if (!nextDate) {
return { valid: false, error: "Expression has no future occurrences" };
}
return {
valid: true,
next: (nextDate as Date).toISOString(),
next: nextDate.toISOString(),
};
} catch (err) {
return {
Expand Down
2 changes: 1 addition & 1 deletion src/storage/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export function loadHistory(): void {
/* backup also corrupt */
}
logError(
"sessions",
"history",
"History data corrupt and no valid backup — starting fresh",
);
}
Expand Down
Loading