fix(skills): persist Railway skill directories across redeploys (#582)

Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
Cameron
2026-03-12 21:55:12 -07:00
committed by GitHub
parent 791e722fca
commit 227b986396
4 changed files with 76 additions and 6 deletions

View File

@@ -104,7 +104,7 @@ The Railway template includes a persistent volume mounted at `/data`. This is se
- **Agent ID** - No need to set `LETTA_AGENT_ID` manually after first run
- **Cron jobs** - Scheduled tasks survive restarts
- **Skills** - Downloaded skills persist
- **Skills** - Agent-scoped (`.letta/agents/.../skills`) and working-dir (`WORKING_DIR/.skills`) skills persist
- **Attachments** - Downloaded media files
### Volume Size
@@ -123,6 +123,8 @@ If you deploy manually from a fork instead of using the template, you'll need to
LettaBot automatically detects `RAILWAY_VOLUME_MOUNT_PATH` and uses it for persistent data.
By default (when `WORKING_DIR` is unset), LettaBot uses `$RAILWAY_VOLUME_MOUNT_PATH/data` as the working directory, so `WORKING_DIR/.skills` is persisted across redeploys. Agent-scoped skills are also stored under `$RAILWAY_VOLUME_MOUNT_PATH/.letta/agents/.../skills`.
## Remote Pairing Approval
When using `pairing` DM policy on a cloud deployment, you need a way to approve new users without CLI access.

View File

@@ -23,6 +23,8 @@ LettaBot scans these directories in priority order. Same-name skills at higher p
| 4 | `skills/` (in lettabot repo) | Bundled | Ships with lettabot |
| 5 (lowest) | `~/.agents/skills/` | skills.sh | Installed via [skills.sh](https://skills.sh) |
On Railway with a mounted volume, LettaBot stores `.letta` skill paths under `$RAILWAY_VOLUME_MOUNT_PATH/.letta/` by default, so agent-scoped and global skills survive redeploys.
Feature-gated skills are copied from source directories into the agent-scoped directory (`~/.letta/agents/{id}/skills/`) when a session is first acquired. The copy is idempotent -- skills already present in the target are skipped.
## Feature-gated skills
@@ -114,6 +116,10 @@ When a session starts, `prependSkillDirsToPath()` in `src/skills/loader.ts` prep
1. **Agent-scoped skills** (`~/.letta/agents/{id}/skills/`) — feature-gated skills installed by `installSkillsToAgent()` on startup.
2. **Working-dir skills** (`WORKING_DIR/.skills/`) — skills enabled via `lettabot skills enable <name>` or the interactive `lettabot skills` wizard.
On Railway with a mounted volume, `WORKING_DIR` defaults to `$RAILWAY_VOLUME_MOUNT_PATH/data` when not explicitly set, so working-dir skills are persisted on the volume by default.
On Railway with a mounted volume, agent-scoped skills are stored under `$RAILWAY_VOLUME_MOUNT_PATH/.letta/agents/{id}/skills/`.
Only directories containing at least one non-`.md` file are added. The prepend is idempotent — directories already on PATH are not duplicated. PATH is not restored after the call; the augmented PATH persists for the lifetime of the process, which is correct because the subprocess retains its inherited environment.
To verify skill directories are present after startup, check the child subprocess's `/proc/[pid]/environ` (not the parent lettabot process, which shares the same augmented PATH).

View File

@@ -12,8 +12,66 @@ import {
isVoiceMemoConfigured,
} from './loader.js';
const ORIGINAL_WORKING_DIR = process.env.WORKING_DIR;
const ORIGINAL_RAILWAY_VOLUME_MOUNT_PATH = process.env.RAILWAY_VOLUME_MOUNT_PATH;
async function importFreshLoader() {
vi.resetModules();
return import('./loader.js');
}
function restoreLoaderPathEnv() {
if (ORIGINAL_WORKING_DIR === undefined) {
delete process.env.WORKING_DIR;
} else {
process.env.WORKING_DIR = ORIGINAL_WORKING_DIR;
}
if (ORIGINAL_RAILWAY_VOLUME_MOUNT_PATH === undefined) {
delete process.env.RAILWAY_VOLUME_MOUNT_PATH;
} else {
process.env.RAILWAY_VOLUME_MOUNT_PATH = ORIGINAL_RAILWAY_VOLUME_MOUNT_PATH;
}
}
describe('skills loader', () => {
afterEach(() => {
restoreLoaderPathEnv();
vi.resetModules();
});
describe('working directory resolution', () => {
it('uses Railway volume-backed working dir when WORKING_DIR is not set', async () => {
process.env.RAILWAY_VOLUME_MOUNT_PATH = '/railway-volume';
delete process.env.WORKING_DIR;
const mod = await importFreshLoader();
expect(mod.WORKING_DIR).toBe('/railway-volume/data');
expect(mod.WORKING_SKILLS_DIR).toBe('/railway-volume/data/.skills');
});
it('prefers explicit WORKING_DIR over Railway volume mount', async () => {
process.env.RAILWAY_VOLUME_MOUNT_PATH = '/railway-volume';
process.env.WORKING_DIR = '/custom/workdir';
const mod = await importFreshLoader();
expect(mod.WORKING_DIR).toBe('/custom/workdir');
expect(mod.WORKING_SKILLS_DIR).toBe('/custom/workdir/.skills');
});
});
describe('getAgentSkillsDir', () => {
it('uses Railway volume path for agent-scoped skills when mounted', async () => {
process.env.RAILWAY_VOLUME_MOUNT_PATH = '/railway-volume';
const mod = await importFreshLoader();
const dir = mod.getAgentSkillsDir('agent-railway');
expect(dir).toBe('/railway-volume/.letta/agents/agent-railway/skills');
});
it('returns path containing agent ID', () => {
const agentId = 'agent-test-123';
const dir = getAgentSkillsDir(agentId);

View File

@@ -7,13 +7,17 @@ import { execSync } from 'node:child_process';
import { join, resolve, delimiter } from 'node:path';
import matter from 'gray-matter';
import type { SkillEntry, ClawdbotMetadata } from './types.js';
import { getWorkingDir } from '../utils/paths.js';
// Skills directories (in priority order: project > agent > global > bundled > skills.sh)
const HOME = process.env.HOME || process.env.USERPROFILE || '';
export const WORKING_DIR = process.env.WORKING_DIR || '/tmp/lettabot';
const LETTA_HOME = process.env.RAILWAY_VOLUME_MOUNT_PATH
? join(process.env.RAILWAY_VOLUME_MOUNT_PATH, '.letta')
: join(HOME, '.letta');
export const WORKING_DIR = getWorkingDir();
export const PROJECT_SKILLS_DIR = resolve(process.cwd(), '.skills');
export const WORKING_SKILLS_DIR = join(WORKING_DIR, '.skills'); // skills enabled via CLI
export const GLOBAL_SKILLS_DIR = join(HOME, '.letta', 'skills');
export const GLOBAL_SKILLS_DIR = join(LETTA_HOME, 'skills');
export const SKILLS_SH_DIR = join(HOME, '.agents', 'skills'); // skills.sh global installs
// Bundled skills from the lettabot repo itself
@@ -29,7 +33,7 @@ export const BUNDLED_SKILLS_DIR = resolve(__dirname, '../../skills'); // lettabo
* Get the agent-scoped skills directory for a specific agent
*/
export function getAgentSkillsDir(agentId: string): string {
return join(HOME, '.letta', 'agents', agentId, 'skills');
return join(LETTA_HOME, 'agents', agentId, 'skills');
}
/**
@@ -69,7 +73,7 @@ export function getWorkingSkillExecutableDirs(): string[] {
* Permanently prepend skill directories to PATH so that subprocesses
* spawned subsequently inherit them.
*
* Includes both agent-scoped skills (~/.letta/agents/{id}/skills/) and
* Includes both agent-scoped skills (.letta/agents/{id}/skills) and
* working-dir skills (WORKING_DIR/.skills/) so that skills enabled via
* `lettabot skills enable` are available without needing a feature-gate.
*
@@ -411,7 +415,7 @@ export function installSkillsToWorkingDir(workingDir: string, config: SkillsInst
/**
* Install feature-gated skills to the agent-scoped skills directory
* (~/.letta/agents/{agentId}/skills/)
* (.letta/agents/{agentId}/skills)
*
* This aligns with Letta Code CLI which uses agent-scoped skills.
* Called after agent creation in bot.ts.