Fix Foundry UI bugs: org names, sessions, and repo selection (#250)

* Fix Foundry auth: migrate to Better Auth adapter, fix access token retrieval

- Remove @ts-nocheck from better-auth.ts, auth-user/index.ts, app-shell.ts
  and fix all type errors
- Fix getAccessTokenForSession: read GitHub token directly from account
  record instead of calling Better Auth's internal /get-access-token
  endpoint which returns 403 on server-side calls
- Re-implement workspaceAuth helper functions (workspaceAuthColumn,
  normalizeAuthValue, workspaceAuthClause, workspaceAuthWhere) that were
  accidentally deleted
- Remove all retry logic (withRetries, isRetryableAppActorError)
- Implement CORS origin allowlist from configured environment
- Document cachedAppWorkspace singleton pattern
- Add inline org sync fallback in buildAppSnapshot for post-OAuth flow
- Add no-retry rule to CLAUDE.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add Foundry dev panel from fix-git-data branch

Port the dev panel component that was left out when PR #243 was replaced
by PR #247. Adapted to remove runtime/mock-debug references that don't
exist on the current branch.

- Toggle with Shift+D, persists visibility to localStorage
- Shows context, session, GitHub sync status sections
- Dev-only (import.meta.env.DEV)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add full Docker image defaults, fix actor deadlocks, and improve dev experience

- Add Dockerfile.full and --all flag to install-agent CLI for pre-built images
- Centralize Docker image constant (FULL_IMAGE) pinned to 0.3.1-full
- Remove examples/shared/Dockerfile{,.dev} and daytona snapshot example
- Expand Docker docs with full runnable Dockerfile
- Fix self-deadlock in createWorkbenchSession (fire-and-forget provisioning)
- Audit and convert 12 task actions from wait:true to wait:false
- Add bun --hot for dev backend hot reload
- Remove --force from pnpm install in dev Dockerfile for faster startup
- Add env_file support to compose.dev.yaml for automatic credential loading
- Add mock frontend compose config and dev panel
- Update CLAUDE.md with wait:true policy and dev environment setup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* WIP: async action fixes and interest manager

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix Foundry UI bugs: org names, hanging sessions, and wrong repo creation

- Fix org display name using GitHub description instead of name field
- Fix createWorkbenchSession hanging when sandbox is provisioning
- Fix auto-session creation retry storm on errors
- Fix task creation using wrong repo due to React state race conditions
- Remove Bun hot-reload from backend Dockerfile (causes port drift)
- Add GitHub sync/install status to dev panel

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-13 20:48:22 -07:00 committed by GitHub
parent 58c54156f1
commit d8b8b49f37
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
88 changed files with 9252 additions and 1933 deletions

View file

@ -28,6 +28,10 @@ export const taskRuntime = sqliteTable(
activeSwitchTarget: text("active_switch_target"),
activeCwd: text("active_cwd"),
statusMessage: text("status_message"),
gitStateJson: text("git_state_json"),
gitStateUpdatedAt: integer("git_state_updated_at"),
provisionStage: text("provision_stage"),
provisionStageUpdatedAt: integer("provision_stage_updated_at"),
updatedAt: integer("updated_at").notNull(),
},
(table) => [check("task_runtime_singleton_id_check", sql`${table.id} = 1`)],
@ -46,8 +50,13 @@ export const taskSandboxes = sqliteTable("task_sandboxes", {
export const taskWorkbenchSessions = sqliteTable("task_workbench_sessions", {
sessionId: text("session_id").notNull().primaryKey(),
sandboxSessionId: text("sandbox_session_id"),
sessionName: text("session_name").notNull(),
model: text("model").notNull(),
status: text("status").notNull().default("ready"),
errorMessage: text("error_message"),
transcriptJson: text("transcript_json").notNull().default("[]"),
transcriptUpdatedAt: integer("transcript_updated_at"),
unread: integer("unread").notNull().default(0),
draftText: text("draft_text").notNull().default(""),
// Structured by the workbench composer attachment payload format.

View file

@ -19,7 +19,9 @@ import {
changeWorkbenchModel,
closeWorkbenchSession,
createWorkbenchSession,
getWorkbenchTask,
getSessionDetail,
getTaskDetail,
getTaskSummary,
markWorkbenchUnread,
publishWorkbenchPr,
renameWorkbenchBranch,
@ -144,14 +146,9 @@ export const task = actor({
async provision(c, cmd: InitializeCommand): Promise<{ ok: true }> {
const self = selfTask(c);
const result = await self.send(taskWorkflowQueueName("task.command.provision"), cmd ?? {}, {
wait: true,
timeout: 30 * 60_000,
await self.send(taskWorkflowQueueName("task.command.provision"), cmd ?? {}, {
wait: false,
});
const response = expectQueueResponse<{ ok: boolean; error?: string }>(result);
if (!response.ok) {
throw new Error(response.error ?? "task provisioning failed");
}
return { ok: true };
},
@ -180,47 +177,35 @@ export const task = actor({
async push(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.push"), cmd ?? {}, {
wait: true,
timeout: 180_000,
wait: false,
});
},
async sync(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.sync"), cmd ?? {}, {
wait: true,
timeout: 30_000,
wait: false,
});
},
async merge(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.merge"), cmd ?? {}, {
wait: true,
timeout: 30_000,
wait: false,
});
},
async archive(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
void self
.send(taskWorkflowQueueName("task.command.archive"), cmd ?? {}, {
wait: true,
timeout: 60_000,
})
.catch((error: unknown) => {
c.log.warn({
msg: "archive command failed",
error: error instanceof Error ? error.message : String(error),
});
});
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: true,
timeout: 60_000,
wait: false,
});
},
@ -228,8 +213,16 @@ export const task = actor({
return await getCurrentRecord({ db: c.db, state: c.state });
},
async getWorkbench(c) {
return await getWorkbenchTask(c);
async getTaskSummary(c) {
return await getTaskSummary(c);
},
async getTaskDetail(c) {
return await getTaskDetail(c);
},
async getSessionDetail(c, input: { sessionId: string }) {
return await getSessionDetail(c, input.sessionId);
},
async markWorkbenchUnread(c): Promise<void> {
@ -255,8 +248,7 @@ export const task = actor({
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: true,
timeout: 5 * 60_000,
wait: false,
});
},
@ -335,8 +327,7 @@ export const task = actor({
attachments: input.attachments,
} satisfies TaskWorkbenchSendMessageCommand,
{
wait: true,
timeout: 10 * 60_000,
wait: false,
},
);
},
@ -344,8 +335,7 @@ export const task = actor({
async stopWorkbenchSession(c, input: TaskTabCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workbench.stop_session"), { sessionId: input.tabId } satisfies TaskWorkbenchSessionCommand, {
wait: true,
timeout: 5 * 60_000,
wait: false,
});
},
@ -360,8 +350,7 @@ export const task = actor({
async closeWorkbenchSession(c, input: TaskTabCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workbench.close_session"), { sessionId: input.tabId } satisfies TaskWorkbenchSessionCommand, {
wait: true,
timeout: 5 * 60_000,
wait: false,
});
},
@ -371,8 +360,7 @@ export const task = actor({
taskWorkflowQueueName("task.command.workbench.publish_pr"),
{},
{
wait: true,
timeout: 10 * 60_000,
wait: false,
},
);
},
@ -380,8 +368,7 @@ export const task = actor({
async revertWorkbenchFile(c, input: { path: string }): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workbench.revert_file"), input, {
wait: true,
timeout: 5 * 60_000,
wait: false,
});
},
},

View file

@ -1,4 +1,5 @@
// @ts-nocheck
import { randomUUID } from "node:crypto";
import { basename } from "node:path";
import { asc, eq } from "drizzle-orm";
import { getActorRuntimeContext } from "../context.js";
@ -6,15 +7,30 @@ import { getOrCreateTaskStatusSync, getOrCreateProject, getOrCreateWorkspace, ge
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
import { task as taskTable, taskRuntime, taskWorkbenchSessions } from "./db/schema.js";
import { getCurrentRecord } from "./workflow/common.js";
import { taskWorkflowQueueName } from "./workflow/queue.js";
const STATUS_SYNC_INTERVAL_MS = 1_000;
function emptyGitState() {
return {
fileChanges: [],
diffs: {},
fileTree: [],
updatedAt: null as number | null,
};
}
async function ensureWorkbenchSessionTable(c: any): Promise<void> {
await c.db.execute(`
CREATE TABLE IF NOT EXISTS task_workbench_sessions (
session_id text PRIMARY KEY NOT NULL,
sandbox_session_id text,
session_name text NOT NULL,
model text NOT NULL,
status text DEFAULT 'ready' NOT NULL,
error_message text,
transcript_json text DEFAULT '[]' NOT NULL,
transcript_updated_at integer,
unread integer DEFAULT 0 NOT NULL,
draft_text text DEFAULT '' NOT NULL,
draft_attachments_json text DEFAULT '[]' NOT NULL,
@ -26,6 +42,18 @@ async function ensureWorkbenchSessionTable(c: any): Promise<void> {
updated_at integer NOT NULL
)
`);
await c.db.execute(`ALTER TABLE task_workbench_sessions ADD COLUMN sandbox_session_id text`).catch(() => {});
await c.db.execute(`ALTER TABLE task_workbench_sessions ADD COLUMN status text DEFAULT 'ready' NOT NULL`).catch(() => {});
await c.db.execute(`ALTER TABLE task_workbench_sessions ADD COLUMN error_message text`).catch(() => {});
await c.db.execute(`ALTER TABLE task_workbench_sessions ADD COLUMN transcript_json text DEFAULT '[]' NOT NULL`).catch(() => {});
await c.db.execute(`ALTER TABLE task_workbench_sessions ADD COLUMN transcript_updated_at integer`).catch(() => {});
}
async function ensureTaskRuntimeCacheColumns(c: any): Promise<void> {
await c.db.execute(`ALTER TABLE task_runtime ADD COLUMN git_state_json text`).catch(() => {});
await c.db.execute(`ALTER TABLE task_runtime ADD COLUMN git_state_updated_at integer`).catch(() => {});
await c.db.execute(`ALTER TABLE task_runtime ADD COLUMN provision_stage text`).catch(() => {});
await c.db.execute(`ALTER TABLE task_runtime ADD COLUMN provision_stage_updated_at integer`).catch(() => {});
}
function defaultModelForAgent(agentType: string | null | undefined) {
@ -74,6 +102,40 @@ function parseDraftAttachments(value: string | null | undefined): Array<any> {
}
}
function parseTranscript(value: string | null | undefined): Array<any> {
if (!value) {
return [];
}
try {
const parsed = JSON.parse(value) as unknown;
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function parseGitState(value: string | null | undefined): { fileChanges: Array<any>; diffs: Record<string, string>; fileTree: Array<any> } {
if (!value) {
return emptyGitState();
}
try {
const parsed = JSON.parse(value) as {
fileChanges?: unknown;
diffs?: unknown;
fileTree?: unknown;
};
return {
fileChanges: Array.isArray(parsed.fileChanges) ? parsed.fileChanges : [],
diffs: parsed.diffs && typeof parsed.diffs === "object" ? (parsed.diffs as Record<string, string>) : {},
fileTree: Array.isArray(parsed.fileTree) ? parsed.fileTree : [],
};
} catch {
return emptyGitState();
}
}
export function shouldMarkSessionUnreadForStatus(meta: { thinkingSinceMs?: number | null }, status: "running" | "idle" | "error"): boolean {
if (status === "running") {
return false;
@ -90,7 +152,13 @@ async function listSessionMetaRows(c: any, options?: { includeClosed?: boolean }
const mapped = rows.map((row: any) => ({
...row,
id: row.sessionId,
sessionId: row.sessionId,
sessionId: row.sandboxSessionId ?? null,
tabId: row.sessionId,
sandboxSessionId: row.sandboxSessionId ?? null,
status: row.status ?? "ready",
errorMessage: row.errorMessage ?? null,
transcript: parseTranscript(row.transcriptJson),
transcriptUpdatedAt: row.transcriptUpdatedAt ?? null,
draftAttachments: parseDraftAttachments(row.draftAttachmentsJson),
draftUpdatedAtMs: row.draftUpdatedAt ?? null,
unread: row.unread === 1,
@ -121,7 +189,13 @@ async function readSessionMeta(c: any, sessionId: string): Promise<any | null> {
return {
...row,
id: row.sessionId,
sessionId: row.sessionId,
sessionId: row.sandboxSessionId ?? null,
tabId: row.sessionId,
sandboxSessionId: row.sandboxSessionId ?? null,
status: row.status ?? "ready",
errorMessage: row.errorMessage ?? null,
transcript: parseTranscript(row.transcriptJson),
transcriptUpdatedAt: row.transcriptUpdatedAt ?? null,
draftAttachments: parseDraftAttachments(row.draftAttachmentsJson),
draftUpdatedAtMs: row.draftUpdatedAt ?? null,
unread: row.unread === 1,
@ -133,14 +207,18 @@ async function readSessionMeta(c: any, sessionId: string): Promise<any | null> {
async function ensureSessionMeta(
c: any,
params: {
sessionId: string;
tabId: string;
sandboxSessionId?: string | null;
model?: string;
sessionName?: string;
unread?: boolean;
created?: boolean;
status?: "pending_provision" | "pending_session_create" | "ready" | "error";
errorMessage?: string | null;
},
): Promise<any> {
await ensureWorkbenchSessionTable(c);
const existing = await readSessionMeta(c, params.sessionId);
const existing = await readSessionMeta(c, params.tabId);
if (existing) {
return existing;
}
@ -153,14 +231,19 @@ async function ensureSessionMeta(
await c.db
.insert(taskWorkbenchSessions)
.values({
sessionId: params.sessionId,
sessionId: params.tabId,
sandboxSessionId: params.sandboxSessionId ?? null,
sessionName,
model,
status: params.status ?? "ready",
errorMessage: params.errorMessage ?? null,
transcriptJson: "[]",
transcriptUpdatedAt: null,
unread: unread ? 1 : 0,
draftText: "",
draftAttachmentsJson: "[]",
draftUpdatedAt: null,
created: 1,
created: params.created === false ? 0 : 1,
closed: 0,
thinkingSinceMs: null,
createdAt: now,
@ -168,25 +251,40 @@ async function ensureSessionMeta(
})
.run();
return await readSessionMeta(c, params.sessionId);
return await readSessionMeta(c, params.tabId);
}
async function updateSessionMeta(c: any, sessionId: string, values: Record<string, unknown>): Promise<any> {
await ensureSessionMeta(c, { sessionId });
async function updateSessionMeta(c: any, tabId: string, values: Record<string, unknown>): Promise<any> {
await ensureSessionMeta(c, { tabId });
await c.db
.update(taskWorkbenchSessions)
.set({
...values,
updatedAt: Date.now(),
})
.where(eq(taskWorkbenchSessions.sessionId, sessionId))
.where(eq(taskWorkbenchSessions.sessionId, tabId))
.run();
return await readSessionMeta(c, sessionId);
return await readSessionMeta(c, tabId);
}
async function notifyWorkbenchUpdated(c: any): Promise<void> {
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
await workspace.notifyWorkbenchUpdated({});
async function readSessionMetaBySandboxSessionId(c: any, sandboxSessionId: string): Promise<any | null> {
await ensureWorkbenchSessionTable(c);
const row = await c.db.select().from(taskWorkbenchSessions).where(eq(taskWorkbenchSessions.sandboxSessionId, sandboxSessionId)).get();
if (!row) {
return null;
}
return await readSessionMeta(c, row.sessionId);
}
async function requireReadySessionMeta(c: any, tabId: string): Promise<any> {
const meta = await readSessionMeta(c, tabId);
if (!meta) {
throw new Error(`Unknown workbench tab: ${tabId}`);
}
if (meta.status !== "ready" || !meta.sandboxSessionId) {
throw new Error(meta.errorMessage ?? "This workbench tab is still preparing");
}
return meta;
}
function shellFragment(parts: string[]): string {
@ -333,17 +431,6 @@ async function collectWorkbenchGitState(c: any, record: any) {
label: "git diff numstat",
});
const numstat = parseNumstat(numstatResult.result);
const diffs: Record<string, string> = {};
for (const row of statusRows) {
const diffResult = await executeInSandbox(c, {
sandboxId: activeSandboxId,
cwd,
command: `if git ls-files --error-unmatch -- ${JSON.stringify(row.path)} >/dev/null 2>&1; then git diff -- ${JSON.stringify(row.path)}; else git diff --no-index -- /dev/null ${JSON.stringify(row.path)} || true; fi`,
label: `git diff ${row.path}`,
});
diffs[row.path] = diffResult.result;
}
const filesResult = await executeInSandbox(c, {
sandboxId: activeSandboxId,
@ -356,6 +443,17 @@ async function collectWorkbenchGitState(c: any, record: any) {
.map((line) => line.trim())
.filter(Boolean);
const diffs: Record<string, string> = {};
for (const row of statusRows) {
const diffResult = await executeInSandbox(c, {
sandboxId: activeSandboxId,
cwd,
command: `git diff -- ${JSON.stringify(row.path)}`,
label: `git diff ${row.path}`,
});
diffs[row.path] = diffResult.exitCode === 0 ? diffResult.result : "";
}
return {
fileChanges: statusRows.map((row) => {
const counts = numstat.get(row.path) ?? { added: 0, removed: 0 };
@ -371,6 +469,37 @@ async function collectWorkbenchGitState(c: any, record: any) {
};
}
async function readCachedGitState(c: any): Promise<{ fileChanges: Array<any>; diffs: Record<string, string>; fileTree: Array<any>; updatedAt: number | null }> {
await ensureTaskRuntimeCacheColumns(c);
const row = await c.db
.select({
gitStateJson: taskRuntime.gitStateJson,
gitStateUpdatedAt: taskRuntime.gitStateUpdatedAt,
})
.from(taskRuntime)
.where(eq(taskRuntime.id, 1))
.get();
const parsed = parseGitState(row?.gitStateJson);
return {
...parsed,
updatedAt: row?.gitStateUpdatedAt ?? null,
};
}
async function writeCachedGitState(c: any, gitState: { fileChanges: Array<any>; diffs: Record<string, string>; fileTree: Array<any> }): Promise<void> {
await ensureTaskRuntimeCacheColumns(c);
const now = Date.now();
await c.db
.update(taskRuntime)
.set({
gitStateJson: JSON.stringify(gitState),
gitStateUpdatedAt: now,
updatedAt: now,
})
.where(eq(taskRuntime.id, 1))
.run();
}
async function readSessionTranscript(c: any, record: any, sessionId: string) {
const sandboxId = record.activeSandboxId ?? record.sandboxes?.[0]?.sandboxId ?? null;
if (!sandboxId) {
@ -380,7 +509,7 @@ async function readSessionTranscript(c: any, record: any, sessionId: string) {
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, sandboxId);
const page = await sandbox.listSessionEvents({
sessionId,
limit: 500,
limit: 100,
});
return page.items.map((event: any) => ({
id: event.id,
@ -393,14 +522,50 @@ async function readSessionTranscript(c: any, record: any, sessionId: string) {
}));
}
async function activeSessionStatus(c: any, record: any, sessionId: string) {
if (record.activeSessionId !== sessionId || !record.activeSandboxId) {
async function writeSessionTranscript(c: any, tabId: string, transcript: Array<any>): Promise<void> {
await updateSessionMeta(c, tabId, {
transcriptJson: JSON.stringify(transcript),
transcriptUpdatedAt: Date.now(),
});
}
async function enqueueWorkbenchRefresh(
c: any,
command: "task.command.workbench.refresh_derived" | "task.command.workbench.refresh_session_transcript",
body: Record<string, unknown>,
): Promise<void> {
const self = selfTask(c);
await self.send(command, body, { wait: false });
}
async function maybeScheduleWorkbenchRefreshes(c: any, record: any, sessions: Array<any>): Promise<void> {
const gitState = await readCachedGitState(c);
if (record.activeSandboxId && !gitState.updatedAt) {
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_derived", {});
}
for (const session of sessions) {
if (session.closed || session.status !== "ready" || !session.sandboxSessionId || session.transcriptUpdatedAt) {
continue;
}
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", {
sessionId: session.sandboxSessionId,
});
}
}
function activeSessionStatus(record: any, sessionId: string) {
if (record.activeSessionId !== sessionId) {
return "idle";
}
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
const status = await sandbox.sessionStatus({ sessionId });
return status.status;
if (record.status === "running") {
return "running";
}
if (record.status === "error") {
return "error";
}
return "idle";
}
async function readPullRequestSummary(c: any, branchName: string | null) {
@ -417,51 +582,75 @@ async function readPullRequestSummary(c: any, branchName: string | null) {
}
export async function ensureWorkbenchSeeded(c: any): Promise<any> {
await ensureTaskRuntimeCacheColumns(c);
const record = await getCurrentRecord({ db: c.db, state: c.state });
if (record.activeSessionId) {
await ensureSessionMeta(c, {
sessionId: record.activeSessionId,
tabId: record.activeSessionId,
sandboxSessionId: record.activeSessionId,
model: defaultModelForAgent(record.agentType),
sessionName: "Session 1",
status: "ready",
});
}
return record;
}
export async function getWorkbenchTask(c: any): Promise<any> {
const record = await ensureWorkbenchSeeded(c);
const gitState = await collectWorkbenchGitState(c, record);
const sessions = await listSessionMetaRows(c);
const tabs = [];
for (const meta of sessions) {
const status = await activeSessionStatus(c, record, meta.sessionId);
let thinkingSinceMs = meta.thinkingSinceMs ?? null;
let unread = Boolean(meta.unread);
if (thinkingSinceMs && status !== "running") {
thinkingSinceMs = null;
unread = true;
}
tabs.push({
id: meta.id,
sessionId: meta.sessionId,
sessionName: meta.sessionName,
agent: agentKindForModel(meta.model),
model: meta.model,
status,
thinkingSinceMs: status === "running" ? thinkingSinceMs : null,
unread,
created: Boolean(meta.created),
draft: {
text: meta.draftText ?? "",
attachments: Array.isArray(meta.draftAttachments) ? meta.draftAttachments : [],
updatedAtMs: meta.draftUpdatedAtMs ?? null,
},
transcript: await readSessionTranscript(c, record, meta.sessionId),
});
function buildSessionSummary(record: any, meta: any): any {
const derivedSandboxSessionId = meta.sandboxSessionId ?? (meta.status === "pending_provision" && record.activeSessionId ? record.activeSessionId : null);
const sessionStatus =
meta.status === "ready" && derivedSandboxSessionId ? activeSessionStatus(record, derivedSandboxSessionId) : meta.status === "error" ? "error" : "idle";
let thinkingSinceMs = meta.thinkingSinceMs ?? null;
let unread = Boolean(meta.unread);
if (thinkingSinceMs && sessionStatus !== "running") {
thinkingSinceMs = null;
unread = true;
}
return {
id: meta.id,
sessionId: derivedSandboxSessionId,
sessionName: meta.sessionName,
agent: agentKindForModel(meta.model),
model: meta.model,
status: sessionStatus,
thinkingSinceMs: sessionStatus === "running" ? thinkingSinceMs : null,
unread,
created: Boolean(meta.created || derivedSandboxSessionId),
};
}
function buildSessionDetailFromMeta(record: any, meta: any): any {
const summary = buildSessionSummary(record, meta);
return {
sessionId: meta.tabId,
tabId: meta.tabId,
sandboxSessionId: summary.sessionId,
sessionName: summary.sessionName,
agent: summary.agent,
model: summary.model,
status: summary.status,
thinkingSinceMs: summary.thinkingSinceMs,
unread: summary.unread,
created: summary.created,
draft: {
text: meta.draftText ?? "",
attachments: Array.isArray(meta.draftAttachments) ? meta.draftAttachments : [],
updatedAtMs: meta.draftUpdatedAtMs ?? null,
},
transcript: meta.transcript ?? [],
};
}
/**
* Builds a WorkbenchTaskSummary from local task actor state. Task actors push
* this to the parent workspace actor so workspace sidebar reads stay local.
*/
export async function buildTaskSummary(c: any): Promise<any> {
const record = await ensureWorkbenchSeeded(c);
const sessions = await listSessionMetaRows(c);
await maybeScheduleWorkbenchRefreshes(c, record, sessions);
return {
id: c.state.taskId,
repoId: c.state.repoId,
@ -471,14 +660,112 @@ export async function getWorkbenchTask(c: any): Promise<any> {
updatedAtMs: record.updatedAt,
branch: record.branchName,
pullRequest: await readPullRequestSummary(c, record.branchName),
tabs,
sessionsSummary: sessions.map((meta) => buildSessionSummary(record, meta)),
};
}
/**
* Builds a WorkbenchTaskDetail from local task actor state for direct task
* subscribers. This is a full replacement payload, not a patch.
*/
export async function buildTaskDetail(c: any): Promise<any> {
const record = await ensureWorkbenchSeeded(c);
const gitState = await readCachedGitState(c);
const sessions = await listSessionMetaRows(c);
await maybeScheduleWorkbenchRefreshes(c, record, sessions);
const summary = await buildTaskSummary(c);
return {
...summary,
task: record.task,
agentType: record.agentType === "claude" || record.agentType === "codex" ? record.agentType : null,
runtimeStatus: record.status,
statusMessage: record.statusMessage ?? null,
activeSessionId: record.activeSessionId ?? null,
diffStat: record.diffStat ?? null,
prUrl: record.prUrl ?? null,
reviewStatus: record.reviewStatus ?? null,
fileChanges: gitState.fileChanges,
diffs: gitState.diffs,
fileTree: gitState.fileTree,
minutesUsed: 0,
sandboxes: (record.sandboxes ?? []).map((sandbox: any) => ({
providerId: sandbox.providerId,
sandboxId: sandbox.sandboxId,
cwd: sandbox.cwd ?? null,
})),
activeSandboxId: record.activeSandboxId ?? null,
};
}
/**
* Builds a WorkbenchSessionDetail for a specific session tab.
*/
export async function buildSessionDetail(c: any, tabId: string): Promise<any> {
const record = await ensureWorkbenchSeeded(c);
const meta = await readSessionMeta(c, tabId);
if (!meta || meta.closed) {
throw new Error(`Unknown workbench session tab: ${tabId}`);
}
return buildSessionDetailFromMeta(record, meta);
}
export async function getTaskSummary(c: any): Promise<any> {
return await buildTaskSummary(c);
}
export async function getTaskDetail(c: any): Promise<any> {
return await buildTaskDetail(c);
}
export async function getSessionDetail(c: any, tabId: string): Promise<any> {
return await buildSessionDetail(c, tabId);
}
/**
* Replaces the old notifyWorkbenchUpdated pattern.
*
* The task actor emits two kinds of updates:
* - Push summary state up to the parent workspace actor so the sidebar
* materialized projection stays current.
* - Broadcast full detail/session payloads down to direct task subscribers.
*/
export async function broadcastTaskUpdate(c: any, options?: { sessionId?: string }): Promise<void> {
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
await workspace.applyTaskSummaryUpdate({ taskSummary: await buildTaskSummary(c) });
c.broadcast("taskUpdated", {
type: "taskDetailUpdated",
detail: await buildTaskDetail(c),
});
if (options?.sessionId) {
c.broadcast("sessionUpdated", {
type: "sessionUpdated",
session: await buildSessionDetail(c, options.sessionId),
});
}
}
export async function refreshWorkbenchDerivedState(c: any): Promise<void> {
const record = await ensureWorkbenchSeeded(c);
const gitState = await collectWorkbenchGitState(c, record);
await writeCachedGitState(c, gitState);
await broadcastTaskUpdate(c);
}
export async function refreshWorkbenchSessionTranscript(c: any, sessionId: string): Promise<void> {
const record = await ensureWorkbenchSeeded(c);
const meta = (await readSessionMetaBySandboxSessionId(c, sessionId)) ?? (await readSessionMeta(c, sessionId));
if (!meta?.sandboxSessionId) {
return;
}
const transcript = await readSessionTranscript(c, record, meta.sandboxSessionId);
await writeSessionTranscript(c, meta.tabId, transcript);
await broadcastTaskUpdate(c, { sessionId: meta.tabId });
}
export async function renameWorkbenchTask(c: any, value: string): Promise<void> {
const nextTitle = value.trim();
if (!nextTitle) {
@ -494,7 +781,7 @@ export async function renameWorkbenchTask(c: any, value: string): Promise<void>
.where(eq(taskTable.id, 1))
.run();
c.state.title = nextTitle;
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c);
}
export async function renameWorkbenchBranch(c: any, value: string): Promise<void> {
@ -545,55 +832,168 @@ export async function renameWorkbenchBranch(c: any, value: string): Promise<void
taskId: c.state.taskId,
branchName: nextBranch,
});
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c);
}
export async function createWorkbenchSession(c: any, model?: string): Promise<{ tabId: string }> {
let record = await ensureWorkbenchSeeded(c);
if (!record.activeSandboxId) {
// Fire-and-forget: enqueue provisioning without waiting to avoid self-deadlock
// (this handler already runs inside the task workflow loop, so wait:true would deadlock).
const providerId = record.providerId ?? c.state.providerId ?? getActorRuntimeContext().providers.defaultProviderId();
await selfTask(c).provision({ providerId });
record = await ensureWorkbenchSeeded(c);
await selfTask(c).send(taskWorkflowQueueName("task.command.provision"), { providerId }, { wait: false });
throw new Error("sandbox is provisioning — retry shortly");
}
if (record.activeSessionId) {
const existingSessions = await listSessionMetaRows(c);
if (existingSessions.length === 0) {
await ensureSessionMeta(c, {
sessionId: record.activeSessionId,
tabId: record.activeSessionId,
sandboxSessionId: record.activeSessionId,
model: model ?? defaultModelForAgent(record.agentType),
sessionName: "Session 1",
status: "ready",
});
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c, { sessionId: record.activeSessionId });
return { tabId: record.activeSessionId };
}
}
if (!record.activeSandboxId) {
throw new Error("cannot create session without an active sandbox");
const tabId = `tab-${randomUUID()}`;
await ensureSessionMeta(c, {
tabId,
model: model ?? defaultModelForAgent(record.agentType),
status: record.activeSandboxId ? "pending_session_create" : "pending_provision",
created: false,
});
const providerId = record.providerId ?? c.state.providerId ?? getActorRuntimeContext().providers.defaultProviderId();
const self = selfTask(c);
if (!record.activeSandboxId && !String(record.status ?? "").startsWith("init_")) {
await self.send("task.command.provision", { providerId }, { wait: false });
}
await self.send(
"task.command.workbench.ensure_session",
{ tabId, ...(model ? { model } : {}) },
{
wait: false,
},
);
await broadcastTaskUpdate(c, { sessionId: tabId });
return { tabId };
}
export async function ensureWorkbenchSession(c: any, tabId: string, model?: string): Promise<void> {
const meta = await readSessionMeta(c, tabId);
if (!meta || meta.closed) {
return;
}
const record = await ensureWorkbenchSeeded(c);
if (!record.activeSandboxId) {
await updateSessionMeta(c, tabId, {
status: "pending_provision",
errorMessage: null,
});
return;
}
if (!meta.sandboxSessionId && record.activeSessionId && meta.status === "pending_provision") {
const existingTabForActiveSession = await readSessionMetaBySandboxSessionId(c, record.activeSessionId);
if (existingTabForActiveSession && existingTabForActiveSession.tabId !== tabId) {
await updateSessionMeta(c, existingTabForActiveSession.tabId, {
closed: 1,
});
}
await updateSessionMeta(c, tabId, {
sandboxSessionId: record.activeSessionId,
status: "ready",
errorMessage: null,
created: 1,
});
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", {
sessionId: record.activeSessionId,
});
await broadcastTaskUpdate(c, { sessionId: tabId });
return;
}
if (meta.sandboxSessionId) {
await updateSessionMeta(c, tabId, {
status: "ready",
errorMessage: null,
});
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", {
sessionId: meta.sandboxSessionId,
});
await broadcastTaskUpdate(c, { sessionId: tabId });
return;
}
const activeSandbox = (record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null;
const cwd = activeSandbox?.cwd ?? record.sandboxes?.[0]?.cwd ?? null;
if (!cwd) {
throw new Error("cannot create session without a sandbox cwd");
await updateSessionMeta(c, tabId, {
status: "error",
errorMessage: "cannot create session without a sandbox cwd",
});
await broadcastTaskUpdate(c, { sessionId: tabId });
return;
}
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
const created = await sandbox.createSession({
prompt: "",
cwd,
agent: agentTypeForModel(model ?? defaultModelForAgent(record.agentType)),
await updateSessionMeta(c, tabId, {
status: "pending_session_create",
errorMessage: null,
});
if (!created.id) {
throw new Error(created.error ?? "sandbox-agent session creation failed");
try {
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
const created = await sandbox.createSession({
prompt: "",
cwd,
agent: agentTypeForModel(model ?? meta.model ?? defaultModelForAgent(record.agentType)),
});
if (!created.id) {
throw new Error(created.error ?? "sandbox-agent session creation failed");
}
await updateSessionMeta(c, tabId, {
sandboxSessionId: created.id,
status: "ready",
errorMessage: null,
});
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", {
sessionId: created.id,
});
} catch (error) {
await updateSessionMeta(c, tabId, {
status: "error",
errorMessage: error instanceof Error ? error.message : String(error),
});
}
await ensureSessionMeta(c, {
sessionId: created.id,
model: model ?? defaultModelForAgent(record.agentType),
});
await notifyWorkbenchUpdated(c);
return { tabId: created.id };
await broadcastTaskUpdate(c, { sessionId: tabId });
}
export async function enqueuePendingWorkbenchSessions(c: any): Promise<void> {
const self = selfTask(c);
const pending = (await listSessionMetaRows(c, { includeClosed: true })).filter(
(row) => row.closed !== true && row.status !== "ready" && row.status !== "error",
);
for (const row of pending) {
await self.send(
"task.command.workbench.ensure_session",
{
tabId: row.tabId,
model: row.model,
},
{
wait: false,
},
);
}
}
export async function renameWorkbenchSession(c: any, sessionId: string, title: string): Promise<void> {
@ -604,14 +1004,14 @@ export async function renameWorkbenchSession(c: any, sessionId: string, title: s
await updateSessionMeta(c, sessionId, {
sessionName: trimmed,
});
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c, { sessionId });
}
export async function setWorkbenchSessionUnread(c: any, sessionId: string, unread: boolean): Promise<void> {
await updateSessionMeta(c, sessionId, {
unread: unread ? 1 : 0,
});
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c, { sessionId });
}
export async function updateWorkbenchDraft(c: any, sessionId: string, text: string, attachments: Array<any>): Promise<void> {
@ -620,14 +1020,14 @@ export async function updateWorkbenchDraft(c: any, sessionId: string, text: stri
draftAttachmentsJson: JSON.stringify(attachments),
draftUpdatedAt: Date.now(),
});
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c, { sessionId });
}
export async function changeWorkbenchModel(c: any, sessionId: string, model: string): Promise<void> {
await updateSessionMeta(c, sessionId, {
model,
});
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c, { sessionId });
}
export async function sendWorkbenchMessage(c: any, sessionId: string, text: string, attachments: Array<any>): Promise<void> {
@ -636,7 +1036,7 @@ export async function sendWorkbenchMessage(c: any, sessionId: string, text: stri
throw new Error("cannot send message without an active sandbox");
}
await ensureSessionMeta(c, { sessionId });
const meta = await requireReadySessionMeta(c, sessionId);
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
const prompt = [text.trim(), ...attachments.map((attachment: any) => `@ ${attachment.filePath}:${attachment.lineNumber}\n${attachment.lineContent}`)]
.filter(Boolean)
@ -646,7 +1046,7 @@ export async function sendWorkbenchMessage(c: any, sessionId: string, text: stri
}
await sandbox.sendPrompt({
sessionId,
sessionId: meta.sandboxSessionId,
prompt,
notification: true,
});
@ -663,25 +1063,28 @@ export async function sendWorkbenchMessage(c: any, sessionId: string, text: stri
await c.db
.update(taskRuntime)
.set({
activeSessionId: sessionId,
activeSessionId: meta.sandboxSessionId,
updatedAt: Date.now(),
})
.where(eq(taskRuntime.id, 1))
.run();
const sync = await getOrCreateTaskStatusSync(c, c.state.workspaceId, c.state.repoId, c.state.taskId, record.activeSandboxId, sessionId, {
const sync = await getOrCreateTaskStatusSync(c, c.state.workspaceId, c.state.repoId, c.state.taskId, record.activeSandboxId, meta.sandboxSessionId, {
workspaceId: c.state.workspaceId,
repoId: c.state.repoId,
taskId: c.state.taskId,
providerId: c.state.providerId,
sandboxId: record.activeSandboxId,
sessionId,
sessionId: meta.sandboxSessionId,
intervalMs: STATUS_SYNC_INTERVAL_MS,
});
await sync.setIntervalMs({ intervalMs: STATUS_SYNC_INTERVAL_MS });
await sync.start();
await sync.force();
await notifyWorkbenchUpdated(c);
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", {
sessionId: meta.sandboxSessionId,
});
await broadcastTaskUpdate(c, { sessionId });
}
export async function stopWorkbenchSession(c: any, sessionId: string): Promise<void> {
@ -689,20 +1092,21 @@ export async function stopWorkbenchSession(c: any, sessionId: string): Promise<v
if (!record.activeSandboxId) {
return;
}
const meta = await requireReadySessionMeta(c, sessionId);
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
await sandbox.cancelSession({ sessionId });
await sandbox.cancelSession({ sessionId: meta.sandboxSessionId });
await updateSessionMeta(c, sessionId, {
thinkingSinceMs: null,
});
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c, { sessionId });
}
export async function syncWorkbenchSessionStatus(c: any, sessionId: string, status: "running" | "idle" | "error", at: number): Promise<void> {
const record = await ensureWorkbenchSeeded(c);
const meta = await ensureSessionMeta(c, { sessionId });
const meta = (await readSessionMetaBySandboxSessionId(c, sessionId)) ?? (await ensureSessionMeta(c, { tabId: sessionId, sandboxSessionId: sessionId }));
let changed = false;
if (record.activeSessionId === sessionId) {
if (record.activeSessionId === sessionId || record.activeSessionId === meta.sandboxSessionId) {
const mappedStatus = status === "running" ? "running" : status === "error" ? "error" : "idle";
if (record.status !== mappedStatus) {
await c.db
@ -753,27 +1157,36 @@ export async function syncWorkbenchSessionStatus(c: any, sessionId: string, stat
}
if (changed) {
await notifyWorkbenchUpdated(c);
if (status !== "running") {
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", {
sessionId,
});
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_derived", {});
}
await broadcastTaskUpdate(c, { sessionId: meta.tabId });
}
}
export async function closeWorkbenchSession(c: any, sessionId: string): Promise<void> {
const record = await ensureWorkbenchSeeded(c);
if (!record.activeSandboxId) {
return;
}
const sessions = await listSessionMetaRows(c);
if (sessions.filter((candidate) => candidate.closed !== true).length <= 1) {
return;
}
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
await sandbox.destroySession({ sessionId });
const meta = await readSessionMeta(c, sessionId);
if (!meta) {
return;
}
if (record.activeSandboxId && meta.sandboxSessionId) {
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
await sandbox.destroySession({ sessionId: meta.sandboxSessionId });
}
await updateSessionMeta(c, sessionId, {
closed: 1,
thinkingSinceMs: null,
});
if (record.activeSessionId === sessionId) {
if (record.activeSessionId === sessionId || record.activeSessionId === meta.sandboxSessionId) {
await c.db
.update(taskRuntime)
.set({
@ -783,7 +1196,7 @@ export async function closeWorkbenchSession(c: any, sessionId: string): Promise<
.where(eq(taskRuntime.id, 1))
.run();
}
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c);
}
export async function markWorkbenchUnread(c: any): Promise<void> {
@ -792,10 +1205,10 @@ export async function markWorkbenchUnread(c: any): Promise<void> {
if (!latest) {
return;
}
await updateSessionMeta(c, latest.sessionId, {
await updateSessionMeta(c, latest.tabId, {
unread: 1,
});
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c, { sessionId: latest.tabId });
}
export async function publishWorkbenchPr(c: any): Promise<void> {
@ -816,7 +1229,7 @@ export async function publishWorkbenchPr(c: any): Promise<void> {
})
.where(eq(taskTable.id, 1))
.run();
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c);
}
export async function revertWorkbenchFile(c: any, path: string): Promise<void> {
@ -838,5 +1251,6 @@ export async function revertWorkbenchFile(c: any, path: string): Promise<void> {
if (result.exitCode !== 0) {
throw new Error(`file revert failed (${result.exitCode}): ${result.result}`);
}
await notifyWorkbenchUpdated(c);
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_derived", {});
await broadcastTaskUpdate(c);
}

View file

@ -1,9 +1,9 @@
// @ts-nocheck
import { eq } from "drizzle-orm";
import type { TaskRecord, TaskStatus } from "@sandbox-agent/foundry-shared";
import { getOrCreateWorkspace } from "../../handles.js";
import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js";
import { historyKey } from "../../keys.js";
import { broadcastTaskUpdate } from "../workbench.js";
export const TASK_ROW_ID = 1;
@ -83,8 +83,7 @@ export async function setTaskState(ctx: any, status: TaskStatus, statusMessage?:
.run();
}
const workspace = await getOrCreateWorkspace(ctx, ctx.state.workspaceId);
await workspace.notifyWorkbenchUpdated({});
await broadcastTaskUpdate(ctx);
}
export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
@ -176,6 +175,5 @@ export async function appendHistory(ctx: any, kind: string, payload: Record<stri
payload,
});
const workspace = await getOrCreateWorkspace(ctx, ctx.state.workspaceId);
await workspace.notifyWorkbenchUpdated({});
await broadcastTaskUpdate(ctx);
}

View file

@ -8,6 +8,7 @@ import {
initCompleteActivity,
initCreateSandboxActivity,
initCreateSessionActivity,
initEnqueueProvisionActivity,
initEnsureAgentActivity,
initEnsureNameActivity,
initExposeSandboxActivity,
@ -32,6 +33,9 @@ import {
changeWorkbenchModel,
closeWorkbenchSession,
createWorkbenchSession,
ensureWorkbenchSession,
refreshWorkbenchDerivedState,
refreshWorkbenchSessionTranscript,
markWorkbenchUnread,
publishWorkbenchPr,
renameWorkbenchBranch,
@ -56,7 +60,7 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
const body = msg.body;
await loopCtx.step("init-bootstrap-db", async () => initBootstrapDbActivity(loopCtx, body));
await loopCtx.removed("init-enqueue-provision", "step");
await loopCtx.step("init-enqueue-provision", async () => initEnqueueProvisionActivity(loopCtx, body));
await loopCtx.removed("init-dispatch-provision-v2", "step");
const currentRecord = await loopCtx.step("init-read-current-record", async () => getCurrentRecord(loopCtx));
@ -164,12 +168,25 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
},
"task.command.workbench.create_session": async (loopCtx, msg) => {
const created = await loopCtx.step({
name: "workbench-create-session",
try {
const created = await loopCtx.step({
name: "workbench-create-session",
timeout: 30_000,
run: async () => createWorkbenchSession(loopCtx, msg.body?.model),
});
await msg.complete(created);
} catch (error) {
await msg.complete({ error: resolveErrorMessage(error) });
}
},
"task.command.workbench.ensure_session": async (loopCtx, msg) => {
await loopCtx.step({
name: "workbench-ensure-session",
timeout: 5 * 60_000,
run: async () => createWorkbenchSession(loopCtx, msg.body?.model),
run: async () => ensureWorkbenchSession(loopCtx, msg.body.tabId, msg.body?.model),
});
await msg.complete(created);
await msg.complete({ ok: true });
},
"task.command.workbench.rename_session": async (loopCtx, msg) => {
@ -215,6 +232,24 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
await msg.complete({ ok: true });
},
"task.command.workbench.refresh_derived": async (loopCtx, msg) => {
await loopCtx.step({
name: "workbench-refresh-derived",
timeout: 5 * 60_000,
run: async () => refreshWorkbenchDerivedState(loopCtx),
});
await msg.complete({ ok: true });
},
"task.command.workbench.refresh_session_transcript": async (loopCtx, msg) => {
await loopCtx.step({
name: "workbench-refresh-session-transcript",
timeout: 60_000,
run: async () => refreshWorkbenchSessionTranscript(loopCtx, msg.body.sessionId),
});
await msg.complete({ ok: true });
},
"task.command.workbench.close_session": async (loopCtx, msg) => {
await loopCtx.step({
name: "workbench-close-session",

View file

@ -8,6 +8,7 @@ import { logActorWarning, resolveErrorMessage } from "../../logging.js";
import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js";
import { TASK_ROW_ID, appendHistory, buildAgentPrompt, collectErrorMessages, resolveErrorDetail, setTaskState } from "./common.js";
import { taskWorkflowQueueName } from "./queue.js";
import { enqueuePendingWorkbenchSessions } from "../workbench.js";
const DEFAULT_INIT_CREATE_SANDBOX_ACTIVITY_TIMEOUT_MS = 180_000;
@ -34,6 +35,13 @@ function debugInit(loopCtx: any, message: string, context?: Record<string, unkno
});
}
async function ensureTaskRuntimeCacheColumns(db: any): Promise<void> {
await db.execute(`ALTER TABLE task_runtime ADD COLUMN git_state_json text`).catch(() => {});
await db.execute(`ALTER TABLE task_runtime ADD COLUMN git_state_updated_at integer`).catch(() => {});
await db.execute(`ALTER TABLE task_runtime ADD COLUMN provision_stage text`).catch(() => {});
await db.execute(`ALTER TABLE task_runtime ADD COLUMN provision_stage_updated_at integer`).catch(() => {});
}
async function withActivityTimeout<T>(timeoutMs: number, label: string, run: () => Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout> | null = null;
try {
@ -60,6 +68,8 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
const initialStatusMessage = loopCtx.state.branchName && loopCtx.state.title ? "provisioning" : "naming";
try {
await ensureTaskRuntimeCacheColumns(db);
await db
.insert(taskTable)
.values({
@ -96,6 +106,10 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
activeSwitchTarget: null,
activeCwd: null,
statusMessage: initialStatusMessage,
gitStateJson: null,
gitStateUpdatedAt: null,
provisionStage: "queued",
provisionStageUpdatedAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
@ -106,6 +120,8 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
activeSwitchTarget: null,
activeCwd: null,
statusMessage: initialStatusMessage,
provisionStage: "queued",
provisionStageUpdatedAt: now,
updatedAt: now,
},
})
@ -118,19 +134,29 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
export async function initEnqueueProvisionActivity(loopCtx: any, body: any): Promise<void> {
await setTaskState(loopCtx, "init_enqueue_provision", "provision queued");
const self = selfTask(loopCtx);
void self
.send(taskWorkflowQueueName("task.command.provision"), body, {
wait: false,
await loopCtx.db
.update(taskRuntime)
.set({
provisionStage: "queued",
provisionStageUpdatedAt: Date.now(),
updatedAt: Date.now(),
})
.catch((error: unknown) => {
logActorWarning("task.init", "background provision command failed", {
workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId,
taskId: loopCtx.state.taskId,
error: resolveErrorMessage(error),
});
.where(eq(taskRuntime.id, TASK_ROW_ID))
.run();
const self = selfTask(loopCtx);
try {
await self.send(taskWorkflowQueueName("task.command.provision"), body, {
wait: false,
});
} catch (error: unknown) {
logActorWarning("task.init", "background provision command failed", {
workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId,
taskId: loopCtx.state.taskId,
error: resolveErrorMessage(error),
});
throw error;
}
}
export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
@ -197,6 +223,8 @@ export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
.update(taskRuntime)
.set({
statusMessage: "provisioning",
provisionStage: "repo_prepared",
provisionStageUpdatedAt: now,
updatedAt: now,
})
.where(eq(taskRuntime.id, TASK_ROW_ID))
@ -222,6 +250,15 @@ export async function initAssertNameActivity(loopCtx: any): Promise<void> {
export async function initCreateSandboxActivity(loopCtx: any, body: any): Promise<any> {
await setTaskState(loopCtx, "init_create_sandbox", "creating sandbox");
await loopCtx.db
.update(taskRuntime)
.set({
provisionStage: "sandbox_allocated",
provisionStageUpdatedAt: Date.now(),
updatedAt: Date.now(),
})
.where(eq(taskRuntime.id, TASK_ROW_ID))
.run();
const { providers } = getActorRuntimeContext();
const providerId = body?.providerId ?? loopCtx.state.providerId;
const provider = providers.get(providerId);
@ -307,6 +344,15 @@ export async function initCreateSandboxActivity(loopCtx: any, body: any): Promis
export async function initEnsureAgentActivity(loopCtx: any, body: any, sandbox: any): Promise<any> {
await setTaskState(loopCtx, "init_ensure_agent", "ensuring sandbox agent");
await loopCtx.db
.update(taskRuntime)
.set({
provisionStage: "agent_installing",
provisionStageUpdatedAt: Date.now(),
updatedAt: Date.now(),
})
.where(eq(taskRuntime.id, TASK_ROW_ID))
.run();
const { providers } = getActorRuntimeContext();
const providerId = body?.providerId ?? loopCtx.state.providerId;
const provider = providers.get(providerId);
@ -318,6 +364,15 @@ export async function initEnsureAgentActivity(loopCtx: any, body: any, sandbox:
export async function initStartSandboxInstanceActivity(loopCtx: any, body: any, sandbox: any, agent: any): Promise<any> {
await setTaskState(loopCtx, "init_start_sandbox_instance", "starting sandbox runtime");
await loopCtx.db
.update(taskRuntime)
.set({
provisionStage: "agent_starting",
provisionStageUpdatedAt: Date.now(),
updatedAt: Date.now(),
})
.where(eq(taskRuntime.id, TASK_ROW_ID))
.run();
try {
const providerId = body?.providerId ?? loopCtx.state.providerId;
const sandboxInstance = await getOrCreateSandboxInstance(loopCtx, loopCtx.state.workspaceId, providerId, sandbox.sandboxId, {
@ -350,6 +405,15 @@ export async function initStartSandboxInstanceActivity(loopCtx: any, body: any,
export async function initCreateSessionActivity(loopCtx: any, body: any, sandbox: any, sandboxInstanceReady: any): Promise<any> {
await setTaskState(loopCtx, "init_create_session", "creating agent session");
await loopCtx.db
.update(taskRuntime)
.set({
provisionStage: "session_creating",
provisionStageUpdatedAt: Date.now(),
updatedAt: Date.now(),
})
.where(eq(taskRuntime.id, TASK_ROW_ID))
.run();
if (!sandboxInstanceReady.ok) {
return {
id: null,
@ -481,6 +545,8 @@ export async function initWriteDbActivity(
activeSwitchTarget: sandbox.switchTarget,
activeCwd,
statusMessage,
provisionStage: sessionHealthy ? "ready" : "error",
provisionStageUpdatedAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
@ -491,6 +557,8 @@ export async function initWriteDbActivity(
activeSwitchTarget: sandbox.switchTarget,
activeCwd,
statusMessage,
provisionStage: sessionHealthy ? "ready" : "error",
provisionStageUpdatedAt: now,
updatedAt: now,
},
})
@ -535,6 +603,12 @@ export async function initCompleteActivity(loopCtx: any, body: any, sandbox: any
});
loopCtx.state.initialized = true;
await enqueuePendingWorkbenchSessions(loopCtx);
const self = selfTask(loopCtx);
await self.send(taskWorkflowQueueName("task.command.workbench.refresh_derived"), {}, { wait: false });
if (sessionId) {
await self.send(taskWorkflowQueueName("task.command.workbench.refresh_session_transcript"), { sessionId }, { wait: false });
}
return;
}
@ -591,6 +665,8 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
activeSwitchTarget: null,
activeCwd: null,
statusMessage: detail,
provisionStage: "error",
provisionStageUpdatedAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
@ -601,6 +677,8 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
activeSwitchTarget: null,
activeCwd: null,
statusMessage: detail,
provisionStage: "error",
provisionStageUpdatedAt: now,
updatedAt: now,
},
})

View file

@ -13,6 +13,7 @@ export const TASK_QUEUE_NAMES = [
"task.command.workbench.rename_task",
"task.command.workbench.rename_branch",
"task.command.workbench.create_session",
"task.command.workbench.ensure_session",
"task.command.workbench.rename_session",
"task.command.workbench.set_session_unread",
"task.command.workbench.update_draft",
@ -20,6 +21,8 @@ export const TASK_QUEUE_NAMES = [
"task.command.workbench.send_message",
"task.command.workbench.stop_session",
"task.command.workbench.sync_session_status",
"task.command.workbench.refresh_derived",
"task.command.workbench.refresh_session_transcript",
"task.command.workbench.close_session",
"task.command.workbench.publish_pr",
"task.command.workbench.revert_file",