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
4 changes: 3 additions & 1 deletion src/actions/respond.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,9 @@ async function tryAutoResume(
codexApprovalPolicy: session.codexApprovalPolicy,
pendingPlanApproval: isPlanApproval ? false : session.pendingPlanApproval,
planApprovalContext: session.planApprovalContext,
planDecisionVersion: session.planDecisionVersion,
planDecisionVersion: isPlanApproval
? (session.actionablePlanDecisionVersion ?? session.planDecisionVersion) + 1
: session.planDecisionVersion,
actionablePlanDecisionVersion: isPlanApproval ? undefined : session.actionablePlanDecisionVersion,
canonicalPlanPromptVersion: session.canonicalPlanPromptVersion,
approvalPromptRequiredVersion: session.approvalPromptRequiredVersion,
Expand Down
34 changes: 13 additions & 21 deletions src/callback-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
import type { PersistedSessionInfo, SessionActionKind, SessionActionToken } from "./types";
import { getRepoPolicyOption, validateRepoPolicyForPrAvailability } from "./repo-policy";
import { assessResumeCandidate } from "./session-resume";
import { resolveCurrentPlanDecisionVersion, tokenMatchesAppliedPlanApproval } from "./plan-decision-state";

type InteractiveChannel = "telegram" | "discord";
type InteractiveCallbackContext = PluginInteractiveTelegramHandlerContext | PluginInteractiveDiscordHandlerContext;
Expand Down Expand Up @@ -201,27 +202,6 @@ function planApprovalWasApplied(session: PlanDecisionTarget | undefined): boolea
return session.approvalState === "approved" || !session.pendingPlanApproval;
}

function latestDefinedVersion(...versions: Array<number | undefined>): number | undefined {
let latest: number | undefined;
for (const version of versions) {
if (version == null) continue;
latest = latest == null ? version : Math.max(latest, version);
}
return latest;
}

function resolveCurrentPlanDecisionVersion(session: PlanDecisionTarget): number | undefined {
if (session.actionablePlanDecisionVersion != null) return session.actionablePlanDecisionVersion;

const deliveryVersion = latestDefinedVersion(
session.approvalPromptRequiredVersion,
session.approvalPromptVersion,
);
if (deliveryVersion != null) return deliveryVersion;

return session.canonicalPlanPromptVersion ?? session.planDecisionVersion;
}

function validatePlanDecisionToken(
token: SessionActionToken,
session: PlanDecisionTarget | undefined,
Expand Down Expand Up @@ -696,6 +676,12 @@ export function createCallbackHandler(
let sessionId = token.sessionId;
let actionSession = sessionManager.resolve?.(sessionId) ?? sessionManager.getPersistedSession?.(sessionId);
let actionSessionName = actionSession?.name ?? sessionId;
if (actionSession && tokenMatchesAppliedPlanApproval(token, actionSession)) {
sessionManager.consumePlanDecisionTokens?.(sessionId, token.planDecisionVersion!);
await clearPlanDecisionButtons(ctx, callbackAcknowledged);
await replyText(ctx, `✅ Plan v${token.planDecisionVersion} was already approved; resume is in progress or running.`);
return { handled: true };
}
let invalidPlanDecision = validatePlanDecisionToken(token, actionSession);
logButtonDiagnostic("callback_plan_validation_completed", {
channel: ctx.channel,
Expand Down Expand Up @@ -829,6 +815,12 @@ export function createCallbackHandler(
sessionId = latestToken.sessionId;
actionSession = sessionManager.resolve?.(sessionId) ?? sessionManager.getPersistedSession?.(sessionId);
actionSessionName = actionSession?.name ?? sessionId;
if (actionSession && tokenMatchesAppliedPlanApproval(latestToken, actionSession)) {
sessionManager.consumePlanDecisionTokens?.(sessionId, latestToken.planDecisionVersion!);
await clearPlanDecisionButtons(ctx, callbackAcknowledged);
await replyText(ctx, `✅ Plan v${latestToken.planDecisionVersion} was already approved; resume is in progress or running.`);
return { handled: true };
}
invalidPlanDecision = validatePlanDecisionToken(latestToken, actionSession);
logButtonDiagnostic("callback_plan_validation_completed", {
channel: ctx.channel,
Expand Down
155 changes: 155 additions & 0 deletions src/plan-decision-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import type { PersistedSessionInfo, PermissionMode, SessionConfig, SessionActionToken } from "./types";

export type PlanDecisionTarget = Pick<
PersistedSessionInfo,
| "approvalState"
| "pendingPlanApproval"
| "planApprovalContext"
| "planDecisionVersion"
| "actionablePlanDecisionVersion"
| "canonicalPlanPromptVersion"
| "approvalPromptRequiredVersion"
| "approvalPromptVersion"
| "approvalPromptStatus"
| "approvalPromptTransport"
| "approvalPromptMessageKind"
| "approvalPromptLastAttemptAt"
| "approvalPromptDeliveredAt"
| "approvalPromptFailedAt"
>;

type PlanDecisionVersionTarget = Pick<
PlanDecisionTarget,
| "planDecisionVersion"
| "actionablePlanDecisionVersion"
| "canonicalPlanPromptVersion"
| "approvalPromptRequiredVersion"
| "approvalPromptVersion"
>;

function latestDefinedVersion(...versions: Array<number | undefined>): number | undefined {
let latest: number | undefined;
for (const version of versions) {
if (version == null) continue;
latest = latest == null ? version : Math.max(latest, version);
}
return latest;
}

export function resolveCurrentPlanDecisionVersion(session: PlanDecisionVersionTarget): number | undefined {
if (session.actionablePlanDecisionVersion != null) return session.actionablePlanDecisionVersion;
const deliveryVersion = latestDefinedVersion(
session.approvalPromptRequiredVersion,
session.approvalPromptVersion,
);
if (deliveryVersion != null) return deliveryVersion;
return session.canonicalPlanPromptVersion ?? session.planDecisionVersion;
}

export function tokenMatchesAppliedPlanApproval(
token: Pick<SessionActionToken, "kind" | "planDecisionVersion">,
session: PlanDecisionVersionTarget & Pick<PlanDecisionTarget, "approvalState" | "pendingPlanApproval">,
): boolean {
return token.kind === "plan-approve"
&& token.planDecisionVersion != null
&& session.approvalState === "approved"
&& !session.pendingPlanApproval
&& session.planDecisionVersion === token.planDecisionVersion + 1;
}

export type ResumedPlanState = {
permissionMode: PermissionMode;
approvalApplied: boolean;
decisionVersion?: number;
patch: Partial<SessionConfig>;
};

export function buildFailedPlanResumeRollbackState(
retryablePlan: PersistedSessionInfo,
postTerminal: PersistedSessionInfo | undefined,
): PersistedSessionInfo {
if (retryablePlan.worktreePath && postTerminal && !postTerminal.worktreePath) {
return {
...retryablePlan,
worktreePath: undefined,
worktreeBranch: undefined,
worktreeState: postTerminal.worktreeState,
worktreeDisposition: postTerminal.worktreeDisposition,
worktreeLifecycle: postTerminal.worktreeLifecycle,
pendingWorktreeDecisionSince: postTerminal.pendingWorktreeDecisionSince,
lastWorktreeReminderAt: postTerminal.lastWorktreeReminderAt,
worktreeDecisionSnoozedUntil: postTerminal.worktreeDecisionSnoozedUntil,
worktreeMerged: postTerminal.worktreeMerged,
worktreeMergedAt: postTerminal.worktreeMergedAt,
worktreeDismissedAt: postTerminal.worktreeDismissedAt,
worktreeRemoteOutcome: postTerminal.worktreeRemoteOutcome,
};
}
return retryablePlan;
}

/**
* Carry a stable session's unresolved plan gate across runtime replacement.
* A bypassPermissions resume is an explicit approval only when an exact,
* actionable pending version exists; every other resume remains plan-gated.
*/
export function buildResumedPlanState(
session: PlanDecisionTarget,
requestedPermissionMode: PermissionMode,
): ResumedPlanState {
const decisionVersion = resolveCurrentPlanDecisionVersion(session);
if (!session.pendingPlanApproval) {
return { permissionMode: requestedPermissionMode, approvalApplied: false, patch: {} };
}

const common: Partial<SessionConfig> = {
planApprovalContext: session.planApprovalContext,
canonicalPlanPromptVersion: session.canonicalPlanPromptVersion,
approvalPromptRequiredVersion: session.approvalPromptRequiredVersion,
approvalPromptVersion: session.approvalPromptVersion,
approvalPromptStatus: session.approvalPromptStatus,
approvalPromptTransport: session.approvalPromptTransport,
approvalPromptMessageKind: session.approvalPromptMessageKind,
approvalPromptLastAttemptAt: session.approvalPromptLastAttemptAt,
approvalPromptDeliveredAt: session.approvalPromptDeliveredAt,
approvalPromptFailedAt: session.approvalPromptFailedAt,
};

if (
requestedPermissionMode === "bypassPermissions"
&& session.actionablePlanDecisionVersion != null
&& session.actionablePlanDecisionVersion > 0
&& decisionVersion === session.actionablePlanDecisionVersion
&& session.approvalState !== "rejected"
) {
Comment thread
goldmar marked this conversation as resolved.
return {
permissionMode: "bypassPermissions",
approvalApplied: true,
decisionVersion,
patch: {
...common,
pendingPlanApproval: false,
approvalState: "approved",
approvalExecutionState: "approved_then_implemented",
planModeApproved: true,
planDecisionVersion: decisionVersion + 1,
actionablePlanDecisionVersion: undefined,
},
};
}

return {
// A stable-ID replacement must not turn a pending plan into executable
// default mode merely because the caller omitted the original mode.
permissionMode: "plan",
approvalApplied: false,
decisionVersion,
patch: {
...common,
pendingPlanApproval: true,
approvalState: session.approvalState ?? "pending",
planDecisionVersion: decisionVersion ?? session.planDecisionVersion,
actionablePlanDecisionVersion: session.actionablePlanDecisionVersion,
},
};
}
17 changes: 17 additions & 0 deletions src/session-action-token-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,23 @@ export class SessionActionTokenStore {
return consumed;
}

consumePlanDecisionTokens(sessionId: string, planDecisionVersion: number): SessionActionToken[] {
const consumed: SessionActionToken[] = [];
const consumedAt = Date.now();
for (const token of this.tokens.values()) {
if (
token.sessionId !== sessionId
|| !isPlanDecisionKind(token.kind)
|| token.planDecisionVersion !== planDecisionVersion
|| token.consumedAt != null
) continue;
token.consumedAt = consumedAt;
consumed.push(token);
}
if (consumed.length > 0) this.notifyChanged();
return consumed;
}

deleteActionTokensForSession(sessionId: string): void {
let changed = false;
for (const [tokenId, token] of this.tokens) {
Expand Down
4 changes: 4 additions & 0 deletions src/session-interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ export class SessionInteractionService {
return this.actionTokens.consumeQuestionAnswerTokens(sessionId, requestId, questionId);
}

consumePlanDecisionTokens(sessionId: string, planDecisionVersion: number): SessionActionToken[] {
return this.actionTokens.consumePlanDecisionTokens(sessionId, planDecisionVersion);
}

getActionToken(tokenId: string): SessionActionToken | undefined {
return this.actionTokens.getActionToken(tokenId);
}
Expand Down
Loading