feat: resolve agent workspace from session/project/fallback

Heartbeat service resolves cwd from task session, project primary
workspace, or agent home directory (~/.paperclip/instances/.../workspaces/).
Adapters receive workspace context and forward it as env vars and
session params. cwd is now optional in adapter config.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-25 08:38:58 -06:00
parent 29af525167
commit 9f049aa4f3
10 changed files with 218 additions and 12 deletions

View File

@@ -84,6 +84,14 @@ Configure storage provider/settings:
pnpm paperclip configure --section storage
```
## Default Agent Workspaces
When a local agent run has no resolved project/session workspace, Paperclip falls back to an agent home workspace under the instance root:
- `~/.paperclip/instances/default/workspaces/<agent-id>`
This path honors `PAPERCLIP_HOME` and `PAPERCLIP_INSTANCE_ID` in non-default setups.
## Quick Health Checks
In another terminal:

View File

@@ -12,7 +12,7 @@ export const agentConfigurationDoc = `# claude_local agent configuration
Adapter: claude_local
Core fields:
- cwd (string, required): absolute working directory for the agent process
- cwd (string, optional): default absolute working directory fallback for the agent process
- model (string, optional): Claude model id
- effort (string, optional): reasoning effort passed via --effort (low|medium|high)
- promptTemplate (string, optional): run prompt template

View File

@@ -63,6 +63,9 @@ interface ClaudeExecutionInput {
interface ClaudeRuntimeConfig {
command: string;
cwd: string;
workspaceId: string | null;
workspaceRepoUrl: string | null;
workspaceRepoRef: string | null;
env: Record<string, string>;
timeoutSec: number;
graceSec: number;
@@ -87,7 +90,13 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
const { runId, agent, config, context, authToken } = input;
const command = asString(config.command, "claude");
const cwd = asString(config.cwd, process.cwd());
const workspaceContext = parseObject(context.paperclipWorkspace);
const workspaceCwd = asString(workspaceContext.cwd, "");
const workspaceSource = asString(workspaceContext.source, "");
const workspaceId = asString(workspaceContext.workspaceId, "") || null;
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "") || null;
const workspaceRepoRef = asString(workspaceContext.repoRef, "") || null;
const cwd = workspaceCwd || asString(config.cwd, process.cwd());
await ensureAbsoluteDirectory(cwd);
const envConfig = parseObject(config.env);
@@ -138,6 +147,21 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
if (linkedIssueIds.length > 0) {
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
}
if (workspaceCwd) {
env.PAPERCLIP_WORKSPACE_CWD = workspaceCwd;
}
if (workspaceSource) {
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
}
if (workspaceId) {
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
}
if (workspaceRepoUrl) {
env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
}
if (workspaceRepoRef) {
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
}
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value;
@@ -161,6 +185,9 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
return {
command,
cwd,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
env,
timeoutSec,
graceSec,
@@ -225,7 +252,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
context,
authToken,
});
const { command, cwd, env, timeoutSec, graceSec, extraArgs } = runtimeConfig;
const {
command,
cwd,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
env,
timeoutSec,
graceSec,
extraArgs,
} = runtimeConfig;
const skillsDir = await buildSkillsDir();
const runtimeSessionParams = parseObject(runtime.sessionParams);
@@ -372,7 +409,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
parsedStream.sessionId ??
(asString(parsed.session_id, opts.fallbackSessionId ?? "") || opts.fallbackSessionId);
const resolvedSessionParams = resolvedSessionId
? ({ sessionId: resolvedSessionId, cwd } as Record<string, unknown>)
? ({
sessionId: resolvedSessionId,
cwd,
...(workspaceId ? { workspaceId } : {}),
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
} as Record<string, unknown>)
: null;
return {

View File

@@ -17,7 +17,16 @@ export const sessionCodec: AdapterSessionCodec = {
readNonEmptyString(record.cwd) ??
readNonEmptyString(record.workdir) ??
readNonEmptyString(record.folder);
return cwd ? { sessionId, cwd } : { sessionId };
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
};
},
serialize(params: Record<string, unknown> | null) {
if (!params) return null;
@@ -27,7 +36,16 @@ export const sessionCodec: AdapterSessionCodec = {
readNonEmptyString(params.cwd) ??
readNonEmptyString(params.workdir) ??
readNonEmptyString(params.folder);
return cwd ? { sessionId, cwd } : { sessionId };
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
};
},
getDisplayId(params: Record<string, unknown> | null) {
if (!params) return null;

View File

@@ -18,7 +18,7 @@ export const agentConfigurationDoc = `# codex_local agent configuration
Adapter: codex_local
Core fields:
- cwd (string, required): absolute working directory for the agent process
- cwd (string, optional): default absolute working directory fallback for the agent process
- model (string, optional): Codex model id
- modelReasoningEffort (string, optional): reasoning effort override (minimal|low|medium|high) passed via -c model_reasoning_effort=...
- promptTemplate (string, optional): run prompt template

View File

@@ -108,7 +108,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
asBoolean(config.dangerouslyBypassSandbox, false),
);
const cwd = asString(config.cwd, process.cwd());
const workspaceContext = parseObject(context.paperclipWorkspace);
const workspaceCwd = asString(workspaceContext.cwd, "");
const workspaceSource = asString(workspaceContext.source, "");
const workspaceId = asString(workspaceContext.workspaceId, "");
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
const cwd = workspaceCwd || asString(config.cwd, process.cwd());
await ensureAbsoluteDirectory(cwd);
await ensureCodexSkillsInjected(onLog);
const envConfig = parseObject(config.env);
@@ -157,6 +163,21 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (linkedIssueIds.length > 0) {
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
}
if (workspaceCwd) {
env.PAPERCLIP_WORKSPACE_CWD = workspaceCwd;
}
if (workspaceSource) {
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
}
if (workspaceId) {
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
}
if (workspaceRepoUrl) {
env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
}
if (workspaceRepoRef) {
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
}
for (const [k, v] of Object.entries(envConfig)) {
if (typeof v === "string") env[k] = v;
}
@@ -270,7 +291,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const resolvedSessionId = attempt.parsed.sessionId ?? runtimeSessionId ?? runtime.sessionId ?? null;
const resolvedSessionParams = resolvedSessionId
? ({ sessionId: resolvedSessionId, cwd } as Record<string, unknown>)
? ({
sessionId: resolvedSessionId,
cwd,
...(workspaceId ? { workspaceId } : {}),
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
} as Record<string, unknown>)
: null;
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);

View File

@@ -17,7 +17,16 @@ export const sessionCodec: AdapterSessionCodec = {
readNonEmptyString(record.cwd) ??
readNonEmptyString(record.workdir) ??
readNonEmptyString(record.folder);
return cwd ? { sessionId, cwd } : { sessionId };
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
};
},
serialize(params: Record<string, unknown> | null) {
if (!params) return null;
@@ -27,7 +36,16 @@ export const sessionCodec: AdapterSessionCodec = {
readNonEmptyString(params.cwd) ??
readNonEmptyString(params.workdir) ??
readNonEmptyString(params.folder);
return cwd ? { sessionId, cwd } : { sessionId };
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
};
},
getDisplayId(params: Record<string, unknown> | null) {
if (!params) return null;

View File

@@ -3,6 +3,7 @@ import path from "node:path";
const DEFAULT_INSTANCE_ID = "default";
const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/;
const PATH_SEGMENT_RE = /^[a-zA-Z0-9_-]+$/;
function expandHomePrefix(value: string): string {
if (value === "~") return os.homedir();
@@ -48,6 +49,14 @@ export function resolveDefaultStorageDir(): string {
return path.resolve(resolvePaperclipInstanceRoot(), "data", "storage");
}
export function resolveDefaultAgentWorkspaceDir(agentId: string): string {
const trimmed = agentId.trim();
if (!PATH_SEGMENT_RE.test(trimmed)) {
throw new Error(`Invalid agent id for workspace path '${agentId}'.`);
}
return path.resolve(resolvePaperclipInstanceRoot(), "workspaces", trimmed);
}
export function resolveHomeAwarePath(value: string): string {
return path.resolve(expandHomePrefix(value));
}

View File

@@ -1,3 +1,4 @@
import fs from "node:fs/promises";
import { and, asc, desc, eq, gt, inArray, sql } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import {
@@ -9,6 +10,7 @@ import {
heartbeatRuns,
costEvents,
issues,
projectWorkspaces,
} from "@paperclip/db";
import { conflict, notFound } from "../errors.js";
import { logger } from "../middleware/logger.js";
@@ -19,6 +21,7 @@ import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
import { secretService } from "./secrets.js";
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1;
@@ -336,6 +339,74 @@ export function heartbeatService(db: Db) {
return runtimeForRun?.sessionId ?? null;
}
async function resolveWorkspaceForRun(
agent: typeof agents.$inferSelect,
context: Record<string, unknown>,
previousSessionParams: Record<string, unknown> | null,
) {
const sessionCwd = readNonEmptyString(previousSessionParams?.cwd);
if (sessionCwd) {
const sessionCwdExists = await fs
.stat(sessionCwd)
.then((stats) => stats.isDirectory())
.catch(() => false);
if (sessionCwdExists) {
return {
cwd: sessionCwd,
source: "task_session" as const,
projectId: readNonEmptyString(context.projectId),
workspaceId: readNonEmptyString(previousSessionParams?.workspaceId),
repoUrl: readNonEmptyString(previousSessionParams?.repoUrl),
repoRef: readNonEmptyString(previousSessionParams?.repoRef),
};
}
}
const issueId = readNonEmptyString(context.issueId);
if (issueId) {
const issue = await db
.select({ id: issues.id, projectId: issues.projectId })
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)))
.then((rows) => rows[0] ?? null);
if (issue?.projectId) {
const workspace = await db
.select()
.from(projectWorkspaces)
.where(
and(
eq(projectWorkspaces.companyId, agent.companyId),
eq(projectWorkspaces.projectId, issue.projectId),
),
)
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id))
.limit(1)
.then((rows) => rows[0] ?? null);
if (workspace) {
return {
cwd: workspace.cwd,
source: "project_primary" as const,
projectId: issue.projectId,
workspaceId: workspace.id,
repoUrl: workspace.repoUrl,
repoRef: workspace.repoRef,
};
}
}
}
const cwd = resolveDefaultAgentWorkspaceDir(agent.id);
await fs.mkdir(cwd, { recursive: true });
return {
cwd,
source: "agent_home" as const,
projectId: readNonEmptyString(context.projectId),
workspaceId: null,
repoUrl: null,
repoRef: null,
};
}
async function upsertTaskSession(input: {
companyId: string;
agentId: string;
@@ -786,6 +857,18 @@ export function heartbeatService(db: Db) {
const previousSessionParams = normalizeSessionParams(
sessionCodec.deserialize(taskSession?.sessionParamsJson ?? null),
);
const resolvedWorkspace = await resolveWorkspaceForRun(agent, context, previousSessionParams);
context.paperclipWorkspace = {
cwd: resolvedWorkspace.cwd,
source: resolvedWorkspace.source,
projectId: resolvedWorkspace.projectId,
workspaceId: resolvedWorkspace.workspaceId,
repoUrl: resolvedWorkspace.repoUrl,
repoRef: resolvedWorkspace.repoRef,
};
if (resolvedWorkspace.projectId && !readNonEmptyString(context.projectId)) {
context.projectId = resolvedWorkspace.projectId;
}
const runtimeSessionFallback = taskKey ? null : runtime.sessionId;
const previousSessionDisplayId = truncateDisplayId(
taskSession?.sessionDisplayId ??

View File

@@ -15,7 +15,7 @@ export const help: Record<string, string> = {
reportsTo: "The agent this one reports to in the org hierarchy.",
capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.",
adapterType: "How this agent runs: local CLI (Claude/Codex), spawned process, or HTTP webhook.",
cwd: "The working directory where the agent operates. Use an absolute path on the machine running Paperclip.",
cwd: "Default working directory fallback for local adapters. Use an absolute path on the machine running Paperclip.",
promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.",
model: "Override the default model used by the adapter.",
thinkingEffort: "Control model reasoning depth. Supported values vary by adapter/model.",