Move to threads based system.

This commit is contained in:
Gabriel Brown
2026-06-22 10:37:26 -04:00
parent 8ae6c4b533
commit 206b64176b
82 changed files with 6169 additions and 1930 deletions
+675 -113
View File
@@ -1,11 +1,18 @@
import { access, readFile, rm } from 'node:fs/promises';
import {
access,
mkdir,
readdir,
readFile,
rm,
stat,
writeFile,
} from 'node:fs/promises';
import path from 'node:path';
import { ConvexHttpClient } from 'convex/browser';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { runOpenAiEdit } from './agent';
import { env } from './env';
import {
cloneRepository,
@@ -22,6 +29,10 @@ type Claim = {
job: {
_id: Id<'agentJobs'>;
prompt: string;
runtime?: 'openai_direct' | 'opencode';
jobType?: 'user_change' | 'maintenance_review' | 'conflict_resolution';
envFilePath?: string;
materializeEnvFile?: boolean;
baseBranch: string;
workBranch: string;
forkOwner: string;
@@ -31,7 +42,26 @@ type Claim = {
};
spoon: { name: string };
openai: {
apiKey: string;
apiKey?: string;
model: string;
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
};
aiProviderProfile?: {
id: string;
name: string;
provider:
| 'openai'
| 'anthropic'
| 'google'
| 'openrouter'
| 'requesty'
| 'litellm'
| 'cloudflare_ai_gateway'
| 'custom_openai_compatible'
| 'opencode_openai_login';
authType: 'api_key' | 'opencode_auth_json' | 'none';
secret?: string;
baseUrl?: string;
model: string;
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
};
@@ -40,13 +70,30 @@ type Claim = {
installCommand?: string;
checkCommand?: string;
testCommand?: string;
autoDetectCommands?: boolean;
} | null;
secrets: { name: string; value: string }[];
};
type ActiveWorkspace = {
claim: Claim;
workdir: string;
repoDir: string;
githubToken: string;
redact: (value: string) => string;
};
type FileTreeNode = {
name: string;
path: string;
type: 'file' | 'directory';
children?: FileTreeNode[];
};
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const client = new ConvexHttpClient(env.convexUrl);
const activeWorkspaces = new Map<string, ActiveWorkspace>();
const appendEvent = async (
jobId: Id<'agentJobs'>,
@@ -129,8 +176,232 @@ const completeWithDraftPr = async (args: {
...args,
});
const applyMaintenanceDecision = async (
jobId: Id<'agentJobs'>,
decision: MaintenanceDecision,
) =>
await client.mutation(api.agentJobs.applyMaintenanceDecision, {
workerToken: env.workerToken,
workerId: env.workerId,
jobId,
...decision,
});
const markWorkspaceActive = async (args: {
jobId: Id<'agentJobs'>;
opencodeSessionId?: string;
containerId?: string;
workspaceUrl?: string;
}) =>
await client.mutation(api.agentJobs.markWorkspaceActive, {
workerToken: env.workerToken,
workerId: env.workerId,
workspaceExpiresAt: Date.now() + 2 * 60 * 60 * 1000,
...args,
});
const markWorkspaceStopped = async (
jobId: Id<'agentJobs'>,
workspaceStatus: 'stopped' | 'expired' | 'failed' = 'stopped',
) =>
await client.mutation(api.agentJobs.markWorkspaceStopped, {
workerToken: env.workerToken,
workerId: env.workerId,
jobId,
workspaceStatus,
});
const appendMessage = async (args: {
jobId: Id<'agentJobs'>;
role: 'user' | 'assistant' | 'system' | 'tool';
content: string;
status: 'queued' | 'streaming' | 'completed' | 'failed';
metadata?: string;
}) =>
await client.mutation(api.agentJobs.appendMessage, {
workerToken: env.workerToken,
workerId: env.workerId,
...args,
});
const recordWorkspaceChange = async (args: {
jobId: Id<'agentJobs'>;
path: string;
source: 'user' | 'agent' | 'command';
changeType: 'added' | 'modified' | 'deleted' | 'renamed';
diff?: string;
}) =>
await client.mutation(api.agentJobs.recordWorkspaceChange, {
workerToken: env.workerToken,
workerId: env.workerId,
...args,
});
const commandToShell = (command: string) => ['bash', '-lc', command];
const providerEnvironment = (claim: Claim): Record<string, string> => {
const profile = claim.aiProviderProfile;
const secret = profile?.secret ?? claim.openai.apiKey;
if (!secret) {
throw new Error('No AI provider credential is configured for this job.');
}
const baseUrl: Record<string, string> = profile?.baseUrl
? { OPENAI_BASE_URL: profile.baseUrl }
: {};
if (!profile || profile.provider === 'openai') {
return { OPENAI_API_KEY: secret, ...baseUrl };
}
if (profile.provider === 'anthropic') return { ANTHROPIC_API_KEY: secret };
if (profile.provider === 'google') return { GOOGLE_API_KEY: secret };
if (profile.provider === 'openrouter') {
return { OPENROUTER_API_KEY: secret, ...baseUrl };
}
if (profile.provider === 'requesty') {
return { REQUESTY_API_KEY: secret, ...baseUrl };
}
if (profile.provider === 'cloudflare_ai_gateway') {
return { CLOUDFLARE_API_KEY: secret, ...baseUrl };
}
if (
profile.provider === 'litellm' ||
profile.provider === 'custom_openai_compatible'
) {
return { OPENAI_API_KEY: secret, ...baseUrl };
}
throw new Error(
'OpenCode login profiles are saved but need auth-file injection before execution.',
);
};
const opencodeModel = (claim: Claim) => {
const profile = claim.aiProviderProfile;
const model = profile?.model ?? claim.openai.model;
if (model.includes('/')) return model;
if (!profile) return `openai/${model}`;
if (
profile.provider === 'custom_openai_compatible' ||
profile.provider === 'cloudflare_ai_gateway'
) {
return model;
}
if (profile.provider === 'opencode_openai_login') return `openai/${model}`;
return `${profile.provider}/${model}`;
};
const systemPromptForJob = (claim: Claim) => {
const base = [
`Spoon: ${claim.spoon.name}`,
`Fork: ${claim.job.forkOwner}/${claim.job.forkRepo}`,
`Upstream: ${claim.job.upstreamOwner}/${claim.job.upstreamRepo}`,
`Selected secret names: ${claim.secrets.map((secret) => secret.name).join(', ') || 'none'}`,
].join('\n');
if (claim.job.jobType === 'maintenance_review') {
return `${base}
You are reviewing upstream changes for a maintained fork.
Determine whether the upstream commits can be safely applied.
If the fork has no relevant customizations, recommend sync.
If upstream changes are irrelevant to this fork, recommend ignore and list commit SHAs.
If changes may affect custom fork commits, explain risks and recommend review PR or manual review.
Do not claim tests passed unless commands were run.
End with a JSON maintenance decision in this exact shape:
{
"decision": "sync" | "ignore" | "open_review_pr" | "manual_review" | "conflict_resolution" | "unknown",
"risk": "low" | "medium" | "high" | "unknown",
"summary": "string",
"ignoredCommitShas": ["string"],
"ignoredReason": "string",
"recommendedAction": "string",
"requiresUserApproval": true
}
User/system request:
${claim.job.prompt}`;
}
if (claim.job.jobType === 'conflict_resolution') {
return `${base}
You are resolving upstream merge conflicts in a maintained fork.
Preserve fork customizations unless the user explicitly removed that upstream behavior.
Prefer small, reviewable changes.
Produce a draft PR rather than committing to main.
Request:
${claim.job.prompt}`;
}
return `${base}
You are working on a maintained fork managed by Spoon.
Make the requested change only.
Preserve fork-specific customizations.
Do not commit secrets.
Use selected environment variables only for running/building/testing.
Open a draft PR only when instructed by Spoon.
Request:
${claim.job.prompt}`;
};
type MaintenanceDecision = {
decision:
| 'sync'
| 'ignore'
| 'open_review_pr'
| 'manual_review'
| 'conflict_resolution'
| 'unknown';
risk: 'low' | 'medium' | 'high' | 'unknown';
summary: string;
ignoredCommitShas: string[];
ignoredReason: string;
recommendedAction: string;
requiresUserApproval: boolean;
};
const parseMaintenanceDecision = (
output: string,
): MaintenanceDecision | null => {
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(output)?.[1];
const candidates = [fenced, output.slice(output.indexOf('{'))].filter(
Boolean,
) as string[];
for (const candidate of candidates) {
try {
const parsed = JSON.parse(
candidate.trim(),
) as Partial<MaintenanceDecision>;
if (
parsed.decision &&
parsed.risk &&
typeof parsed.summary === 'string'
) {
return {
decision: parsed.decision,
risk: parsed.risk,
summary: parsed.summary,
ignoredCommitShas: Array.isArray(parsed.ignoredCommitShas)
? parsed.ignoredCommitShas.filter(
(sha): sha is string => typeof sha === 'string',
)
: [],
ignoredReason:
typeof parsed.ignoredReason === 'string'
? parsed.ignoredReason
: '',
recommendedAction:
typeof parsed.recommendedAction === 'string'
? parsed.recommendedAction
: parsed.summary,
requiresUserApproval: parsed.requiresUserApproval ?? true,
};
}
} catch {
// Try the next candidate.
}
}
return null;
};
const fileExists = async (filePath: string) => {
try {
await access(filePath);
@@ -180,28 +451,91 @@ const runProjectCommand = async (args: {
}
};
const quoteShell = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
const resolveWorkspace = (jobId: string) => {
const workspace = activeWorkspaces.get(jobId);
if (!workspace) {
throw new Error('Agent workspace is not active on this worker.');
}
return workspace;
};
const safeWorkspacePath = (repoDir: string, filePath: string) => {
const resolved = path.resolve(repoDir, filePath);
const root = path.resolve(repoDir);
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
throw new Error(`Refusing to access path outside repository: ${filePath}`);
}
return resolved;
};
const fileChangedType = async (repoDir: string, filePath: string) => {
const status = await run('git', ['status', '--short', '--', filePath], {
cwd: repoDir,
redact: (value) => value,
timeoutMs: 60_000,
});
const code = status.output.trim().slice(0, 2);
if (code.includes('D')) return 'deleted' as const;
if (code.includes('A') || code.includes('?')) return 'added' as const;
if (code.includes('R')) return 'renamed' as const;
return 'modified' as const;
};
const materializeEnvFile = async (workspace: ActiveWorkspace) => {
const { claim, repoDir } = workspace;
if (!claim.job.materializeEnvFile || !claim.job.envFilePath) return;
const envPath = safeWorkspacePath(repoDir, claim.job.envFilePath);
await mkdir(path.dirname(envPath), { recursive: true });
const content = `${claim.secrets
.map((secret) => `${secret.name}=${JSON.stringify(secret.value)}`)
.join('\n')}\n`;
await writeFile(envPath, content);
await appendEvent(
claim.job._id,
'info',
'clone',
`Materialized selected secrets into ${claim.job.envFilePath}.`,
);
};
const detectPackageCommands = async (
repoDir: string,
): Promise<{ install?: string; check?: string; test?: string }> => {
): Promise<{
packageManager?: string;
scripts?: string[];
install?: string;
check?: string;
test?: string;
build?: string;
}> => {
const packageJsonPath = path.join(repoDir, 'package.json');
try {
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as {
packageManager?: string;
scripts?: Record<string, string>;
};
const scripts = packageJson.scripts ?? {};
const packageManager = (await fileExists(path.join(repoDir, 'bun.lock')))
const declaredPackageManager = packageJson.packageManager?.split('@')[0];
const detectedPackageManager = (await fileExists(
path.join(repoDir, 'bun.lock'),
))
? 'bun'
: (await fileExists(path.join(repoDir, 'pnpm-lock.yaml')))
? 'pnpm'
: (await fileExists(path.join(repoDir, 'yarn.lock')))
? 'yarn'
: 'npm';
const packageManager = declaredPackageManager ?? detectedPackageManager;
const runScript = (script: string) =>
packageManager === 'npm'
? `npm run ${script}`
: `${packageManager} run ${script}`;
return {
packageManager,
scripts: Object.keys(scripts).sort(),
install: `${packageManager} install`,
check: scripts.typecheck
? runScript('typecheck')
@@ -213,6 +547,7 @@ const detectPackageCommands = async (
? 'npm test'
: `${packageManager} test`
: undefined,
build: scripts.build ? runScript('build') : undefined,
};
} catch {
return {};
@@ -250,13 +585,28 @@ ${
Generated by Spoon.`;
const ensureNoEnvFilesStaged = async (workspace: ActiveWorkspace) => {
const status = await run('git', ['status', '--short'], {
cwd: workspace.repoDir,
redact: workspace.redact,
timeoutMs: 60_000,
});
const envLine = status.output
.split('\n')
.find((line) => /\s\.env(?:$|[./-])/.test(line));
if (envLine) {
throw new Error(`Refusing to commit env file changes: ${envLine.trim()}`);
}
};
const runClaim = async (claim: Claim) => {
const jobId = claim.job._id;
const workdir = path.resolve(env.workdir, jobId);
const secretValues = [
claim.openai.apiKey,
claim.openai.apiKey ?? '',
claim.aiProviderProfile?.secret ?? '',
...claim.secrets.map((secret) => secret.value),
];
].filter(Boolean);
const redact = createRedactor(secretValues);
try {
await updateStatus(jobId, 'preparing');
@@ -275,118 +625,37 @@ const runClaim = async (claim: Claim) => {
redact,
timeoutMs: env.jobTimeoutMs,
});
await updateStatus(jobId, 'running');
await appendEvent(jobId, 'info', 'plan', 'Gathering repo context.');
const edit = await runOpenAiEdit({
const workspace: ActiveWorkspace = {
claim,
workdir,
repoDir,
apiKey: claim.openai.apiKey,
model: claim.openai.model,
reasoningEffort: claim.openai.reasoningEffort,
prompt: claim.job.prompt,
secretNames: claim.secrets.map((secret) => secret.name),
spoonName: claim.spoon.name,
upstreamFullName: `${claim.job.upstreamOwner}/${claim.job.upstreamRepo}`,
forkFullName: `${claim.job.forkOwner}/${claim.job.forkRepo}`,
});
await addArtifact({
jobId,
kind: 'plan',
title: 'Agent plan',
content: edit.summary,
contentType: 'text/markdown',
});
const status = await getStatus(repoDir, redact);
if (!status.output.trim()) {
throw new Error('No changes produced by the agent.');
}
const diff = await getWorktreeDiff(repoDir, redact);
await addArtifact({
jobId,
kind: 'diff',
title: 'Git diff',
content: truncate(diff.output, 200_000),
contentType: 'text/x-diff',
});
await updateStatus(jobId, 'checks_running');
const detected = await detectPackageCommands(repoDir);
const settings = claim.agentSettings;
const installCommand = settings?.installCommand ?? detected.install;
const checkCommand = settings?.checkCommand ?? detected.check;
const testCommand = settings?.testCommand ?? detected.test;
if (installCommand) {
await runProjectCommand({
command: installCommand,
phase: 'install',
claim,
workdir,
repoDir,
redact,
});
}
if (checkCommand) {
await runProjectCommand({
command: checkCommand,
phase: 'check',
claim,
workdir,
repoDir,
redact,
});
}
if (testCommand) {
await runProjectCommand({
command: testCommand,
phase: 'test',
claim,
workdir,
repoDir,
redact,
});
}
await appendEvent(jobId, 'info', 'commit', 'Committing changes.');
const commitSha = await commitAndPush({
repoDir,
workBranch: claim.job.workBranch,
message: `Agent: ${claim.job.prompt.slice(0, 72)}`,
githubToken,
redact,
timeoutMs: env.jobTimeoutMs,
});
const prBody = buildPrBody({
prompt: claim.job.prompt,
summary: edit.summary,
commands: [
installCommand,
checkCommand,
testCommand,
...edit.commands,
].filter((command): command is string => Boolean(command)),
limitations: edit.limitations,
});
};
await materializeEnvFile(workspace);
const detected = await detectPackageCommands(repoDir);
await addArtifact({
jobId,
kind: 'pr_body',
title: 'Draft PR body',
content: prBody,
contentType: 'text/markdown',
kind: 'summary',
title: 'Detected project commands',
content: JSON.stringify(detected, null, 2),
contentType: 'application/json',
});
await appendEvent(jobId, 'info', 'pr', 'Opening draft pull request.');
const pullRequest = await openDraftPullRequest({
installationId: claim.github.installationId,
forkOwner: claim.job.forkOwner,
forkRepo: claim.job.forkRepo,
baseBranch: claim.job.baseBranch,
workBranch: claim.job.workBranch,
title: `Agent: ${claim.job.prompt.slice(0, 64)}`,
body: prBody,
activeWorkspaces.set(jobId, workspace);
await markWorkspaceActive({ jobId });
await updateStatus(jobId, 'running', {
summary: 'Workspace is active.',
});
await completeWithDraftPr({
await appendMessage({
jobId,
commitSha,
pullRequestUrl: pullRequest.html_url,
pullRequestNumber: pullRequest.number,
summary: edit.summary,
role: 'system',
status: 'completed',
content:
'Workspace is ready. You can browse files, edit manually, run commands, or send messages to the agent.',
});
await appendEvent(jobId, 'info', 'cleanup', 'Agent job completed.');
await appendEvent(jobId, 'info', 'plan', 'Interactive workspace is ready.');
await sendWorkspaceMessage(jobId, systemPromptForJob(claim));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await appendEvent(
@@ -407,11 +676,304 @@ const runClaim = async (claim: Claim) => {
message.toLowerCase().includes('timed out') ? 'timed_out' : 'failed',
{ error: truncate(redact(message), 10_000) },
);
} finally {
await rm(workdir, { recursive: true, force: true });
await markWorkspaceStopped(
jobId,
message.toLowerCase().includes('timed out') ? 'expired' : 'failed',
).catch((stopError: unknown) => {
console.error(stopError);
});
}
};
export const listWorkspaceTree = async (jobId: string) => {
const workspace = resolveWorkspace(jobId);
const buildNode = async (
absolutePath: string,
relativePath: string,
): Promise<FileTreeNode | null> => {
const basename = path.basename(absolutePath);
if (['.git', 'node_modules', '.next', 'dist', 'build'].includes(basename)) {
return null;
}
const stats = await stat(absolutePath);
if (stats.isDirectory()) {
const entries = await readdir(absolutePath);
const children = (
await Promise.all(
entries
.sort((a, b) => a.localeCompare(b))
.map((entry) =>
buildNode(
path.join(absolutePath, entry),
path.join(relativePath, entry),
),
),
)
).filter((node): node is FileTreeNode => Boolean(node));
return {
name: relativePath ? basename : workspace.claim.job.forkRepo,
path: relativePath,
type: 'directory',
children,
};
}
return { name: basename, path: relativePath, type: 'file' };
};
return await buildNode(workspace.repoDir, '');
};
export const readWorkspaceFile = async (jobId: string, filePath: string) => {
const workspace = resolveWorkspace(jobId);
const target = safeWorkspacePath(workspace.repoDir, filePath);
return await readFile(target, 'utf8');
};
export const writeWorkspaceFile = async (
jobId: string,
filePath: string,
content: string,
) => {
const workspace = resolveWorkspace(jobId);
const target = safeWorkspacePath(workspace.repoDir, filePath);
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, content);
const diff = await getWorktreeDiff(workspace.repoDir, workspace.redact);
await recordWorkspaceChange({
jobId: workspace.claim.job._id,
path: filePath,
source: 'user',
changeType: await fileChangedType(workspace.repoDir, filePath),
diff: truncate(diff.output, 50_000),
});
await appendEvent(
workspace.claim.job._id,
'info',
'edit',
`Saved ${filePath}.`,
);
return { success: true };
};
export const getWorkspaceDiff = async (jobId: string) => {
const workspace = resolveWorkspace(jobId);
const diff = await getWorktreeDiff(workspace.repoDir, workspace.redact);
return diff.output;
};
export const runWorkspaceCommand = async (jobId: string, command: string) => {
const workspace = resolveWorkspace(jobId);
await updateStatus(workspace.claim.job._id, 'checks_running');
await runProjectCommand({
command,
phase: command.includes('test') ? 'test' : 'check',
claim: workspace.claim,
workdir: workspace.workdir,
repoDir: workspace.repoDir,
redact: workspace.redact,
});
await updateStatus(workspace.claim.job._id, 'running');
await recordWorkspaceChange({
jobId: workspace.claim.job._id,
path: '.',
source: 'command',
changeType: 'modified',
diff: truncate(
(await getWorktreeDiff(workspace.repoDir, workspace.redact)).output,
50_000,
),
});
return { success: true };
};
export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
const workspace = resolveWorkspace(jobId);
const { claim, repoDir, redact, workdir } = workspace;
await appendMessage({
jobId: claim.job._id,
role: 'user',
status: 'completed',
content: prompt,
});
await appendEvent(claim.job._id, 'info', 'plan', 'Sending message to agent.');
try {
if ((claim.job.runtime ?? 'opencode') !== 'opencode') {
throw new Error('Legacy OpenAI direct jobs are no longer supported.');
}
const model = opencodeModel(claim);
const aiEnv = providerEnvironment(claim);
const secretEnv = Object.fromEntries(
claim.secrets.map((secret) => [secret.name, secret.value]),
);
const result =
env.runtime === 'docker'
? await runInJobContainer({
workdir,
command: commandToShell(
`opencode run --model ${quoteShell(model)} ${quoteShell(prompt)}`,
),
environment: {
...aiEnv,
...secretEnv,
},
redact,
timeoutMs: env.jobTimeoutMs,
})
: await run(
'bash',
[
'-lc',
`opencode run --model ${quoteShell(model)} ${quoteShell(prompt)}`,
],
{
cwd: repoDir,
env: {
...aiEnv,
...secretEnv,
},
redact,
timeoutMs: env.jobTimeoutMs,
},
);
await appendMessage({
jobId: claim.job._id,
role: 'assistant',
status: result.exitCode === 0 ? 'completed' : 'failed',
content: truncate(result.output, 40_000),
});
if (result.exitCode !== 0) {
throw new Error(`opencode failed:\n${result.output}`);
}
if (claim.job.jobType === 'maintenance_review') {
const decision = parseMaintenanceDecision(result.output);
if (decision) {
await addArtifact({
jobId: claim.job._id,
kind: 'summary',
title: 'Maintenance decision',
content: JSON.stringify(decision, null, 2),
contentType: 'application/json',
});
await applyMaintenanceDecision(claim.job._id, decision);
} else {
await updateStatus(claim.job._id, 'changes_ready', {
summary:
'OpenCode completed the review, but Spoon could not parse a structured maintenance decision.',
});
}
}
const diff = await getWorktreeDiff(repoDir, redact);
await addArtifact({
jobId: claim.job._id,
kind: 'diff',
title: 'Git diff',
content: truncate(diff.output, 200_000),
contentType: 'text/x-diff',
});
await recordWorkspaceChange({
jobId: claim.job._id,
path: '.',
source: 'agent',
changeType: 'modified',
diff: truncate(diff.output, 50_000),
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await appendEvent(
claim.job._id,
'error',
'cleanup',
truncate(redact(message), 20_000),
);
await appendMessage({
jobId: claim.job._id,
role: 'assistant',
status: 'failed',
content: truncate(redact(message), 40_000),
});
throw error;
}
};
export const openWorkspacePullRequest = async (jobId: string) => {
const workspace = resolveWorkspace(jobId);
const { claim, repoDir, redact } = workspace;
await ensureNoEnvFilesStaged(workspace);
const status = await getStatus(repoDir, redact);
if (!status.output.trim()) {
throw new Error('No changes are ready for a draft PR.');
}
const diff = await getWorktreeDiff(repoDir, redact);
const settings = claim.agentSettings;
const detected = await detectPackageCommands(repoDir);
const installCommand = settings?.installCommand ?? detected.install;
const checkCommand = settings?.checkCommand ?? detected.check;
const testCommand = settings?.testCommand ?? detected.test;
const prBody = buildPrBody({
prompt: claim.job.prompt,
summary: 'Interactive Spoon agent workspace changes.',
commands: [installCommand, checkCommand, testCommand].filter(
(command): command is string => Boolean(command),
),
limitations: ['Review the draft PR before merging.'],
});
await addArtifact({
jobId: claim.job._id,
kind: 'diff',
title: 'Final Git diff',
content: truncate(diff.output, 200_000),
contentType: 'text/x-diff',
});
await addArtifact({
jobId: claim.job._id,
kind: 'pr_body',
title: 'Draft PR body',
content: prBody,
contentType: 'text/markdown',
});
const commitSha = await commitAndPush({
repoDir,
workBranch: claim.job.workBranch,
message: `Agent: ${claim.job.prompt.slice(0, 72)}`,
redact,
timeoutMs: env.jobTimeoutMs,
});
if (!claim.github.installationId) {
throw new Error('GitHub installation ID is missing.');
}
const pullRequest = await openDraftPullRequest({
installationId: claim.github.installationId,
forkOwner: claim.job.forkOwner,
forkRepo: claim.job.forkRepo,
baseBranch: claim.job.baseBranch,
workBranch: claim.job.workBranch,
title: `Agent: ${claim.job.prompt.slice(0, 64)}`,
body: prBody,
});
await completeWithDraftPr({
jobId: claim.job._id,
commitSha,
pullRequestUrl: pullRequest.html_url,
pullRequestNumber: pullRequest.number,
summary: 'Draft PR opened from interactive workspace.',
});
await markWorkspaceStopped(claim.job._id);
activeWorkspaces.delete(jobId);
await rm(workspace.workdir, { recursive: true, force: true });
return {
pullRequestUrl: pullRequest.html_url,
pullRequestNumber: pullRequest.number,
};
};
export const stopWorkspace = async (jobId: string) => {
const workspace = resolveWorkspace(jobId);
await markWorkspaceStopped(workspace.claim.job._id);
activeWorkspaces.delete(jobId);
await rm(workspace.workdir, { recursive: true, force: true });
return { success: true };
};
export const startWorker = async () => {
console.log(`Spoon agent worker ${env.workerId} polling ${env.convexUrl}`);
for (;;) {