chore(foundry): migrate to actions (#262)

* feat(foundry): checkpoint actor and workspace refactor

* docs(foundry): add agent handoff context

* wip(foundry): continue actor refactor

* wip(foundry): capture remaining local changes

* Complete Foundry refactor checklist

* Fix Foundry validation fallout

* wip

* wip: convert all actors from workflow to plain run handlers

Workaround for RivetKit bug where c.queue.iter() never yields messages
for actors created via getOrCreate from another actor's context. The
queue accepts messages (visible in inspector) but the iterator hangs.
Sleep/wake fixes it, but actors with active connections never sleep.

Converted organization, github-data, task, and user actors from
run: workflow(...) to plain run: async (c) => { for await ... }.

Also fixes:
- Missing auth tables in org migration (auth_verification etc)
- default_model NOT NULL constraint on org profile upsert
- Nested workflow step in github-data (HistoryDivergedError)
- Removed --force from frontend Dockerfile pnpm install

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Convert all actors from queues/workflows to direct actions, lazy task creation

Major refactor replacing all queue-based workflow communication with direct
RivetKit action calls across all actors. This works around a RivetKit bug
where c.queue.iter() deadlocks for actors created from another actor's context.

Key changes:
- All actors (organization, task, user, audit-log, github-data) converted
  from run: workflow(...) to actions-only (no run handler, no queues)
- PR sync creates virtual task entries in org local DB instead of spawning
  task actors — prevents OOM from 200+ actors created simultaneously
- Task actors created lazily on first user interaction via getOrCreate,
  self-initialize from org's getTaskIndexEntry data
- Removed requireRepoExists cross-actor call (caused 500s), replaced with
  local resolveTaskRepoId from org's taskIndex table
- Fixed getOrganizationContext to thread overrides through all sync phases
- Fixed sandbox repo path (/home/user/repo for E2B compatibility)
- Fixed buildSessionDetail to skip transcript fetch for pending sessions
- Added process crash protection (uncaughtException/unhandledRejection)
- Fixed React infinite render loop in mock-layout useEffect dependencies
- Added sandbox listProcesses error handling for expired E2B sandboxes
- Set E2B sandbox timeout to 1 hour (was 5 min default)
- Updated CLAUDE.md with lazy task creation rules, no-silent-catch policy,
  React hook dependency safety rules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix E2B sandbox timeout comment, frontend stability, and create-flow improvements

- Add TEMPORARY comment on E2B timeoutMs with pointer to rivetkit sandbox
  resilience proposal for when autoPause lands
- Fix React useEffect dependency stability in mock-layout and
  organization-dashboard to prevent infinite re-render loops
- Fix terminal-pane ref handling
- Improve create-flow service and tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-16 15:23:59 -07:00 committed by GitHub
parent 32f3c6c3bc
commit f45a467484
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
139 changed files with 9768 additions and 7204 deletions

View file

@ -1,393 +1,47 @@
import { actor, queue } from "rivetkit";
import { workflow } from "rivetkit/workflow";
import type {
AgentType,
TaskRecord,
TaskWorkbenchChangeModelInput,
TaskWorkbenchRenameInput,
TaskWorkbenchRenameSessionInput,
TaskWorkbenchSetSessionUnreadInput,
TaskWorkbenchSendMessageInput,
TaskWorkbenchUpdateDraftInput,
SandboxProviderId,
} from "@sandbox-agent/foundry-shared";
import { expectQueueResponse } from "../../services/queue.js";
import { selfTask } from "../handles.js";
import { actor } from "rivetkit";
import type { TaskRecord } from "@sandbox-agent/foundry-shared";
import { taskDb } from "./db/db.js";
import { getCurrentRecord } from "./workflow/common.js";
import {
changeWorkbenchModel,
closeWorkbenchSession,
createWorkbenchSession,
getSessionDetail,
getTaskDetail,
getTaskSummary,
markWorkbenchUnread,
publishWorkbenchPr,
renameWorkbenchBranch,
renameWorkbenchTask,
renameWorkbenchSession,
revertWorkbenchFile,
sendWorkbenchMessage,
syncWorkbenchSessionStatus,
setWorkbenchSessionUnread,
stopWorkbenchSession,
updateWorkbenchDraft,
} from "./workbench.js";
import { TASK_QUEUE_NAMES, taskWorkflowQueueName, runTaskWorkflow } from "./workflow/index.js";
import { getSessionDetail, getTaskDetail, getTaskSummary } from "./workspace.js";
import { taskCommandActions } from "./workflow/index.js";
export interface TaskInput {
organizationId: string;
repoId: string;
taskId: string;
repoRemote: string;
branchName: string | null;
title: string | null;
task: string;
sandboxProviderId: SandboxProviderId;
agentType: AgentType | null;
explicitTitle: string | null;
explicitBranchName: string | null;
initialPrompt: string | null;
}
interface InitializeCommand {
sandboxProviderId?: SandboxProviderId;
}
interface TaskActionCommand {
reason?: string;
}
interface TaskSessionCommand {
sessionId: string;
}
interface TaskStatusSyncCommand {
sessionId: string;
status: "running" | "idle" | "error";
at: number;
}
interface TaskWorkbenchValueCommand {
value: string;
}
interface TaskWorkbenchSessionTitleCommand {
sessionId: string;
title: string;
}
interface TaskWorkbenchSessionUnreadCommand {
sessionId: string;
unread: boolean;
}
interface TaskWorkbenchUpdateDraftCommand {
sessionId: string;
text: string;
attachments: Array<any>;
}
interface TaskWorkbenchChangeModelCommand {
sessionId: string;
model: string;
}
interface TaskWorkbenchSendMessageCommand {
sessionId: string;
text: string;
attachments: Array<any>;
}
interface TaskWorkbenchCreateSessionCommand {
model?: string;
}
interface TaskWorkbenchCreateSessionAndSendCommand {
model?: string;
text: string;
}
interface TaskWorkbenchSessionCommand {
sessionId: string;
}
export const task = actor({
db: taskDb,
queues: Object.fromEntries(TASK_QUEUE_NAMES.map((name) => [name, queue()])),
options: {
name: "Task",
icon: "wrench",
actionTimeout: 5 * 60_000,
actionTimeout: 10 * 60_000,
},
createState: (_c, input: TaskInput) => ({
organizationId: input.organizationId,
repoId: input.repoId,
taskId: input.taskId,
repoRemote: input.repoRemote,
branchName: input.branchName,
title: input.title,
task: input.task,
sandboxProviderId: input.sandboxProviderId,
agentType: input.agentType,
explicitTitle: input.explicitTitle,
explicitBranchName: input.explicitBranchName,
initialPrompt: input.initialPrompt,
initialized: false,
previousStatus: null as string | null,
}),
actions: {
async initialize(c, cmd: InitializeCommand): Promise<TaskRecord> {
const self = selfTask(c);
const result = await self.send(taskWorkflowQueueName("task.command.initialize"), cmd ?? {}, {
wait: true,
timeout: 10_000,
});
return expectQueueResponse<TaskRecord>(result);
},
async provision(c, cmd: InitializeCommand): Promise<{ ok: true }> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.provision"), cmd ?? {}, {
wait: false,
});
return { ok: true };
},
async attach(c, cmd?: TaskActionCommand): Promise<{ target: string; sessionId: string | null }> {
const self = selfTask(c);
const result = await self.send(taskWorkflowQueueName("task.command.attach"), cmd ?? {}, {
wait: true,
timeout: 10_000,
});
return expectQueueResponse<{ target: string; sessionId: string | null }>(result);
},
async switch(c): Promise<{ switchTarget: string }> {
const self = selfTask(c);
const result = await self.send(
taskWorkflowQueueName("task.command.switch"),
{},
{
wait: true,
timeout: 10_000,
},
);
return expectQueueResponse<{ switchTarget: string }>(result);
},
async push(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.push"), cmd ?? {}, {
wait: false,
});
},
async sync(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.sync"), cmd ?? {}, {
wait: false,
});
},
async merge(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.merge"), cmd ?? {}, {
wait: false,
});
},
async archive(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.archive"), cmd ?? {}, {
wait: false,
});
},
async kill(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.kill"), cmd ?? {}, {
wait: false,
});
},
async get(c): Promise<TaskRecord> {
return await getCurrentRecord({ db: c.db, state: c.state });
return await getCurrentRecord(c);
},
async getTaskSummary(c) {
return await getTaskSummary(c);
},
async getTaskDetail(c) {
return await getTaskDetail(c);
async getTaskDetail(c, input?: { authSessionId?: string }) {
return await getTaskDetail(c, input?.authSessionId);
},
async getSessionDetail(c, input: { sessionId: string }) {
return await getSessionDetail(c, input.sessionId);
async getSessionDetail(c, input: { sessionId: string; authSessionId?: string }) {
return await getSessionDetail(c, input.sessionId, input.authSessionId);
},
async markWorkbenchUnread(c): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.mark_unread"),
{},
{
wait: true,
timeout: 10_000,
},
);
},
async renameWorkbenchTask(c, input: TaskWorkbenchRenameInput): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workbench.rename_task"), { value: input.value } satisfies TaskWorkbenchValueCommand, {
wait: true,
timeout: 20_000,
});
},
async renameWorkbenchBranch(c, input: TaskWorkbenchRenameInput): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workbench.rename_branch"), { value: input.value } satisfies TaskWorkbenchValueCommand, {
wait: false,
});
},
async createWorkbenchSession(c, input?: { model?: string }): Promise<{ sessionId: string }> {
const self = selfTask(c);
const result = await self.send(
taskWorkflowQueueName("task.command.workbench.create_session"),
{ ...(input?.model ? { model: input.model } : {}) } satisfies TaskWorkbenchCreateSessionCommand,
{
wait: true,
timeout: 10_000,
},
);
return expectQueueResponse<{ sessionId: string }>(result);
},
/**
* Fire-and-forget: creates a workbench session and sends the initial message.
* Used by createWorkbenchTask so the caller doesn't block on session creation.
*/
async createWorkbenchSessionAndSend(c, input: { model?: string; text: string }): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.create_session_and_send"),
{ model: input.model, text: input.text } satisfies TaskWorkbenchCreateSessionAndSendCommand,
{ wait: false },
);
},
async renameWorkbenchSession(c, input: TaskWorkbenchRenameSessionInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.rename_session"),
{ sessionId: input.sessionId, title: input.title } satisfies TaskWorkbenchSessionTitleCommand,
{
wait: true,
timeout: 10_000,
},
);
},
async setWorkbenchSessionUnread(c, input: TaskWorkbenchSetSessionUnreadInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.set_session_unread"),
{ sessionId: input.sessionId, unread: input.unread } satisfies TaskWorkbenchSessionUnreadCommand,
{
wait: true,
timeout: 10_000,
},
);
},
async updateWorkbenchDraft(c, input: TaskWorkbenchUpdateDraftInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.update_draft"),
{
sessionId: input.sessionId,
text: input.text,
attachments: input.attachments,
} satisfies TaskWorkbenchUpdateDraftCommand,
{
wait: false,
},
);
},
async changeWorkbenchModel(c, input: TaskWorkbenchChangeModelInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.change_model"),
{ sessionId: input.sessionId, model: input.model } satisfies TaskWorkbenchChangeModelCommand,
{
wait: true,
timeout: 10_000,
},
);
},
async sendWorkbenchMessage(c, input: TaskWorkbenchSendMessageInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.send_message"),
{
sessionId: input.sessionId,
text: input.text,
attachments: input.attachments,
} satisfies TaskWorkbenchSendMessageCommand,
{
wait: false,
},
);
},
async stopWorkbenchSession(c, input: TaskSessionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workbench.stop_session"), { sessionId: input.sessionId } satisfies TaskWorkbenchSessionCommand, {
wait: false,
});
},
async syncWorkbenchSessionStatus(c, input: TaskStatusSyncCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workbench.sync_session_status"), input, {
wait: true,
timeout: 20_000,
});
},
async closeWorkbenchSession(c, input: TaskSessionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workbench.close_session"), { sessionId: input.sessionId } satisfies TaskWorkbenchSessionCommand, {
wait: false,
});
},
async publishWorkbenchPr(c): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.publish_pr"),
{},
{
wait: false,
},
);
},
async revertWorkbenchFile(c, input: { path: string }): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workbench.revert_file"), input, {
wait: false,
});
},
...taskCommandActions,
},
run: workflow(runTaskWorkflow),
});
export { TASK_QUEUE_NAMES };
export { taskWorkflowQueueName } from "./workflow/index.js";