fix(acp): capture caller lifecycle before setup

This commit is contained in:
Vincent Koc
2026-08-07 09:11:23 +02:00
parent bb7aff140b
commit 58d08dedd3
2 changed files with 91 additions and 3 deletions
+67
View File
@@ -788,6 +788,73 @@ describe("tryDispatchAcpReply", () => {
clearAgentRunContext(runId, replacementGeneration);
});
it("captures caller-owned ACP lifecycle before lazy dispatch setup yields", async () => {
setReadyAcpResolution();
const runId = "caller-reused-during-setup";
const admittedGeneration = getAgentEventLifecycleGeneration();
claimAgentRunContext(runId, {
agentId: "caller",
lifecycleGeneration: admittedGeneration,
sessionKey,
});
const admittedToken = getAgentRunContextLifecycleToken(runId, admittedGeneration);
let startGeneration: string | undefined;
let startToken: object | undefined;
let callbackGeneration: string | undefined;
let callbackToken: object | undefined;
let terminalGeneration: string | undefined;
let terminalToken: object | undefined;
auditMocks.emitAcpLifecycleStart.mockImplementationOnce(
(params: { lifecycleGeneration?: string }) => {
startGeneration = params.lifecycleGeneration;
startToken = getAgentRunExecutionContextLifecycleToken(runId);
},
);
auditMocks.emitAcpRuntimeEvent.mockImplementationOnce(() => {
callbackGeneration = getAgentRunExecutionLifecycleGeneration();
callbackToken = getAgentRunExecutionContextLifecycleToken(runId);
});
auditMocks.emitAcpLifecycleEnd.mockImplementationOnce(() => {
terminalGeneration = getAgentRunExecutionLifecycleGeneration();
terminalToken = getAgentRunExecutionContextLifecycleToken(runId);
});
managerMocks.runTurn.mockImplementationOnce(
async ({ onEvent }: { onEvent?: (event: unknown) => Promise<void> }) => {
if (onEvent) {
await emitToolLifecycleEvents(onEvent, "tool-after-setup-reuse");
}
},
);
const dispatch = runDispatch({ bodyForAgent: "audit setup race", runId });
const replacementGeneration = rotateAgentEventLifecycleGeneration();
claimAgentRunContext(runId, {
agentId: "replacement",
lifecycleGeneration: replacementGeneration,
sessionKey: "agent:replacement:main",
});
const replacementToken = getAgentRunContextLifecycleToken(runId, replacementGeneration);
await dispatch;
expect(admittedToken).toBeTypeOf("object");
expect(replacementToken).toBeTypeOf("object");
expect(replacementToken).not.toBe(admittedToken);
expect(startGeneration).toBe(admittedGeneration);
expect(startToken).toBe(admittedToken);
expect(callbackGeneration).toBe(admittedGeneration);
expect(callbackToken).toBe(admittedToken);
expect(terminalGeneration).toBe(admittedGeneration);
expect(terminalToken).toBe(admittedToken);
expect(getAgentRunContext(runId)).toMatchObject({
agentId: "replacement",
lifecycleGeneration: replacementGeneration,
sessionKey: "agent:replacement:main",
});
clearAgentRunContext(runId, replacementGeneration);
});
it("keeps audit run ids unique when channel message ids repeat", async () => {
setReadyAcpResolution();
+24 -3
View File
@@ -22,8 +22,10 @@ import {
captureAgentRunLifecycleGeneration,
withAgentRunLifecycleGeneration,
} from "../../infra/agent-events.js";
import { captureAgentRunExecutionContextLifecycleToken } from "../../infra/agent-run-execution-context.js";
import {
claimAgentRunContext,
getAgentRunContextLifecycleToken,
releaseAgentRunContext,
retainActiveAgentRunContext,
} from "../../infra/agent-run-registry.js";
@@ -455,6 +457,17 @@ export async function tryDispatchAcpReply(params: {
return null;
}
const existingRunId = normalizeOptionalString(params.runId);
// Lazy ACP setup can yield while a caller-owned run id is rotated and reused.
// Snapshot both ownership identities now so later audit callbacks cannot rebound.
const admittedAuditLifecycleGeneration = existingRunId
? captureAgentRunLifecycleGeneration(existingRunId)
: undefined;
const admittedAuditContextLifecycleToken =
existingRunId && admittedAuditLifecycleGeneration
? getAgentRunContextLifecycleToken(existingRunId, admittedAuditLifecycleGeneration)
: undefined;
const { getAcpSessionManager } = await loadDispatchAcpManagerRuntime();
const acpManager = getAcpSessionManager();
const acpResolution = acpManager.resolveSession({
@@ -581,14 +594,13 @@ export async function tryDispatchAcpReply(params: {
markIdle: params.markIdle,
});
const requestId = resolveAcpRequestId(params.ctx);
const existingRunId = normalizeOptionalString(params.runId);
const auditOnly = existingRunId === undefined;
const auditRunId = existingRunId ?? generateSecureUuid();
const auditRuntime = await loadDispatchAcpAuditRuntime();
const auditToolTracker = auditRuntime.createAcpToolLifecycleTracker();
let auditStarted = false;
let auditFinished = false;
let auditLifecycleGeneration: string | undefined;
let auditLifecycleGeneration = admittedAuditLifecycleGeneration;
let auditContextOwnerToken: string | undefined;
let releaseAuditContextLease: (() => void) | undefined;
let auditTerminalOutcome: "blocked" | undefined;
@@ -639,7 +651,16 @@ export async function tryDispatchAcpReply(params: {
const runWithAuditLifecycle = <T>(run: () => T): T => {
claimAuditContext();
return auditLifecycleGeneration
? withAgentRunLifecycleGeneration(auditLifecycleGeneration, run)
? withAgentRunLifecycleGeneration(auditLifecycleGeneration, () => {
if (admittedAuditContextLifecycleToken) {
captureAgentRunExecutionContextLifecycleToken(
auditRunId,
auditLifecycleGeneration,
admittedAuditContextLifecycleToken,
);
}
return run();
})
: run();
};
const releaseAuditContext = () => {