Skip to content

Commit 78c13ac

Browse files
committed
Fix replay-invalid session reset semantics
1 parent 0001e1f commit 78c13ac

3 files changed

Lines changed: 49 additions & 130 deletions

File tree

src/auto-reply/reply/agent-runner-execution.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4776,7 +4776,6 @@ describe("runAgentTurnWithFallback", () => {
47764776
shouldEmitToolResult: () => true,
47774777
shouldEmitToolOutput: () => false,
47784778
pendingToolTasks: new Set(),
4779-
resetSessionAfterCompactionFailure: async () => false,
47804779
resetSessionAfterRoleOrderingConflict: async () => false,
47814780
isHeartbeat: false,
47824781
sessionKey: "main",

src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ function createMinimalRun(params?: {
196196
return {
197197
typing,
198198
opts,
199+
followupRun,
199200
run: async () => {
200201
const runReplyAgent = await getRunReplyAgent();
201202
return runReplyAgent({
@@ -373,7 +374,7 @@ describe("runReplyAgent pending final delivery capture", () => {
373374
),
374375
);
375376

376-
const { run } = createMinimalRun({
377+
const { followupRun, run } = createMinimalRun({
377378
sessionEntry,
378379
sessionStore,
379380
sessionKey: "main",
@@ -391,10 +392,18 @@ describe("runReplyAgent pending final delivery capture", () => {
391392
text: "⚠️ Session history got out of sync. Please try again, or use /new to start a fresh session.",
392393
});
393394
expect(stored.sessionId).not.toBe("poisoned-session");
395+
expect(stored.sessionFile).toEqual(expect.any(String));
396+
expect(stored.sessionFile).not.toBe("/tmp/poisoned-session.jsonl");
397+
expect(followupRun.run.sessionId).toBe(stored.sessionId);
398+
expect(followupRun.run.sessionFile).toBe(stored.sessionFile);
399+
expect(vi.mocked(refreshQueuedFollowupSession)).toHaveBeenCalledWith({
400+
key: "main",
401+
previousSessionId: "poisoned-session",
402+
nextSessionId: stored.sessionId,
403+
nextSessionFile: stored.sessionFile,
404+
});
394405
expect(stored.systemSent).toBe(false);
395-
expect(stored.status).toBeUndefined();
396406
expect(stored.contextTokens).toBeUndefined();
397-
expect(stored.cliSessionBindings).toBeUndefined();
398407
});
399408

400409
it("does not persist message-tool-only final replies for heartbeat replay", async () => {

src/auto-reply/reply/agent-runner.ts

Lines changed: 37 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import crypto from "node:crypto";
21
import fs from "node:fs/promises";
32
import {
43
hasSessionAutoModelFallbackProvenance,
@@ -103,7 +102,6 @@ import {
103102
type ReplyOperation,
104103
} from "./reply-run-registry.js";
105104
import { createReplyToModeFilterForChannel, resolveReplyToMode } from "./reply-threading.js";
106-
import { clearSessionResetRuntimeState } from "./session-reset-cleanup.js";
107105
import { incrementRunCompactionCount, persistRunSessionUsage } from "./session-run-accounting.js";
108106
import { resolveSourceReplyVisibilityPolicy } from "./source-reply-delivery-mode.js";
109107
import { createTypingSignaler } from "./typing-mode.js";
@@ -115,54 +113,6 @@ function isReplayInvalidAgentRunError(error: unknown): boolean {
115113
return classifyProviderRuntimeFailureKind(formatErrorMessage(error)) === "replay_invalid";
116114
}
117115

118-
function buildReplayInvalidSessionResetPatch(now: number): Partial<SessionEntry> {
119-
return {
120-
sessionId: crypto.randomUUID(),
121-
sessionFile: undefined,
122-
updatedAt: now,
123-
sessionStartedAt: now,
124-
lastInteractionAt: now,
125-
systemSent: false,
126-
abortedLastRun: false,
127-
status: undefined,
128-
startedAt: undefined,
129-
endedAt: undefined,
130-
runtimeMs: undefined,
131-
totalTokens: undefined,
132-
inputTokens: undefined,
133-
outputTokens: undefined,
134-
totalTokensFresh: undefined,
135-
estimatedCostUsd: undefined,
136-
cacheRead: undefined,
137-
cacheWrite: undefined,
138-
contextTokens: undefined,
139-
responseUsage: undefined,
140-
modelProvider: undefined,
141-
model: undefined,
142-
agentHarnessId: undefined,
143-
fallbackNoticeSelectedModel: undefined,
144-
fallbackNoticeActiveModel: undefined,
145-
fallbackNoticeReason: undefined,
146-
compactionCount: 0,
147-
memoryFlushAt: undefined,
148-
memoryFlushCompactionCount: undefined,
149-
memoryFlushContextHash: undefined,
150-
pendingFinalDelivery: undefined,
151-
pendingFinalDeliveryCreatedAt: undefined,
152-
pendingFinalDeliveryLastAttemptAt: undefined,
153-
pendingFinalDeliveryAttemptCount: undefined,
154-
pendingFinalDeliveryLastError: undefined,
155-
pendingFinalDeliveryText: undefined,
156-
pendingFinalDeliveryContext: undefined,
157-
pendingFinalDeliveryIntentId: undefined,
158-
cliSessionIds: undefined,
159-
cliSessionBindings: undefined,
160-
claudeCliSessionId: undefined,
161-
skillsSnapshot: undefined,
162-
systemPromptReport: undefined,
163-
};
164-
}
165-
166116
function markBeforeAgentRunBlockedPayloads(payloads: ReplyPayload[]): ReplyPayload[] {
167117
return payloads.map((payload) =>
168118
setReplyPayloadMetadata(payload, { beforeAgentRunBlocked: true }),
@@ -1209,52 +1159,44 @@ export async function runReplyAgent(params: {
12091159
});
12101160
}
12111161
};
1212-
const resetSessionAfterReplayInvalid = async (reason: string): Promise<boolean> => {
1213-
if (!sessionKey) {
1214-
return false;
1215-
}
1216-
const oldSessionId = activeSessionEntry?.sessionId;
1217-
const patch = buildReplayInvalidSessionResetPatch(Date.now());
1218-
let didReset = false;
1219-
if (activeSessionStore && activeSessionEntry) {
1220-
const nextEntry = { ...activeSessionEntry, ...patch } as SessionEntry;
1221-
activeSessionStore[sessionKey] = nextEntry;
1222-
activeSessionEntry = nextEntry;
1223-
didReset = true;
1224-
}
1225-
if (storePath) {
1226-
try {
1227-
const updated = await updateSessionStoreEntry({
1228-
storePath,
1229-
sessionKey,
1230-
update: async () => patch,
1231-
});
1232-
if (updated) {
1233-
activeSessionEntry = updated;
1234-
if (activeSessionStore) {
1235-
activeSessionStore[sessionKey] = updated;
1236-
}
1237-
didReset = true;
1238-
}
1239-
} catch (resetError) {
1240-
logVerbose(
1241-
`failed to reset replay-invalid session ${sessionKey}: ${formatErrorMessage(resetError)}`,
1242-
);
1243-
}
1244-
}
1245-
if (!didReset) {
1246-
return false;
1247-
}
1248-
followupRun.run.sessionId = String(patch.sessionId);
1249-
activeIsNewSession = true;
1250-
clearSessionResetRuntimeState([sessionKey, oldSessionId]);
1251-
logVerbose(
1252-
`reset replay-invalid session ${sessionKey}` +
1253-
(oldSessionId ? ` (${oldSessionId} -> ${String(patch.sessionId)})` : "") +
1254-
` after ${reason}`,
1255-
);
1256-
return true;
1162+
type SessionResetOptions = {
1163+
failureLabel: string;
1164+
buildLogMessage: (nextSessionId: string) => string;
1165+
cleanupTranscripts?: boolean;
12571166
};
1167+
const resetSession = async ({
1168+
failureLabel,
1169+
buildLogMessage,
1170+
cleanupTranscripts,
1171+
}: SessionResetOptions): Promise<boolean> =>
1172+
await resetReplyRunSession({
1173+
options: {
1174+
failureLabel,
1175+
buildLogMessage,
1176+
cleanupTranscripts,
1177+
},
1178+
sessionKey,
1179+
queueKey,
1180+
activeSessionEntry,
1181+
activeSessionStore,
1182+
storePath,
1183+
messageThreadId:
1184+
typeof sessionCtx.MessageThreadId === "string" ? sessionCtx.MessageThreadId : undefined,
1185+
followupRun,
1186+
onActiveSessionEntry: (nextEntry) => {
1187+
activeSessionEntry = nextEntry;
1188+
},
1189+
onNewSession: () => {
1190+
activeIsNewSession = true;
1191+
},
1192+
});
1193+
const resetSessionAfterReplayInvalid = async (reason: string): Promise<boolean> =>
1194+
resetSession({
1195+
failureLabel: "replay-invalid session state",
1196+
buildLogMessage: (nextSessionId) =>
1197+
`Replay-invalid session state (${reason}). Restarting session ${sessionKey} -> ${nextSessionId}.`,
1198+
cleanupTranscripts: true,
1199+
});
12581200

12591201
if (effectiveShouldSteer && isStreaming) {
12601202
const steerSessionId =
@@ -1508,37 +1450,6 @@ export async function runReplyAgent(params: {
15081450
});
15091451

15101452
let responseUsageLine: string | undefined;
1511-
type SessionResetOptions = {
1512-
failureLabel: string;
1513-
buildLogMessage: (nextSessionId: string) => string;
1514-
cleanupTranscripts?: boolean;
1515-
};
1516-
const resetSession = async ({
1517-
failureLabel,
1518-
buildLogMessage,
1519-
cleanupTranscripts,
1520-
}: SessionResetOptions): Promise<boolean> =>
1521-
await resetReplyRunSession({
1522-
options: {
1523-
failureLabel,
1524-
buildLogMessage,
1525-
cleanupTranscripts,
1526-
},
1527-
sessionKey,
1528-
queueKey,
1529-
activeSessionEntry,
1530-
activeSessionStore,
1531-
storePath,
1532-
messageThreadId:
1533-
typeof sessionCtx.MessageThreadId === "string" ? sessionCtx.MessageThreadId : undefined,
1534-
followupRun,
1535-
onActiveSessionEntry: (nextEntry) => {
1536-
activeSessionEntry = nextEntry;
1537-
},
1538-
onNewSession: () => {
1539-
activeIsNewSession = true;
1540-
},
1541-
});
15421453
const resetSessionAfterRoleOrderingConflict = async (reason: string): Promise<boolean> =>
15431454
resetSession({
15441455
failureLabel: "role ordering conflict",

0 commit comments

Comments
 (0)