Skip to content
Merged
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ passed through verbatim as prompt text.
| `handoff` | Create a cloud session at app.devin.ai with repo+branch+diff context | `--wait`, `--background`, `--context`, `--tag`, `--poll-interval-ms` (both poll to terminal — detach in your own agent loop instead) |
| `status` | List jobs; `status <id>` for one; `--wait` polls to terminal | `--json`, `--timeout-ms` |
| `result` | Print a finished job's stored output verbatim | `--json` |
| `cancel` | Kill a running job's process tree (or stop a cloud session) | — |
| `cancel` | Kill a running job's process tree (or terminate a cloud session; the job is `cancelled` only once the stop is confirmed, otherwise `cancel_pending`, which stays active and can be retried) | none |

Every command accepts `--json` for machine-readable output.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ devin plugins install TheOnlyFusionCube/devin-plugin-cc#plugins/devin
| `/devin:handoff <task>` | Create a cloud Devin session carrying repo, branch, context, and a bounded uncommitted diff. `--wait` polls until terminal. |
| `/devin:status [job-id]` | List local jobs and cloud handoffs, with live phase/elapsed info. |
| `/devin:result [job-id]` | Show the complete stored output of a finished job. |
| `/devin:cancel <job-id>` | Kill a running local job's process tree; for cloud handoffs also asks the session to stop. |
| `/devin:cancel <job-id>` | Kill a running local job's process tree; for cloud handoffs also terminates the remote session and confirms it with a follow-up read. If the stop cannot be confirmed (missing key, API error) the job stays `cancel_pending`, still shows as active in `/devin:status`, and can be retried. |

Review commands are strictly read-only: they never apply fixes. They run inside Devin's OS-level sandbox and fail closed: if the sandbox cannot start, the job fails with platform-specific guidance and is never retried without it. macOS (Seatbelt) works out of the box, Linux needs `bubblewrap` (`bwrap`) and `socat`, and native Windows is not supported (use WSL2). See the [Devin sandbox docs](https://docs.devin.ai/cli/sandbox). Arguments are parsed POSIX-style: flags first, then free text passed through verbatim.

Expand Down
77 changes: 62 additions & 15 deletions plugins/devin/scripts/devin-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ import {
createCloudSession,
getCloudConfig,
getCloudSession,
isCloudSessionStopped,
isTerminalCloudStatus,
pollCloudSession,
sendCloudMessage
terminateCloudSession
} from "./lib/cloud.mjs";
import {
buildDevinPrintArgs,
Expand Down Expand Up @@ -525,7 +526,13 @@ async function refreshCloudJob(cwd, job) {
pullRequestUrl: snapshot.pullRequestUrl,
sessionUrl: snapshot.url ?? job.sessionUrl
};
if (verdict.terminal) {
if (job.status === "cancel_pending") {
// Only an observed-stopped session settles a pending cancel; a session that
// is merely waiting_for_user or finished may still be alive.
if (isCloudSessionStopped(snapshot)) {
Object.assign(patch, { status: "cancelled", phase: "cancelled", completedAt: nowIso(), errorMessage: null });
}
} else if (verdict.terminal) {
patch.status = verdict.ok ? "completed" : "failed";
patch.completedAt = nowIso();
}
Expand Down Expand Up @@ -647,6 +654,51 @@ async function cmdResult(cwd, argv) {
outputResult({ jobId: job.id, kind: job.kind, status: job.status, rendered, result: job.result ?? null }, rendered, options.json);
}

function persistJobPatch(cwd, patch) {
upsertJob(cwd, patch);
const jobFile = resolveJobFile(cwd, patch.id);
if (fs.existsSync(jobFile)) {
writeJobFile(cwd, patch.id, { ...readJobFile(jobFile), ...patch });
}
}

// Cloud handoffs only become "cancelled" once the remote session is confirmed
// stopped. cancel_pending is written first, so a crash or failure anywhere below
// leaves the job active and refreshable instead of hiding a billing session.
async function cancelCloudHandoff(cwd, job, asJson) {
persistJobPatch(cwd, {
id: job.id,
status: "cancel_pending",
phase: "cancel_pending",
pid: null,
errorMessage: "Cancel requested; the remote stop is not confirmed yet."
});

let delivered = false;
if (job.pid) {
delivered = terminateProcessTree(job.pid).delivered;
}

const outcome = await terminateCloudSession(job.cloudSessionId, getCloudConfig());
const patch = outcome.confirmed
? { id: job.id, status: "cancelled", phase: "cancelled", errorMessage: null, completedAt: nowIso() }
: { id: job.id, status: "cancel_pending", phase: "cancel_pending", errorMessage: outcome.diagnostic };
persistJobPatch(cwd, patch);

outputResult(
{
jobId: job.id,
status: patch.status,
delivered,
remoteStopped: outcome.confirmed,
remoteStatus: outcome.status,
errorMessage: patch.errorMessage
},
renderCancelReport({ job, delivered, remoteStopped: outcome.confirmed, status: patch.status, diagnostic: outcome.diagnostic }),
asJson
);
}

async function cmdCancel(cwd, argv) {
const { options, positionals } = parseCommandInput(argv, { booleanOptions: ["json"] });
const jobId = positionals[0] ?? null;
Expand All @@ -659,30 +711,25 @@ async function cmdCancel(cwd, argv) {
return;
}

if (job.kind === "handoff" && job.cloudSessionId) {
await cancelCloudHandoff(cwd, job, options.json);
return;
}

// Mark cancelled BEFORE killing: the running companion's close handler
// re-reads the job file after its child dies and must see "cancelled" so it
// doesn't overwrite the outcome with "failed".
const completedAt = nowIso();
const patch = { id: job.id, status: "cancelled", phase: "cancelled", pid: null, completedAt };
upsertJob(cwd, patch);
const jobFile = resolveJobFile(cwd, job.id);
if (fs.existsSync(jobFile)) {
writeJobFile(cwd, job.id, { ...readJobFile(jobFile), ...patch });
}
persistJobPatch(cwd, { id: job.id, status: "cancelled", phase: "cancelled", pid: null, completedAt: nowIso() });

let delivered = false;
if (job.pid) {
const outcome = terminateProcessTree(job.pid);
delivered = outcome.delivered;
}

if (job.kind === "handoff" && job.cloudSessionId && getCloudConfig().available) {
await sendCloudMessage(job.cloudSessionId, "Please stop working on this task — it was cancelled by the user.").catch(() => null);
}

outputResult(
{ jobId: job.id, status: "cancelled", delivered },
renderCancelReport({ job, delivered }),
{ jobId: job.id, status: "cancelled", delivered, remoteStopped: null },
renderCancelReport({ job, delivered, remoteStopped: null }),
options.json
);
}
Expand Down
68 changes: 67 additions & 1 deletion plugins/devin/scripts/lib/cloud.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,8 @@ export async function getCloudSession(sessionId, config = getCloudConfig()) {
statusDetail: body?.status_detail ?? null,
title: body?.title ?? null,
url: body?.url ?? null,
pullRequestUrl: body?.pull_request?.url ?? body?.pull_requests?.[0]?.url ?? null,
// v1 returns pull_request: {url}; v3 returns pull_requests: [{pr_url, pr_state}].
pullRequestUrl: body?.pull_request?.url ?? body?.pull_requests?.[0]?.pr_url ?? body?.pull_requests?.[0]?.url ?? null,
structuredOutput: body?.structured_output ?? null,
raw: body
};
Expand All @@ -189,6 +190,71 @@ export async function sendCloudMessage(sessionId, message, config = getCloudConf
return apiRequest(config, "POST", `${base}/sessions/${sessionId}/messages`, { message });
}

// Terminates the remote session instead of only asking it to stop. Both API
// versions use the same relative path: DELETE /v1/sessions/{session_id} for
// personal keys, DELETE /v3/organizations/{org_id}/sessions/{devin_id} for
// service keys, so resolveApiBase picks the right base for either.
export async function stopCloudSession(sessionId, config = getCloudConfig()) {
if (!config.available) {
throw new Error("DEVIN_API_KEY is not set.");
}
const base = resolveApiBase(config);
return apiRequest(config, "DELETE", `${base}/sessions/${sessionId}`);
}

// The session itself no longer runs. Stricter than isTerminalCloudStatus, which
// also counts waiting_for_user and finished: those sessions are still alive.
export function isCloudSessionStopped({ status }) {
return status === "exit" || status === "error" || status === "suspended";
}

function sanitizeDiagnostic(error, config) {
let text = (error instanceof Error ? error.message : String(error)).replace(/\s+/g, " ").trim();
if (config.apiKey) {
text = text.split(config.apiKey).join("[redacted]");
}
return text.length > 300 ? `${text.slice(0, 299)}…` : text;
}

// Stops the remote session and reports whether the stop is CONFIRMED. The DELETE
// response is never trusted on its own: v1 returns only {detail}, and v3 returns a
// SessionResponse whose status is not documented as the post-termination state
// (status_detail is only populated on GET). So after the DELETE, even a failed one
// (v1 rejects DELETE on an already-exited session), we GET the session and confirm
// only when its status is exit, error, or suspended. Never throws; the diagnostic
// never contains the API key or the DEVIN_API_URL value.
export async function terminateCloudSession(sessionId, config = getCloudConfig()) {
if (!config.available) {
return { confirmed: false, status: null, diagnostic: "DEVIN_API_KEY is not set; it is needed to stop the remote session." };
}
let deleteError = null;
try {
await stopCloudSession(sessionId, config);
} catch (error) {
deleteError = sanitizeDiagnostic(error, config);
}
let status = null;
let readError = null;
try {
const snapshot = await getCloudSession(sessionId, config);
status = snapshot.status;
if (isCloudSessionStopped(snapshot)) {
return { confirmed: true, status, diagnostic: null };
}
} catch (error) {
readError = sanitizeDiagnostic(error, config);
}
const parts = [deleteError ? `Stop request failed: ${deleteError}` : "Stop request was accepted."];
if (readError) {
if (readError !== deleteError) {
parts.push(`Could not read the session to confirm: ${readError}`);
}
} else {
parts.push(`The session is still reported as "${status}".`);
}
return { confirmed: false, status, diagnostic: parts.join(" ") };
}

export function isTerminalCloudStatus({ status, statusDetail }) {
if (status === "error" || status === "suspended") {
return { terminal: true, ok: false };
Expand Down
9 changes: 8 additions & 1 deletion plugins/devin/scripts/lib/jobs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,15 @@ export function sortJobsNewestFirst(jobs) {
});
}

// cancel_pending: a cancel was requested but the remote cloud session is not
// confirmed stopped, so it stays active and refreshable until it is.
export function isJobActive(job) {
return job?.status === "queued" || job?.status === "running" || job?.status === "detached";
return (
job?.status === "queued" ||
job?.status === "running" ||
job?.status === "detached" ||
job?.status === "cancel_pending"
);
}

export function elapsedMs(job) {
Expand Down
22 changes: 20 additions & 2 deletions plugins/devin/scripts/lib/render.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -128,15 +128,33 @@ export function renderStoredJobResult(job) {
return `Job ${job?.id ?? "?"} has no stored output yet (status: ${job?.status ?? "unknown"}).\n`;
}

export function renderCancelReport({ job, delivered }) {
export function renderCancelReport({ job, delivered, remoteStopped = null, status = "cancelled", diagnostic = null }) {
const lines = [];
if (status === "cancel_pending") {
lines.push(`Job ${job.id} (${job.kind}) is NOT cancelled: the cloud session was not confirmed stopped and may still be running.`);
if (delivered) {
lines.push("The local poller was stopped.");
}
if (job.sessionUrl) {
lines.push(`Cloud session: ${job.sessionUrl}`);
}
if (diagnostic) {
lines.push(`Reason: ${diagnostic}`);
}
lines.push(`Retry with /devin:cancel ${job.id}, re-check with /devin:status ${job.id}, or stop it in the Devin web app.`);
return `${lines.join("\n")}\n`;
}
if (delivered) {
lines.push(`Cancellation requested for job ${job.id} (${job.kind}).`);
} else {
lines.push(`Job ${job.id} (${job.kind}) was already finished or its process is gone; marked cancelled.`);
}
if (job.sessionUrl) {
lines.push(`Cloud session: ${job.sessionUrl} — stop or message it from the Devin web app if it is still running.`);
lines.push(
remoteStopped
? `Cloud session: ${job.sessionUrl} (stopped remotely, confirmed).`
: `Cloud session: ${job.sessionUrl} (could not confirm the remote stop; check the Devin web app).`
);
}
return `${lines.join("\n")}\n`;
}
Expand Down
Loading
Loading