diff --git a/CHANGELOG.md b/CHANGELOG.md index a584998c7..8e948628a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ - UI: show gateway auth guidance + doc link on unauthorized Control UI connections. - Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in `clawdbot security audit`. - Apps: store node auth tokens encrypted (Keychain/SecurePrefs). +- Cron: isolated cron jobs now start a fresh session id on every run to prevent context buildup. - Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts. - Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter. - Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24). diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index a7c006c7d..3fc3fedde 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -78,6 +78,7 @@ Isolated jobs run a dedicated agent turn in session `cron:`. Key behaviors: - Prompt is prefixed with `[cron: ]` for traceability. +- Each run starts a **fresh session id** (no prior conversation carry-over). - A summary is posted to the main session (prefix `Cron`, configurable). - `wakeMode: "now"` triggers an immediate heartbeat after posting the summary. - If `payload.deliver: true`, output is delivered to a channel; otherwise it stays internal. diff --git a/docs/concepts/session.md b/docs/concepts/session.md index 94995b633..b90493267 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -57,6 +57,7 @@ the workspace is writable. See [Memory](/concepts/memory) and - Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message. - Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. If `/new` or `/reset` is sent alone, Clawdbot runs a short “hello” greeting turn to confirm the reset. - Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them. +- Isolated cron jobs always mint a fresh `sessionId` per run (no idle reuse). ## Send policy (optional) Block delivery for specific session types without listing individual ids. diff --git a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts index 5e3223410..c796580be 100644 --- a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts +++ b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts @@ -48,6 +48,12 @@ async function writeSessionStore(home: string) { return storePath; } +async function readSessionEntry(storePath: string, key: string) { + const raw = await fs.readFile(storePath, "utf-8"); + const store = JSON.parse(raw) as Record; + return store[key]; +} + function makeCfg( home: string, storePath: string, @@ -466,4 +472,51 @@ describe("runCronIsolatedAgentTurn", () => { expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled(); }); }); + + it("starts a fresh session id for each cron run", async () => { + await withTempHome(async (home) => { + const storePath = await writeSessionStore(home); + const deps: CliDeps = { + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn(), + sendMessageDiscord: vi.fn(), + sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), + }; + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + + const cfg = makeCfg(home, storePath); + const job = makeJob({ kind: "agentTurn", message: "ping", deliver: false }); + + await runCronIsolatedAgentTurn({ + cfg, + deps, + job, + message: "ping", + sessionKey: "cron:job-1", + lane: "cron", + }); + const first = await readSessionEntry(storePath, "agent:main:cron:job-1"); + + await runCronIsolatedAgentTurn({ + cfg, + deps, + job, + message: "ping", + sessionKey: "cron:job-1", + lane: "cron", + }); + const second = await readSessionEntry(storePath, "agent:main:cron:job-1"); + + expect(first?.sessionId).toBeDefined(); + expect(second?.sessionId).toBeDefined(); + expect(second?.sessionId).not.toBe(first?.sessionId); + }); + }); }); diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 65e8ccde7..99db389a2 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -206,8 +206,10 @@ export async function runCronIsolatedAgentTurn(params: { const base = `[cron:${params.job.id} ${params.job.name}] ${params.message}`.trim(); const commandBody = base; - const needsSkillsSnapshot = cronSession.isNewSession || !cronSession.sessionEntry.skillsSnapshot; + const existingSnapshot = cronSession.sessionEntry.skillsSnapshot; const skillsSnapshotVersion = getSkillsSnapshotVersion(workspaceDir); + const needsSkillsSnapshot = + !existingSnapshot || existingSnapshot.version !== skillsSnapshotVersion; const skillsSnapshot = needsSkillsSnapshot ? buildWorkspaceSkillSnapshot(workspaceDir, { config: cfgWithAgentDefaults, diff --git a/src/cron/isolated-agent/session.ts b/src/cron/isolated-agent/session.ts index 555396d3d..e6eb81d14 100644 --- a/src/cron/isolated-agent/session.ts +++ b/src/cron/isolated-agent/session.ts @@ -1,12 +1,7 @@ import crypto from "node:crypto"; import type { ClawdbotConfig } from "../../config/config.js"; -import { - DEFAULT_IDLE_MINUTES, - loadSessionStore, - resolveStorePath, - type SessionEntry, -} from "../../config/sessions.js"; +import { loadSessionStore, resolveStorePath, type SessionEntry } from "../../config/sessions.js"; export function resolveCronSession(params: { cfg: ClawdbotConfig; @@ -15,16 +10,13 @@ export function resolveCronSession(params: { agentId: string; }) { const sessionCfg = params.cfg.session; - const idleMinutes = Math.max(sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES, 1); - const idleMs = idleMinutes * 60_000; const storePath = resolveStorePath(sessionCfg?.store, { agentId: params.agentId, }); const store = loadSessionStore(storePath); const entry = store[params.sessionKey]; - const fresh = entry && params.nowMs - entry.updatedAt <= idleMs; - const sessionId = fresh ? entry.sessionId : crypto.randomUUID(); - const systemSent = fresh ? Boolean(entry.systemSent) : false; + const sessionId = crypto.randomUUID(); + const systemSent = false; const sessionEntry: SessionEntry = { sessionId, updatedAt: params.nowMs, @@ -36,6 +28,8 @@ export function resolveCronSession(params: { sendPolicy: entry?.sendPolicy, lastChannel: entry?.lastChannel, lastTo: entry?.lastTo, + lastAccountId: entry?.lastAccountId, + skillsSnapshot: entry?.skillsSnapshot, }; - return { storePath, store, sessionEntry, systemSent, isNewSession: !fresh }; + return { storePath, store, sessionEntry, systemSent, isNewSession: true }; }