Skip to content

Commit 76dba71

Browse files
authored
Harden assign_to_agent concurrency: isolate handler state and serialize MCP stdin dispatch (#52034)
1 parent b258efc commit 76dba71

6 files changed

Lines changed: 319 additions & 127 deletions

File tree

.changeset/patch-fix-assign-to-agent-concurrency.md

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

actions/setup/js/assign_to_agent.cjs

Lines changed: 146 additions & 117 deletions
Large diffs are not rendered by default.

actions/setup/js/assign_to_agent.test.cjs

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,10 @@ describe("assign_to_agent", () => {
8888
const _tempIdMap = loadTemporaryIdMap();
8989
for (const _item of _items) { await _handler(_item, {}, _tempIdMap); }
9090
}
91-
await writeAssignToAgentSummary();
92-
const _errorCount = getAssignToAgentErrorCount();
93-
core.setOutput("assigned", getAssignToAgentAssigned());
94-
core.setOutput("assignment_errors", getAssignToAgentErrors());
91+
await writeAssignToAgentSummary(_handler);
92+
const _errorCount = getAssignToAgentErrorCount(_handler);
93+
core.setOutput("assigned", getAssignToAgentAssigned(_handler));
94+
core.setOutput("assignment_errors", getAssignToAgentErrors(_handler));
9595
core.setOutput("assignment_error_count", String(_errorCount));
9696
if (_errorCount > 0) { core.setFailed("Failed to assign " + _errorCount + " agent(s)"); }
9797
`;
@@ -1359,6 +1359,88 @@ describe("assign_to_agent", () => {
13591359
expect(mockSleep).toHaveBeenCalledWith(10000);
13601360
});
13611361

1362+
it("does not consume a max slot for invalid items", async () => {
1363+
mockGithub.rest.issues.checkUserCanBeAssigned.mockResolvedValue({});
1364+
mockGithub.rest.users.getByUsername.mockResolvedValue({ data: { id: 99999 } });
1365+
mockGithub.rest.issues.get.mockImplementation(async ({ issue_number }) => ({
1366+
data: { id: Number(issue_number) + 1000, number: issue_number, assignees: [], html_url: "", title: "", body: "" },
1367+
}));
1368+
mockGithub.request.mockResolvedValue({ data: { id: "task-123" } });
1369+
1370+
const result = await eval(`(async () => {
1371+
${assignToAgentScript};
1372+
const _handler = await main({ max: "1", name: "copilot" });
1373+
const _invalid = await _handler({ type: "assign_to_agent", issue_number: 1, pull_number: 2, agent: "copilot" }, {}, new Map());
1374+
const _valid = await _handler({ type: "assign_to_agent", issue_number: 3, agent: "copilot" }, {}, new Map());
1375+
return {
1376+
invalid: _invalid,
1377+
valid: _valid,
1378+
assigned: getAssignToAgentAssigned(_handler),
1379+
};
1380+
})()`);
1381+
1382+
expect(result.invalid.success).toBe(false);
1383+
expect(result.valid.success).toBe(true);
1384+
expect(result.assigned.split("\n").filter(Boolean)).toHaveLength(1);
1385+
});
1386+
1387+
it("atomically reserves the max slot before the inter-assignment delay", async () => {
1388+
mockGithub.rest.issues.checkUserCanBeAssigned.mockResolvedValue({});
1389+
mockGithub.rest.users.getByUsername.mockResolvedValue({ data: { id: 99999 } });
1390+
mockGithub.rest.issues.get.mockImplementation(async ({ issue_number }) => ({
1391+
data: { id: Number(issue_number) + 1000, number: issue_number, assignees: [], html_url: "", title: "", body: "" },
1392+
}));
1393+
mockGithub.request.mockResolvedValue({ data: { id: "task-123" } });
1394+
1395+
let releaseSleep;
1396+
mockSleep.mockImplementationOnce(
1397+
() =>
1398+
new Promise(resolve => {
1399+
releaseSleep = resolve;
1400+
})
1401+
);
1402+
1403+
const result = await eval(`(async () => {
1404+
${assignToAgentScript};
1405+
const _handler = await main({ max: "2", name: "copilot" });
1406+
await _handler({ type: "assign_to_agent", issue_number: 1, agent: "copilot" }, {}, new Map());
1407+
return {
1408+
second: _handler({ type: "assign_to_agent", issue_number: 2, agent: "copilot" }, {}, new Map()),
1409+
third: _handler({ type: "assign_to_agent", issue_number: 3, agent: "copilot" }, {}, new Map()),
1410+
};
1411+
})()`);
1412+
1413+
await vi.waitFor(() => expect(mockSleep).toHaveBeenCalledTimes(1));
1414+
releaseSleep();
1415+
1416+
const [second, third] = await Promise.all([result.second, result.third]);
1417+
expect(second.success).toBe(true);
1418+
expect(third.skipped).toBe(true);
1419+
});
1420+
1421+
it("keeps assign_to_agent results isolated per main() invocation", async () => {
1422+
mockGithub.rest.issues.checkUserCanBeAssigned.mockResolvedValue({});
1423+
mockGithub.rest.users.getByUsername.mockResolvedValue({ data: { id: 99999 } });
1424+
mockGithub.rest.issues.get.mockImplementation(async ({ issue_number }) => ({
1425+
data: { id: Number(issue_number) + 2000, number: issue_number, assignees: [], html_url: "", title: "", body: "" },
1426+
}));
1427+
mockGithub.request.mockResolvedValue({ data: { id: "task-123" } });
1428+
1429+
const result = await eval(`(async () => {
1430+
${assignToAgentScript};
1431+
const _handlerA = await main({ max: "5", name: "copilot" });
1432+
const _handlerB = await main({ max: "5", name: "copilot" });
1433+
await _handlerA({ type: "assign_to_agent", issue_number: 11, agent: "copilot" }, {}, new Map());
1434+
return {
1435+
assignedA: getAssignToAgentAssigned(_handlerA),
1436+
assignedB: getAssignToAgentAssigned(_handlerB),
1437+
};
1438+
})()`);
1439+
1440+
expect(result.assignedA).toContain("issue:11:copilot");
1441+
expect(result.assignedB).toBe("");
1442+
});
1443+
13621444
describe("Cross-repository allowlist validation", () => {
13631445
it("should reject target repository not in allowlist", async () => {
13641446
process.env.GH_AW_ALLOWED_REPOS = "allowed-owner/allowed-repo";

actions/setup/js/mcp_server_core.cjs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1043,9 +1043,15 @@ function start(server, options = {}) {
10431043
throw new Error(`${ERR_VALIDATION}: No tools registered`);
10441044
}
10451045

1046-
const onData = async chunk => {
1046+
let processingChain = Promise.resolve();
1047+
const onData = chunk => {
10471048
server.readBuffer.append(chunk);
1048-
await processReadBuffer(server, defaultHandler);
1049+
processingChain = processingChain
1050+
.then(() => processReadBuffer(server, defaultHandler))
1051+
.catch(error => {
1052+
server.debug(`processReadBuffer error: ${getErrorMessage(error)}`);
1053+
});
1054+
return processingChain;
10491055
};
10501056

10511057
process.stdin.on("data", onData);

actions/setup/js/mcp_server_core.test.cjs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1410,6 +1410,75 @@ echo "result=$INPUT_MY_INPUT" >> "$GITHUB_OUTPUT"
14101410
});
14111411
});
14121412

1413+
describe("start", () => {
1414+
it("serializes pipelined stdin chunks and avoids overlapping tool dispatch", async () => {
1415+
const { createServer, registerTool, start } = await import("./mcp_server_core.cjs");
1416+
const server = createServer({ name: "test-server", version: "1.0.0" });
1417+
1418+
let activeHandlers = 0;
1419+
let maxActiveHandlers = 0;
1420+
let callCount = 0;
1421+
let releaseFirstCall;
1422+
const firstCallGate = new Promise(resolve => {
1423+
releaseFirstCall = resolve;
1424+
});
1425+
1426+
registerTool(server, {
1427+
name: "slow_tool",
1428+
description: "A test tool",
1429+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
1430+
handler: async () => {
1431+
callCount++;
1432+
activeHandlers++;
1433+
maxActiveHandlers = Math.max(maxActiveHandlers, activeHandlers);
1434+
if (callCount === 1) {
1435+
await firstCallGate;
1436+
}
1437+
activeHandlers--;
1438+
return { content: [{ type: "text", text: "ok" }] };
1439+
},
1440+
});
1441+
1442+
// Avoid writing to real stdout/stderr during this test.
1443+
server.writeMessage = () => {};
1444+
server.debug = () => {};
1445+
1446+
let dataHandler;
1447+
const onSpy = vi.spyOn(process.stdin, "on").mockImplementation((event, handler) => {
1448+
if (event === "data") {
1449+
dataHandler = handler;
1450+
}
1451+
return process.stdin;
1452+
});
1453+
const resumeSpy = vi.spyOn(process.stdin, "resume").mockImplementation(() => process.stdin);
1454+
1455+
try {
1456+
start(server);
1457+
expect(typeof dataHandler).toBe("function");
1458+
1459+
const message1 = Buffer.from(`${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "slow_tool", arguments: {} } })}\n`);
1460+
const message2 = Buffer.from(`${JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "slow_tool", arguments: {} } })}\n`);
1461+
1462+
const first = dataHandler(message1);
1463+
const second = dataHandler(message2);
1464+
1465+
// Let queued tasks run before releasing first call.
1466+
await Promise.resolve();
1467+
await Promise.resolve();
1468+
expect(callCount).toBe(1);
1469+
1470+
releaseFirstCall();
1471+
await Promise.all([first, second]);
1472+
1473+
expect(callCount).toBe(2);
1474+
expect(maxActiveHandlers).toBe(1);
1475+
} finally {
1476+
onSpy.mockRestore();
1477+
resumeSpy.mockRestore();
1478+
}
1479+
});
1480+
});
1481+
14131482
describe("findSimilarTools", () => {
14141483
it("should find tools with typos", async () => {
14151484
const { findSimilarTools } = await import("./mcp_server_core.cjs");

actions/setup/js/safe_output_handler_manager.cjs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1730,17 +1730,18 @@ async function main() {
17301730

17311731
// Export assign_to_agent outputs when the handler was loaded
17321732
if (messageHandlers.has("assign_to_agent")) {
1733-
const assignToAgentAssigned = getAssignToAgentAssigned();
1734-
const assignToAgentErrors = getAssignToAgentErrors();
1735-
const assignToAgentErrorCount = getAssignToAgentErrorCount();
1733+
const assignToAgentHandler = messageHandlers.get("assign_to_agent");
1734+
const assignToAgentAssigned = getAssignToAgentAssigned(assignToAgentHandler);
1735+
const assignToAgentErrors = getAssignToAgentErrors(assignToAgentHandler);
1736+
const assignToAgentErrorCount = getAssignToAgentErrorCount(assignToAgentHandler);
17361737
core.setOutput("assign_to_agent_assigned", assignToAgentAssigned);
17371738
core.setOutput("assign_to_agent_assignment_errors", assignToAgentErrors);
17381739
core.setOutput("assign_to_agent_assignment_error_count", assignToAgentErrorCount.toString());
17391740
if (assignToAgentErrorCount > 0) {
17401741
core.warning(`${assignToAgentErrorCount} agent assignment(s) failed`);
17411742
}
17421743
core.info(`Exported assign_to_agent outputs (${assignToAgentErrorCount} error(s))`);
1743-
await writeAssignToAgentSummary();
1744+
await writeAssignToAgentSummary(assignToAgentHandler);
17441745
}
17451746

17461747
// Export create_agent_session outputs when the handler was loaded

0 commit comments

Comments
 (0)