Skip to content
Closed
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
36 changes: 36 additions & 0 deletions packages/eve/src/harness/approval-candidates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export interface ApprovalSettlementAuditRecord {
readonly requestId: string;
readonly settledAt: number;
readonly candidateId?: string;
readonly eventEmitted?: boolean;
}

interface ActiveApprovalCandidate {
Expand All @@ -47,6 +48,7 @@ interface ActiveApprovalCandidate {
readonly status: "pending" | "authorization-required";
readonly authorizationName?: string;
readonly createdAt: number;
readonly pendingEventEmitted?: boolean;
readonly expiresAt: number;
readonly provider?: string;
readonly runtimeRevision?: string;
Expand Down Expand Up @@ -116,6 +118,40 @@ export function createApprovalCandidate(input: {
};
}

/** Marks the pending candidate event as emitted. */
export function markApprovalCandidatePendingEventEmitted(input: {
readonly candidateId: string;
readonly state: SessionStateMap | undefined;
}): SessionStateMap | undefined {
const approvalState = readApprovalState(input.state);
const candidate = approvalState.activeCandidates[input.candidateId];
if (candidate === undefined || candidate.pendingEventEmitted === true) return input.state;
return writeApprovalState(input.state, {
...approvalState,
activeCandidates: {
...approvalState.activeCandidates,
[input.candidateId]: { ...candidate, pendingEventEmitted: true },
},
});
}

/** Marks a terminal settlement event as emitted. */
export function markApprovalSettlementEventEmitted(input: {
readonly requestId: string;
readonly state: SessionStateMap | undefined;
}): SessionStateMap | undefined {
const approvalState = readApprovalState(input.state);
const settlement = approvalState.settlements[input.requestId];
if (settlement === undefined || settlement.eventEmitted === true) return input.state;
return writeApprovalState(input.state, {
...approvalState,
settlements: {
...approvalState.settlements,
[input.requestId]: { ...settlement, eventEmitted: true },
},
});
}

/** Marks a candidate as waiting on a private authorization challenge. */
export function markApprovalCandidateAuthorizationRequired(input: {
readonly authorizationName: string;
Expand Down
21 changes: 18 additions & 3 deletions packages/eve/src/harness/authorize-approval-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ const APPROVAL_CANDIDATE_TTL_MS = 10 * 60_000;

export type PendingApprovalAuthorizationResult =
| { readonly kind: "continue"; readonly session: HarnessSession; readonly stepInput?: StepInput }
| {
readonly candidateId: string;
readonly kind: "candidate-created";
readonly requestId: string;
readonly session: HarnessSession;
}
| {
readonly authorization: AuthorizationSignal;
readonly candidateId: string;
Expand Down Expand Up @@ -68,9 +74,10 @@ export async function authorizePendingApprovalResponse(input: {
const batch = getPendingInputBatch(input.session.state);
const activeCandidate = getApprovalAuditState(input.session.state).activeCandidates.find(
(candidate) =>
candidate.status === "authorization-required" &&
candidate.authorizationName !== undefined &&
getAuthorizationResult(candidate.authorizationName) !== undefined,
(candidate.status === "pending" && candidate.pendingEventEmitted === true) ||
(candidate.status === "authorization-required" &&
candidate.authorizationName !== undefined &&
getAuthorizationResult(candidate.authorizationName) !== undefined),
);
const response =
input.stepInput?.inputResponses?.find((entry) =>
Expand Down Expand Up @@ -147,6 +154,14 @@ export async function authorizePendingApprovalResponse(input: {
state: input.session.state,
});
let session = { ...input.session, state: created.state };
if (created.result.kind === "created") {
return {
candidateId,
kind: "candidate-created",
requestId: response.requestId,
session,
};
}
if (
activeCandidate === undefined &&
(created.result.kind === "duplicate" || created.result.kind === "stale")
Expand Down
43 changes: 37 additions & 6 deletions packages/eve/src/harness/input-requests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -912,8 +912,37 @@
return contextStorage.run(ctx, run);
}

async function continueCreatedCandidate(
result: Awaited<ReturnType<typeof authorizePendingApprovalResponse>>,
tools: HarnessToolMap,
) {
if (result.kind !== "candidate-created") return result;
const state = result.session.state?.["eve.runtime.hitl.approvalState"] as {
activeCandidates: Record<string, Record<string, unknown>>;
};
const activeCandidates = Object.fromEntries(
Object.entries(state.activeCandidates).map(([id, candidate]) => [
id,
{ ...candidate, pendingEventEmitted: true },
]),
);
return await approvalContext(() =>
authorizePendingApprovalResponse({
now: 101,
session: {
...result.session,
state: {
...result.session.state,
"eve.runtime.hitl.approvalState": { ...state, activeCandidates },
},
},
tools,
}),
);
}

it("fails closed when a required authorizer is missing", async () => {
const result = await approvalContext(() =>
let result = await approvalContext(() =>
authorizePendingApprovalResponse({
now: 100,
session: pendingSession(),
Expand All @@ -923,11 +952,11 @@
tools: new Map(),
}),
);
result = await continueCreatedCandidate(result, new Map());

expect(result).toMatchObject({
kind: "failed",
safeReason: "Approval authorization is temporarily unavailable. Please try again.",
stepInput: { inputResponses: [] },
});
expect(resolvePendingInput({ session: result.session }).outcome).toBe("unresolved");
});
Expand All @@ -950,7 +979,7 @@
},
],
]);
const result = await approvalContext(() =>
let result = await approvalContext(() =>
authorizePendingApprovalResponse({
now: 100,
session: pendingSession(),
Expand All @@ -960,11 +989,11 @@
tools,
}),
);
result = await continueCreatedCandidate(result, tools);

expect(result).toMatchObject({
kind: "rejected",
safeReason: "U1 lacks repository write access.",
stepInput: { inputResponses: [] },
});
expect(resolvePendingInput({ session: result.session }).outcome).toBe("unresolved");
});
Expand All @@ -984,7 +1013,7 @@
},
],
]);
const result = await approvalContext(() =>
let result = await approvalContext(() =>
authorizePendingApprovalResponse({
now: 100,
session: pendingSession(),
Expand All @@ -994,6 +1023,7 @@
tools,
}),
);
result = await continueCreatedCandidate(result, tools);

expect(result.kind).toBe("continue");
if (result.kind !== "continue") throw new Error("Expected allowed candidate.");
Expand Down Expand Up @@ -1023,7 +1053,7 @@
const session = setPendingInputBatch({
requests: [
...(
pendingSession().state?.["eve.runtime.pendingInputBatch"] as {

Check warning on line 1056 in packages/eve/src/harness/input-requests.test.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(no-unsafe-optional-chaining)

Unsafe usage of optional chaining
requests: readonly InputRequest[];
}
).requests,
Expand Down Expand Up @@ -1058,7 +1088,7 @@
},
],
]);
const result = await approvalContext(() =>
let result = await approvalContext(() =>
authorizePendingApprovalResponse({
now: 100,
session,
Expand All @@ -1071,6 +1101,7 @@
tools,
}),
);
result = await continueCreatedCandidate(result, tools);

expect(result.kind).toBe("continue");
if (result.kind !== "continue") throw new Error("Expected first candidate to settle.");
Expand Down
84 changes: 61 additions & 23 deletions packages/eve/src/harness/tool-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,11 @@ import {
} from "#harness/input-extraction.js";
import { createToolResultMessagePartFromToolError } from "#harness/action-result-helpers.js";
import { buildTelemetryRuntimeContext } from "#harness/instrumentation-runtime-context.js";
import { getApprovalAuditState } from "#harness/approval-candidates.js";
import {
getApprovalAuditState,
markApprovalCandidatePendingEventEmitted,
markApprovalSettlementEventEmitted,
} from "#harness/approval-candidates.js";
import { authorizePendingApprovalResponse } from "#harness/authorize-approval-response.js";
import {
consumeDeferredStepInput,
Expand Down Expand Up @@ -574,11 +578,44 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn {
tools: responseAuthorizationTools,
});
session = authorized.session;
if (emit && authorized.kind !== "continue" && authorized.kind !== "duplicate") {
if (authorized.kind === "candidate-created") {
return { next: runStep, session };
}

const pendingCandidateEvent = getApprovalAuditState(session.state).activeCandidates.find(
(candidate) => candidate.pendingEventEmitted !== true,
);
if (emit && pendingCandidateEvent !== undefined) {
await emit(
createApprovalCandidateEvent({
candidateId: pendingCandidateEvent.candidateId,
outcome: "pending",
requestId: pendingCandidateEvent.requestId,
responderPrincipalId: pendingCandidateEvent.responder.principalId,
sequence: emissionState.sequence,
stepIndex: emissionState.stepIndex,
turnId: emissionState.turnId,
}),
);
session = {
...session,
state: markApprovalCandidatePendingEventEmitted({
candidateId: pendingCandidateEvent.candidateId,
state: session.state,
}),
};

@vercel vercel Bot Jul 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pending-approval-candidate event block emits the pending event and sets pendingEventEmitted but never returns { next: runStep }, so the step parks and the response-authorized approval flow deadlocks.

Fix on Vercel

}

if (
emit &&
authorized.kind !== "continue" &&
authorized.kind !== "duplicate" &&
authorized.kind !== "authorization-required"
) {
await emit(
createApprovalCandidateEvent({
candidateId: authorized.candidateId ?? `stale:${authorized.requestId}`,
outcome: authorized.kind === "authorization-required" ? "pending" : authorized.kind,
outcome: authorized.kind,
requestId: authorized.requestId,
responderPrincipalId:
[
Expand Down Expand Up @@ -621,27 +658,28 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn {
};
}

if (emit && authorized.kind === "continue") {
const response = authorized.stepInput?.inputResponses?.find(
(entry) => entry.optionId === "approve" || entry.optionId === "cancel",
const pendingSettlementEvent = getApprovalAuditState(session.state).settlements.find(
(settlement) => settlement.eventEmitted !== true,
);
if (emit && pendingSettlementEvent !== undefined) {
await emit(
createApprovalSettledEvent({
outcome: pendingSettlementEvent.outcome === "allowed" ? "approved" : "cancelled",
requestId: pendingSettlementEvent.requestId,
responderPrincipalId: pendingSettlementEvent.actor.principalId,
sequence: emissionState.sequence,
stepIndex: emissionState.stepIndex,
turnId: emissionState.turnId,
}),
);
if (response !== undefined) {
const settlement = getApprovalAuditState(session.state).settlements.find(
(entry) => entry.requestId === response.requestId,
);
if (settlement !== undefined) {
await emit(
createApprovalSettledEvent({
outcome: settlement.outcome === "allowed" ? "approved" : "cancelled",
requestId: settlement.requestId,
responderPrincipalId: settlement.actor.principalId,
sequence: emissionState.sequence,
stepIndex: emissionState.stepIndex,
turnId: emissionState.turnId,
}),
);
}
}
session = {
...session,
state: markApprovalSettlementEventEmitted({
requestId: pendingSettlementEvent.requestId,
state: session.state,
}),
};
return { next: runStep, session };
}

const pending = resolvePendingInput({
Expand Down
Loading