Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/api/src/handlers/tasks/__tests__/task-stop.test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions apps/api/src/handlers/tasks/cancelTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
isExitedRunStatus,
} from '@roomote/types';
import { captureTaskSettled } from '@roomote/telemetry/server';
import { destroyCanceledTaskRunSandbox } from '@roomote/sdk/server';

import type { Variables } from '../../types';
import type { McpAuth } from '../mcp/middleware';
Expand Down Expand Up @@ -88,6 +89,14 @@ export async function cancelTask(

if (canceledRun) {
void captureTaskSettled(canceledRun.id, 'canceled');

// This cancel writes the terminal state directly (it never reaches
// finishRun), so tear down any attached sandbox here or it keeps
// running against the provider's capacity until its TTL.
await destroyCanceledTaskRunSandbox({
runId: canceledRun.id,
logPrefix: 'cancelTask',
});
}

return c.json({ success: true });
Expand Down
13 changes: 12 additions & 1 deletion apps/api/src/handlers/tasks/task-stop.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { TRPCClientError } from '@trpc/client';
import { withSandboxServerRpcClient } from '@roomote/sdk/server';
import {
destroyCanceledTaskRunSandbox,
withSandboxServerRpcClient,
} from '@roomote/sdk/server';
import {
and,
cancelTaskRunDirect,
Expand Down Expand Up @@ -73,6 +76,14 @@ async function cancelTaskRunBeforeSandbox(runId: number): Promise<boolean> {
const canceled = await cancelTaskRunDirect({ runId });
if (canceled) {
void captureTaskSettled(runId, 'canceled');

// Usually a no-op (pre-sandbox runs have no machine), but a spawn racing
// this cancel may already have stamped machineId without a reachable
// sandbox server — destroy it rather than leaking it until provider TTL.
await destroyCanceledTaskRunSandbox({
runId,
logPrefix: 'stopTaskRun',
});
}
return canceled;
}
Expand Down
73 changes: 73 additions & 0 deletions apps/bullmq/src/scheduled-jobs/__tests__/sleep-check.test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions apps/bullmq/src/scheduled-jobs/sleep-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
type ComputeProviderClient,
} from '@roomote/compute-providers';
import {
claimMachineDestroy,
createSnapshot,
finishRun,
refreshTaskTitleOnCompletion,
Expand Down Expand Up @@ -1432,6 +1433,24 @@ async function destroyInstanceWithAudit(
);
}

// Serialize with the cancel-finalization teardown path: both destroyers
// record their final usage row only after the provider call returns, so the
// redis claim is the only atomic arbiter for a live race on this machine.
// The lease renews until settled below, so a slow provider delete cannot
// outlive it.
const claim = await claimMachineDestroy({
provider: job.vendor ?? 'docker',
machineId: job.machineId!,
owner: logPrefix,
});

if (claim.outcome === 'held') {
console.log(
`[${logPrefix}] Skipping destroyInstance for ${job.machineId}: another destroyer holds the teardown claim`,
);
return;
}

const recordMutation = createComputeProviderMutationEventRecorder(
db,
{
Expand All @@ -1453,6 +1472,10 @@ async function destroyInstanceWithAudit(
try {
const result = await client.destroyInstance({ instanceId: job.machineId! });

// Success: stop renewing and let the claim expire naturally — the
// residual TTL keeps guarding against a duplicate delete.
claim.finish();

await tryRecordComputeProviderUsage({
runId: job.id,
lifecycleAction: 'destroy',
Expand All @@ -1465,6 +1488,9 @@ async function destroyInstanceWithAudit(
logPrefix,
});
} catch (error) {
// Give the claim back (token-conditional, so a successor that took over
// after a lapsed lease is unaffected) so teardown can be retried.
await claim.release();
await recordMutation({
provider: job.vendor ?? 'docker',
operation: 'destroy_instance',
Expand Down
43 changes: 42 additions & 1 deletion apps/controller/src/BaseController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import {
sql,
} from '@roomote/db/server';
import { dequeueTaskRun } from '@roomote/cloud-agents/server';
import { finishRun } from '@roomote/sdk/server';
import { destroyCanceledTaskRunSandbox, finishRun } from '@roomote/sdk/server';

import { getOrphanedTaskRun } from './orphaned-task-runs';
import {
Expand Down Expand Up @@ -399,11 +399,52 @@ export abstract class BaseController {
sandboxTimeoutMs,
provider,
);

// A cancel that lands mid-provision beats the machine stamp: the run row
// is already terminal, so no later finalize path will tear the fresh
// sandbox down. Re-check once the spawn settled and destroy if so.
await this.destroySandboxIfCanceledDuringSpawn(taskRun.id);
} catch (error) {
await this.handleSpawnTaskRunError(taskRun, error);
}
}

private async destroySandboxIfCanceledDuringSpawn(
runId: number,
): Promise<void> {
try {
const latestRun = await db.query.taskRuns.findFirst({
where: eq(taskRuns.id, runId),
columns: {
status: true,
canceledAt: true,
},
});

if (
!latestRun ||
(!latestRun.canceledAt && latestRun.status !== RunStatus.Canceled)
) {
return;
}

console.warn(
`[BaseController] Task run #${runId} was canceled during spawn; destroying its sandbox`,
);

await destroyCanceledTaskRunSandbox({
runId,
logPrefix: 'BaseController',
});
} catch (error) {
console.warn(
`[BaseController] Failed post-spawn cancel check for task run #${runId}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}

private spawnWorkerInBackground(taskRun: TaskRun): boolean {
if (this.inFlightSpawns.has(taskRun.id)) {
console.warn(
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/lib/task-run-errors.test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions apps/web/src/lib/task-run-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ const DOCKER_RELEASE_ARCHIVE_MISSING =
/Docker worker release archive does not exist|Docker provider requires a local worker release archive/i;
const DOCKER_FETCH_FAILED_IN_LOGS =
/Job\s+<\s*unknown\s*>\s*failed:\s*fetch failed|❌[^\n]*failed:\s*fetch failed/i;
// Roomote Cloud compute broker capacity rejection (code sandbox_limit_reached).
const SANDBOX_LIMIT_REACHED =
/tenant sandbox limit was reached|sandbox_limit_reached/i;
const SANDBOX_LIMIT_REACHED_MESSAGE =
"Roomote couldn't start a new sandbox because this deployment is already running its maximum number of concurrent sandboxes. Wait for an active task to finish, or stop one you no longer need, then try again.";

function parseOpenAiAdminErrorBody(
body: string,
Expand Down Expand Up @@ -222,6 +227,10 @@ export function getTaskRunErrorDisplayMessage(
return workspacePreparationMessage;
}

if (SANDBOX_LIMIT_REACHED.test(stripped)) {
return SANDBOX_LIMIT_REACHED_MESSAGE;
}

// The persisted category is authoritative; text inference covers runs
// that failed before error codes existed.
const dockerFriendly =
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/trpc/commands/task-runs/index.test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions apps/web/src/trpc/commands/task-runs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
tasks,
} from '@roomote/db/server';
import { SlackNotifier } from '@roomote/slack';
import { destroyCanceledTaskRunSandbox } from '@roomote/sdk/server';

import type { UserAuthSuccess } from '@/types';
import { Env, getArtifactById, getRepositories } from '@/lib/server';
Expand Down Expand Up @@ -494,6 +495,14 @@ export async function cancelTaskRunCommand(

if (canceledRun) {
void captureTaskSettled(canceledRun.id, 'canceled');

// This cancel writes the terminal state directly (it never reaches
// finishRun), so tear down any attached sandbox here or it keeps
// running against the provider's capacity until its TTL.
await destroyCanceledTaskRunSandbox({
runId: canceledRun.id,
logPrefix: 'cancelTaskRunCommand',
});
}
}

Expand Down
Loading
Loading