Fix cron-jobs path mismatch between CronService and lettabot-schedule (#284)

This commit is contained in:
Cameron
2026-02-11 14:04:26 -08:00
committed by GitHub
parent e395dc58a4
commit 9550fc0c03
8 changed files with 196 additions and 14 deletions

View File

@@ -8,6 +8,9 @@ LETTA_API_KEY=your_letta_api_key
# Working directory for agent workspace
# WORKING_DIR=/tmp/lettabot
# Persistent data directory override (agent store, cron jobs, logs)
# DATA_DIR=/absolute/path/to/lettabot-data
# Custom system prompt (optional)
# SYSTEM_PROMPT=You are a helpful assistant...

View File

@@ -162,6 +162,19 @@ Shows:
- `cron-jobs.json` - Job configurations
- `cron-log.jsonl` - Execution logs
### Cron Storage Path
Cron state is resolved with deterministic precedence:
1. `RAILWAY_VOLUME_MOUNT_PATH`
2. `DATA_DIR`
3. `WORKING_DIR`
4. `/tmp/lettabot`
Migration note:
- Older versions used `process.cwd()/cron-jobs.json` when `DATA_DIR` was not set.
- On first run after upgrade, LettaBot auto-copies that legacy file into the new canonical cron path.
## Troubleshooting
### Cron jobs not running

View File

@@ -14,7 +14,7 @@ const config = loadAppConfigOrExit();
applyConfigToEnv(config);
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { getDataDir, getWorkingDir } from './utils/paths.js';
import { getCronStorePath, getDataDir, getLegacyCronStorePath, getWorkingDir } from './utils/paths.js';
import { fileURLToPath } from 'node:url';
import { spawn, spawnSync } from 'node:child_process';
import updateNotifier from 'update-notifier';
@@ -326,7 +326,8 @@ async function main() {
const workingDir = getWorkingDir();
const agentJsonPath = join(dataDir, 'lettabot-agent.json');
const skillsDir = join(workingDir, '.skills');
const cronJobsPath = join(dataDir, 'cron-jobs.json');
const cronJobsPath = getCronStorePath();
const legacyCronJobsPath = getLegacyCronStorePath();
p.intro('🗑️ Destroy LettaBot Data');
@@ -334,6 +335,9 @@ async function main() {
p.log.message(` • Agent store: ${agentJsonPath}`);
p.log.message(` • Skills: ${skillsDir}`);
p.log.message(` • Cron jobs: ${cronJobsPath}`);
if (legacyCronJobsPath !== cronJobsPath) {
p.log.message(` • Legacy cron jobs: ${legacyCronJobsPath}`);
}
p.log.message('');
p.log.message('Note: The agent on Letta servers will NOT be deleted.');
@@ -367,6 +371,12 @@ async function main() {
p.log.success('Deleted cron-jobs.json');
deleted++;
}
if (legacyCronJobsPath !== cronJobsPath && existsSync(legacyCronJobsPath)) {
rmSync(legacyCronJobsPath);
p.log.success('Deleted legacy cron-jobs.json');
deleted++;
}
if (deleted === 0) {
p.log.info('Nothing to delete');

View File

@@ -13,9 +13,9 @@
*/
import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { getDataDir } from '../utils/paths.js';
import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync, copyFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { getCronLogPath, getCronStorePath, getLegacyCronStorePath } from '../utils/paths.js';
// Parse ISO datetime string
function parseISODateTime(input: string): Date {
@@ -56,8 +56,23 @@ interface CronStore {
}
// Store path
const STORE_PATH = resolve(getDataDir(), 'cron-jobs.json');
const LOG_PATH = resolve(getDataDir(), 'cron-log.jsonl');
const STORE_PATH = getCronStorePath();
const LOG_PATH = getCronLogPath();
function migrateLegacyStoreIfNeeded(): void {
if (existsSync(STORE_PATH)) return;
const legacyPath = getLegacyCronStorePath();
if (legacyPath === STORE_PATH || !existsSync(legacyPath)) return;
try {
mkdirSync(dirname(STORE_PATH), { recursive: true });
copyFileSync(legacyPath, STORE_PATH);
console.error(`[Cron] store_migrated: ${JSON.stringify({ from: legacyPath, to: STORE_PATH })}`);
} catch (e) {
console.error('[Cron] Failed to migrate legacy cron store:', e);
}
}
function log(event: string, data: Record<string, unknown>): void {
const entry = {
@@ -78,6 +93,7 @@ function log(event: string, data: Record<string, unknown>): void {
}
function loadStore(): CronStore {
migrateLegacyStoreIfNeeded();
try {
if (existsSync(STORE_PATH)) {
return JSON.parse(readFileSync(STORE_PATH, 'utf-8'));

View File

@@ -12,11 +12,11 @@ import { resolve, dirname } from 'node:path';
import type { AgentSession } from '../core/interfaces.js';
import type { TriggerContext } from '../core/types.js';
import { buildHeartbeatPrompt, buildCustomHeartbeatPrompt } from '../core/prompts.js';
import { getDataDir } from '../utils/paths.js';
import { getCronLogPath } from '../utils/paths.js';
// Log file
const LOG_PATH = resolve(getDataDir(), 'cron-log.jsonl');
const LOG_PATH = getCronLogPath();
function logEvent(event: string, data: Record<string, unknown>): void {
const entry = {

View File

@@ -5,15 +5,15 @@
* Supports heartbeat check-ins and agent-managed cron jobs.
*/
import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync, watch, type FSWatcher } from 'node:fs';
import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync, copyFileSync, watch, type FSWatcher } from 'node:fs';
import { resolve, dirname } from 'node:path';
import type { AgentSession } from '../core/interfaces.js';
import type { CronJob, CronJobCreate, CronSchedule, CronConfig, HeartbeatConfig } from './types.js';
import { DEFAULT_HEARTBEAT_MESSAGES } from './types.js';
import { getDataDir } from '../utils/paths.js';
import { getCronDataDir, getCronLogPath, getCronStorePath, getLegacyCronStorePath } from '../utils/paths.js';
// Log file for cron events
const LOG_PATH = resolve(getDataDir(), 'cron-log.jsonl');
const LOG_PATH = getCronLogPath();
function logEvent(event: string, data: Record<string, unknown>): void {
const entry = {
@@ -61,10 +61,28 @@ export class CronService {
this.bot = bot;
this.config = config || {};
this.storePath = config?.storePath
? resolve(getDataDir(), config.storePath)
: resolve(getDataDir(), 'cron-jobs.json');
? resolve(getCronDataDir(), config.storePath)
: getCronStorePath();
this.migrateLegacyStoreIfNeeded();
this.loadJobs();
}
private migrateLegacyStoreIfNeeded(): void {
// Explicit storePath overrides are already deterministic and should not auto-migrate.
if (this.config.storePath) return;
if (existsSync(this.storePath)) return;
const legacyPath = getLegacyCronStorePath();
if (legacyPath === this.storePath || !existsSync(legacyPath)) return;
try {
mkdirSync(dirname(this.storePath), { recursive: true });
copyFileSync(legacyPath, this.storePath);
logEvent('store_migrated', { from: legacyPath, to: this.storePath });
} catch (e) {
console.error('[Cron] Failed to migrate legacy store:', e);
}
}
private loadJobs(): void {
try {

72
src/utils/paths.test.ts Normal file
View File

@@ -0,0 +1,72 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { resolve } from 'node:path';
import {
getCronDataDir,
getCronLogPath,
getCronStorePath,
getLegacyCronStorePath,
} from './paths.js';
const TEST_ENV_KEYS = [
'RAILWAY_VOLUME_MOUNT_PATH',
'DATA_DIR',
'WORKING_DIR',
] as const;
const ORIGINAL_ENV: Record<(typeof TEST_ENV_KEYS)[number], string | undefined> = {
RAILWAY_VOLUME_MOUNT_PATH: process.env.RAILWAY_VOLUME_MOUNT_PATH,
DATA_DIR: process.env.DATA_DIR,
WORKING_DIR: process.env.WORKING_DIR,
};
function clearPathEnv(): void {
for (const key of TEST_ENV_KEYS) {
delete process.env[key];
}
}
describe('cron path resolution', () => {
beforeEach(() => {
clearPathEnv();
});
afterEach(() => {
clearPathEnv();
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
if (value !== undefined) {
process.env[key] = value;
}
}
});
it('prioritizes Railway volume path', () => {
process.env.RAILWAY_VOLUME_MOUNT_PATH = '/railway/volume';
process.env.DATA_DIR = '/custom/data';
process.env.WORKING_DIR = '/custom/work';
expect(getCronDataDir()).toBe('/railway/volume');
});
it('uses DATA_DIR when Railway volume is not set', () => {
process.env.DATA_DIR = '/custom/data';
process.env.WORKING_DIR = '/custom/work';
expect(getCronDataDir()).toBe('/custom/data');
});
it('uses WORKING_DIR when DATA_DIR is not set', () => {
process.env.WORKING_DIR = '/custom/work';
expect(getCronDataDir()).toBe('/custom/work');
});
it('falls back to /tmp/lettabot when no overrides are set', () => {
expect(getCronDataDir()).toBe('/tmp/lettabot');
expect(getCronStorePath()).toBe('/tmp/lettabot/cron-jobs.json');
expect(getCronLogPath()).toBe('/tmp/lettabot/cron-log.jsonl');
});
it('keeps legacy cron path behavior for migration', () => {
expect(getLegacyCronStorePath()).toBe(resolve(process.cwd(), 'cron-jobs.json'));
});
});

View File

@@ -54,6 +54,56 @@ export function getWorkingDir(): string {
return '/tmp/lettabot';
}
/**
* Get the canonical directory for cron state (cron-jobs.json / cron-log.jsonl).
*
* This is intentionally deterministic across server and CLI contexts, and does
* not depend on process.cwd().
*
* Priority:
* 1. RAILWAY_VOLUME_MOUNT_PATH (Railway persistent volume)
* 2. DATA_DIR (explicit persistent data override)
* 3. WORKING_DIR (runtime workspace)
* 4. /tmp/lettabot (deterministic local fallback)
*/
export function getCronDataDir(): string {
if (process.env.RAILWAY_VOLUME_MOUNT_PATH) {
return process.env.RAILWAY_VOLUME_MOUNT_PATH;
}
if (process.env.DATA_DIR) {
return process.env.DATA_DIR;
}
if (process.env.WORKING_DIR) {
return process.env.WORKING_DIR;
}
return '/tmp/lettabot';
}
/**
* Canonical cron store path.
*/
export function getCronStorePath(): string {
return resolve(getCronDataDir(), 'cron-jobs.json');
}
/**
* Canonical cron log path.
*/
export function getCronLogPath(): string {
return resolve(getCronDataDir(), 'cron-log.jsonl');
}
/**
* Legacy cron store path (used before deterministic cron path resolution).
* Kept for migration of existing local files.
*/
export function getLegacyCronStorePath(): string {
return resolve(getDataDir(), 'cron-jobs.json');
}
/**
* Check if running on Railway
*/