Skip to content

Commit 31def17

Browse files
authored
fix: Retry Clean Cancelled BYOM Settlements Through Stop Grace (#188)
A clean atomic workspace mutation rejection was settled with retries cut off at the original execution deadline. When Stop arrived near that deadline, process-tree termination finished after it, so the first settlement attempt was aborted immediately while Code API was still draining the cancellation. The client received ASSIGNMENT_EXPIRED and the durable mutation guard stayed armed. Route clean workspace mutation rejections through the known-clean rejection recovery path: a transient-retrying heartbeat and settlement retries through the rejection acknowledgement grace, floored at the bridge cancellation settlement grace. Worker shutdown still fails closed. Share the grace constant from the protocol module so the bridge and worker stay aligned. Closes #173
1 parent 8a90f6d commit 31def17

4 files changed

Lines changed: 227 additions & 7 deletions

File tree

‎packages/code/src/protocol.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export const BRIDGE_WORKSPACE_COMMAND_MAX_TIMEOUT_MS = 5 * 60_000;
2020
export const BRIDGE_WORKSPACE_COMMAND_DEFAULT_OUTPUT_BYTES = 256 * 1024;
2121
export const BRIDGE_WORKSPACE_COMMAND_MAX_OUTPUT_BYTES = 1024 * 1024;
2222
export const BRIDGE_WORKSPACE_COMMAND_SIGNAL_MAX_LENGTH = 32;
23+
/** How long Code API drains a clean rejection after Stop cancels a workspace mutation. */
24+
export const BRIDGE_CANCELLED_WORKSPACE_SETTLEMENT_GRACE_MS = 5_000;
2325

2426
export type BridgeProtocolVersion = typeof BRIDGE_PROTOCOL_VERSION;
2527

‎packages/code/src/worker.ts‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { randomBytes } from 'node:crypto';
22

33
import {
4+
BRIDGE_CANCELLED_WORKSPACE_SETTLEMENT_GRACE_MS,
45
BRIDGE_PROTOCOL_VERSION,
56
BridgeProtocolError,
67
bridgeWorkerPath,
@@ -1619,23 +1620,38 @@ export class BridgeWorker {
16191620
assignment.runtimeSessionId != null &&
16201621
settlement.status === 'rejected' &&
16211622
(!sandboxStarted || sandboxRejectedExecution);
1622-
if (knownCleanStatefulRejection) {
1623+
// An armed mutation reaches settlement as rejected only after an atomic
1624+
// failure that does not require quarantine. Code API accepts that
1625+
// rejection after expiry and drains it for its own grace after Stop, so a
1626+
// Stop near the deadline must not cut off retries at the deadline.
1627+
const knownCleanWorkspaceRejection =
1628+
workspaceMutationArmed && settlement.status === 'rejected';
1629+
if (knownCleanStatefulRejection || knownCleanWorkspaceRejection) {
16231630
heartbeatController.abort();
16241631
await heartbeat;
16251632
const recoveryHeartbeatController = new AbortController();
16261633
const recoveryHeartbeat = this.maintainRegistration(
16271634
recoveryHeartbeatController.signal,
16281635
true,
16291636
).catch(() => undefined);
1637+
const rejectionAckGraceMs = Math.max(
1638+
0,
1639+
this.options.rejectionAckGraceMs ?? REJECTION_ACK_GRACE_MS,
1640+
);
16301641
try {
16311642
await this.settleWithRetry(
16321643
assignment,
16331644
settlement,
16341645
localDeadlineAtMs +
1635-
Math.max(
1636-
0,
1637-
this.options.rejectionAckGraceMs ?? REJECTION_ACK_GRACE_MS,
1638-
),
1646+
(knownCleanStatefulRejection
1647+
? rejectionAckGraceMs
1648+
: Math.max(
1649+
rejectionAckGraceMs,
1650+
BRIDGE_CANCELLED_WORKSPACE_SETTLEMENT_GRACE_MS,
1651+
)),
1652+
// Stateful rejections outlive shutdown; workspace guards still
1653+
// fail closed when the worker itself stops.
1654+
knownCleanStatefulRejection ? undefined : signal,
16391655
);
16401656
} finally {
16411657
recoveryHeartbeatController.abort();

‎packages/code/src/workspace-worker.test.ts‎

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1480,6 +1480,208 @@ test('worker clears quarantine after a command cancellation confirms process ter
14801480
assert.deepEqual(lifecycle, ['arm', 'execute', 'settle', 'clear']);
14811481
});
14821482

1483+
test('worker retries a clean Stop rejection near its deadline through the cancellation grace', async () => {
1484+
const lifecycle: string[] = [];
1485+
const settlements: Array<Record<string, unknown>> = [];
1486+
const remainingMs = 100;
1487+
const startedAt = Date.now();
1488+
const baseCapabilities = {
1489+
protocolVersion: 1 as const,
1490+
operations: ['read_file' as const],
1491+
workspaces: [{ id: 'primary', operations: ['read_file' as const] }],
1492+
};
1493+
const workspaceTools = new SandboxWorkspaceTools({
1494+
workspaceTools: {
1495+
capabilities: baseCapabilities,
1496+
mutationFailuresAreAtomic: true,
1497+
async execute() { throw new Error('base executor must not run'); },
1498+
},
1499+
commandWorkspaces: ['primary'],
1500+
commandSandbox: {
1501+
mutationFailuresAreAtomic: true,
1502+
async execute(_request, signal) {
1503+
lifecycle.push('execute');
1504+
await new Promise<void>((resolve) => {
1505+
if (signal?.aborted) return resolve();
1506+
signal?.addEventListener('abort', () => resolve(), { once: true });
1507+
});
1508+
lifecycle.push('stop');
1509+
// Process-group termination is confirmed after the original deadline.
1510+
await new Promise((resolve) => setTimeout(resolve, remainingMs));
1511+
throw new WorkspaceToolError(
1512+
'Workspace command execution aborted',
1513+
'EXECUTION_ABORTED',
1514+
true,
1515+
false,
1516+
);
1517+
},
1518+
},
1519+
});
1520+
const worker = new BridgeWorker({
1521+
codeApiUrl: 'https://code.example/v1',
1522+
token: 'worker-secret',
1523+
workerId: 'vm-1',
1524+
incarnationId,
1525+
sandboxEndpoint: 'http://127.0.0.1:2000/api/v2',
1526+
capabilities: {
1527+
statefulWorkspace: true,
1528+
sandboxProfile: 'nsjail',
1529+
runtimes: ['bash'],
1530+
workspaceTools: workspaceTools.capabilities,
1531+
},
1532+
workspaceTools,
1533+
workspaceMutationQuarantine: mutationQuarantine(
1534+
() => lifecycle.push('quarantine'),
1535+
() => lifecycle.push('arm'),
1536+
() => lifecycle.push('clear'),
1537+
),
1538+
cancellationPollIntervalMs: 5,
1539+
fetchImpl: async (input, init) => {
1540+
if (String(input).endsWith('/cancellation')) {
1541+
return Response.json({
1542+
protocolVersion: 1,
1543+
cancelled: Date.now() >= startedAt + remainingMs / 2,
1544+
});
1545+
}
1546+
if (!String(input).endsWith('/settle')) {
1547+
return Response.json({ protocolVersion: 1, accepted: true });
1548+
}
1549+
lifecycle.push('settle');
1550+
settlements.push({
1551+
...(JSON.parse(String(init?.body)) as Record<string, unknown>),
1552+
attemptedAt: Date.now(),
1553+
});
1554+
// Settlement delivery takes a real transport turn and honors its deadline.
1555+
await new Promise<void>((resolve, reject) => {
1556+
const timer = setTimeout(resolve, 10);
1557+
init?.signal?.addEventListener(
1558+
'abort',
1559+
() => {
1560+
clearTimeout(timer);
1561+
reject(new DOMException('aborted', 'AbortError'));
1562+
},
1563+
{ once: true },
1564+
);
1565+
});
1566+
if (settlements.length === 1) {
1567+
return Response.json(
1568+
{ error: 'Bridge settlement temporarily unavailable' },
1569+
{ status: 503 },
1570+
);
1571+
}
1572+
return Response.json({ protocolVersion: 1, accepted: true });
1573+
},
1574+
});
1575+
1576+
await worker.executeAndSettle({
1577+
protocolVersion: 1,
1578+
assignmentId: 'assignment-command-stopped-near-deadline',
1579+
workerId: 'vm-1',
1580+
incarnationId,
1581+
generation: 4,
1582+
leaseToken: 'lease-token-that-is-long-enough-for-testing',
1583+
expiresAt: new Date(startedAt + remainingMs).toISOString(),
1584+
remainingMs,
1585+
executionKind: 'workspace_tool',
1586+
request: {
1587+
protocolVersion: 1,
1588+
operation: 'execute_command',
1589+
workspaceId: 'primary',
1590+
command: 'sleep 30; touch delayed.txt',
1591+
},
1592+
});
1593+
1594+
assert.deepEqual(lifecycle, [
1595+
'arm',
1596+
'execute',
1597+
'stop',
1598+
'settle',
1599+
'settle',
1600+
'clear',
1601+
]);
1602+
assert.ok(Number(settlements[0]?.attemptedAt) > startedAt + remainingMs);
1603+
assert.equal(settlements[1]?.status, 'rejected');
1604+
assert.equal(settlements[1]?.errorCode, 'EXECUTION_ABORTED');
1605+
});
1606+
1607+
test('worker keeps quarantine armed when shutdown interrupts a clean command rejection', async () => {
1608+
const lifecycle: string[] = [];
1609+
const controller = new AbortController();
1610+
const baseCapabilities = {
1611+
protocolVersion: 1 as const,
1612+
operations: ['read_file' as const],
1613+
workspaces: [{ id: 'primary', operations: ['read_file' as const] }],
1614+
};
1615+
const workspaceTools = new SandboxWorkspaceTools({
1616+
workspaceTools: {
1617+
capabilities: baseCapabilities,
1618+
mutationFailuresAreAtomic: true,
1619+
async execute() { throw new Error('base executor must not run'); },
1620+
},
1621+
commandWorkspaces: ['primary'],
1622+
commandSandbox: {
1623+
mutationFailuresAreAtomic: true,
1624+
async execute() {
1625+
lifecycle.push('execute');
1626+
controller.abort(new Error('shutdown'));
1627+
throw new WorkspaceToolError(
1628+
'Workspace command execution aborted',
1629+
'EXECUTION_ABORTED',
1630+
true,
1631+
false,
1632+
);
1633+
},
1634+
},
1635+
});
1636+
const worker = new BridgeWorker({
1637+
codeApiUrl: 'https://code.example/v1',
1638+
token: 'worker-secret',
1639+
workerId: 'vm-1',
1640+
incarnationId,
1641+
sandboxEndpoint: 'http://127.0.0.1:2000/api/v2',
1642+
capabilities: {
1643+
statefulWorkspace: true,
1644+
sandboxProfile: 'nsjail',
1645+
runtimes: ['bash'],
1646+
workspaceTools: workspaceTools.capabilities,
1647+
},
1648+
workspaceTools,
1649+
workspaceMutationQuarantine: mutationQuarantine(
1650+
() => lifecycle.push('quarantine'),
1651+
() => lifecycle.push('arm'),
1652+
() => lifecycle.push('clear'),
1653+
),
1654+
fetchImpl: async () => {
1655+
lifecycle.push('settle');
1656+
return Response.json({ protocolVersion: 1, accepted: true });
1657+
},
1658+
});
1659+
1660+
await assert.rejects(
1661+
worker.executeAndSettle(
1662+
{
1663+
protocolVersion: 1,
1664+
assignmentId: 'assignment-command-shutdown-cleanly',
1665+
workerId: 'vm-1',
1666+
incarnationId,
1667+
generation: 4,
1668+
leaseToken: 'lease-token-that-is-long-enough-for-testing',
1669+
expiresAt: new Date(Date.now() + 5_000).toISOString(),
1670+
executionKind: 'workspace_tool',
1671+
request: {
1672+
protocolVersion: 1,
1673+
operation: 'execute_command',
1674+
workspaceId: 'primary',
1675+
command: 'sleep 30',
1676+
},
1677+
},
1678+
controller.signal,
1679+
),
1680+
/shutdown/,
1681+
);
1682+
assert.deepEqual(lifecycle, ['arm', 'execute']);
1683+
});
1684+
14831685
test('worker retains quarantine when an atomic executor cannot confirm durability', async () => {
14841686
const lifecycle: string[] = [];
14851687
const workspaceCapabilities = {

‎service/src/bridge/store.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
} from '../../../packages/code/src/protocol';
1212

1313
import {
14+
BRIDGE_CANCELLED_WORKSPACE_SETTLEMENT_GRACE_MS,
1415
BRIDGE_PROTOCOL_VERSION,
1516
isValidBridgeWorkerCapabilities,
1617
isValidBridgeWorkerId,
@@ -23,7 +24,6 @@ import { BridgeWorkspaceSlots } from './slots';
2324

2425
const PREFIX = 'codeapi:bridge:v1';
2526
const POLL_INTERVAL_MS = 100;
26-
const CANCELLED_WORKSPACE_SETTLEMENT_GRACE_MS = 5_000;
2727
const DEFAULT_WORKER_TTL_SECONDS = 60;
2828
const DEFAULT_REDIS_COMMAND_TIMEOUT_MS = 1_000;
2929

@@ -1924,7 +1924,7 @@ export class RedisBridgeStore {
19241924
// Give Stop its own grace so a near-timeout cancellation is not
19251925
// misclassified as an ambiguous timeout.
19261926
const cancellationDeadlineAtMs =
1927-
Date.now() + CANCELLED_WORKSPACE_SETTLEMENT_GRACE_MS;
1927+
Date.now() + BRIDGE_CANCELLED_WORKSPACE_SETTLEMENT_GRACE_MS;
19281928
let cancellationPollMs = POLL_INTERVAL_MS;
19291929
while (Date.now() < cancellationDeadlineAtMs) {
19301930
const raw = await boundedCommand(

0 commit comments

Comments
 (0)