Skip to content

Commit fe5c22f

Browse files
committed
fix(server): limit cursorless rollback fallback to compaction
1 parent c5c86e1 commit fe5c22f

4 files changed

Lines changed: 36 additions & 13 deletions

File tree

‎apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5426,14 +5426,18 @@ describe("ClaudeAdapterV2 background wake turns", () => {
54265426

54275427
const providerTurn = lastProviderTurn(harness.events);
54285428
assert.notEqual(providerTurn.nativeTurnRef?.nativeId, subagentUuid);
5429-
const rollbackTo = (providerThreadTurns: ReadonlyArray<typeof providerTurn>) =>
5429+
const rollbackTo = (
5430+
providerThreadTurns: ReadonlyArray<typeof providerTurn>,
5431+
text = "Background task completed.",
5432+
) =>
54305433
harness.runtime.rollbackThread({
54315434
providerThread: harness.providerThread,
54325435
target: {
54335436
type: "provider_turn",
54345437
checkpointId: CheckpointId.make("checkpoint-claude-cursor-subagent-only"),
54355438
appRunOrdinal: 1,
54365439
providerTurn,
5440+
message: { role: "user", text, attachments: [] },
54375441
},
54385442
providerThreadTurns,
54395443
});
@@ -5451,7 +5455,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
54515455
assert.instanceOf(error, ProviderAdapter.ProviderAdapterRollbackThreadError);
54525456
assert.include(String(error.cause), "no SDK assistant message cursor");
54535457
const earlierCursor = "00000000-0000-4000-8000-000000000648";
5454-
const pastSilentTurn = yield* rollbackTo([
5458+
const turnsAcrossSilent = [
54555459
{
54565460
...providerTurn,
54575461
id: ProviderTurnId.make("provider-turn-claude-cursor-earlier"),
@@ -5468,7 +5472,10 @@ describe("ClaudeAdapterV2 background wake turns", () => {
54685472
id: ProviderTurnId.make("provider-turn-claude-cursor-next"),
54695473
ordinal: providerTurn.ordinal + 1,
54705474
},
5471-
]);
5475+
] satisfies ReadonlyArray<typeof providerTurn>;
5476+
const semanticError = yield* rollbackTo(turnsAcrossSilent).pipe(Effect.flip);
5477+
assert.instanceOf(semanticError, ProviderAdapter.ProviderAdapterRollbackThreadError);
5478+
const pastSilentTurn = yield* rollbackTo(turnsAcrossSilent, "/compact");
54725479
assert.equal(
54735480
pastSilentTurn.providerThread.nativeConversationHeadRef?.nativeId,
54745481
earlierCursor,

‎apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2030,15 +2030,16 @@ const resolveClaudeRollbackResumeSessionAt = Effect.fn(
20302030
return null;
20312031
}
20322032

2033-
// Turns with no main-thread assistant message, such as `/compact`, record no cursor.
2034-
// The latest earlier cursor resumes the same conversation without that turn.
2035-
const earlierCursor = input.providerThreadTurns
2036-
.filter((turn) => turn.ordinal < target.providerTurn.ordinal)
2037-
.toSorted((left, right) => left.ordinal - right.ordinal)
2038-
.map(claudeTurnCursor)
2039-
.findLast((candidate) => candidate !== null);
2040-
if (earlierCursor != null) {
2041-
return earlierCursor;
2033+
// Only compaction can be omitted without dropping a retained user prompt.
2034+
if (target.message !== undefined && isCompactionCommandMessage(target.message)) {
2035+
const earlierCursor = input.providerThreadTurns
2036+
.filter((turn) => turn.ordinal < target.providerTurn.ordinal)
2037+
.toSorted((left, right) => left.ordinal - right.ordinal)
2038+
.map(claudeTurnCursor)
2039+
.findLast((candidate) => candidate !== null);
2040+
if (earlierCursor != null) {
2041+
return earlierCursor;
2042+
}
20422043
}
20432044

20442045
return yield* new ProviderAdapter.ProviderAdapterRollbackThreadError({

‎apps/server/src/orchestration-v2/CheckpointRollbackService.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,19 +183,33 @@ export const layer: Layer.Layer<
183183
const targetTurn =
184184
latestProviderTurnForAttempt(context.providerTurns, targetAttempt?.id) ??
185185
context.providerTurns.find((turn) => turn.id === targetAttempt?.providerTurnId);
186-
if (targetTurn === undefined || targetTurn.providerThreadId !== providerThread.id) {
186+
if (
187+
targetAttempt === undefined ||
188+
targetTurn === undefined ||
189+
targetTurn.providerThreadId !== providerThread.id
190+
) {
187191
return yield* new CheckpointRollbackExecutionError({
188192
reason: "provider-turn-unavailable",
189193
threadId: input.threadId,
190194
providerThreadId: input.providerThreadId,
191195
checkpointId: input.checkpointId,
192196
});
193197
}
198+
const { attempt } = yield* projections.getProviderControlContext(input.threadId, {
199+
providerThreadId: providerThread.id,
200+
providerTurnId: targetTurn.id,
201+
attemptId: targetAttempt.id,
202+
});
203+
const message =
204+
attempt === undefined
205+
? undefined
206+
: yield* projections.getRunMessage(input.threadId, attempt.runId);
194207
return {
195208
type: "provider_turn" as const,
196209
checkpointId: checkpoint.id,
197210
appRunOrdinal: targetOrdinal,
198211
providerTurn: targetTurn,
212+
...(message === undefined ? {} : { message }),
199213
};
200214
});
201215

‎packages/provider-core/src/server/ProviderAdapter.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,7 @@ export type ProviderAdapterV2RollbackTarget =
475475
readonly checkpointId: CheckpointId;
476476
readonly appRunOrdinal: number;
477477
readonly providerTurn: OrchestrationV2ProviderTurn;
478+
readonly message?: Pick<OrchestrationV2ConversationMessage, "role" | "text" | "attachments">;
478479
};
479480

480481
export interface ProviderAdapterV2RollbackThreadInput {

0 commit comments

Comments
 (0)