Fix cron-jobs path mismatch between CronService and lettabot-schedule (#284)
This commit is contained in:
@@ -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...
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
14
src/cli.ts
14
src/cli.ts
@@ -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');
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
72
src/utils/paths.test.ts
Normal 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'));
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user