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: 2 additions & 2 deletions docs-web/content/docs/architecture-sprint-rollbacks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ Code UX models a rollback as a new sprint, not as destructive history editing. T

Before creation, Code UX checks the completed source sprint, remote Git configuration, later sprint activity, and the source merge at the tip of the default branch.

- **Automatic rollback** is offered only for a proven isolated latest merge with no later sprint work. Code UX reverts that merge in a detached worktree and pushes a dedicated rollback branch without starting a coding invocation.
- **Automatic rollback** is offered only for a proven isolated latest merge with no later sprint work. Code UX reverts that merge in a detached worktree, pushes a dedicated rollback branch, and enforces a hard no-dispatch boundary for its settled audit task. It then opens the pull request, waits for green checks, and merges it automatically.
- **Agent-assisted rollback** is used when later work may depend on the source, merge history is ambiguous, a deterministic revert conflicts, or you enter custom instructions.

Entering instructions always selects the agent path. This is how you request a partial rollback such as “remove only feature XY but keep the migration.” The agent is told to inspect dependencies, preserve compatible work, update tests, and push only to the rollback branch.

## Pull-request guarantee

Every rollback is delivered through a remote pull request. Rollback sprints force live PR tracking even when ordinary sprint PR monitoring is disabled. If the normal main-PR mode is `OFF`, rollback uses `CREATE_PR` behavior and pauses for a human merge. The rollback sprint is completed only after the Git host reports the PR merged.
Every rollback is delivered through a remote pull request. Rollback sprints force live PR tracking even when ordinary sprint PR monitoring is disabled. Automatic rollbacks use green-check auto-merge; agent-assisted rollbacks retain the configured merge policy, with `OFF` promoted to `CREATE_PR`. The rollback sprint is completed only after the Git host reports the PR merged.

## Dashboard identity

Expand Down
6 changes: 3 additions & 3 deletions docs/architecture/sprint-rollbacks.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ These checks are intentionally conservative. An ambiguous history is not an auto

## Automatic path

`SprintRollbackService` fetches the default branch, creates a detached temporary worktree, creates a unique `rollback/<source>-<suffix>` branch, and runs `git revert -m 1` against the proven integration merge. The branch is pushed to `origin` only after the revert succeeds. The visible checkout and uncommitted user work are never changed.
`SprintRollbackService` fetches the default branch, creates a detached temporary worktree, creates a unique `rollback/<source>-<suffix>` branch, and runs `git revert -m 1` against the proven integration merge. Temporary worktree commands remain rooted in the source repository and address the worktree with `git -C`, preserving the containerized Git helper's metadata paths. The branch is pushed to `origin` only after the revert succeeds. The visible checkout and uncommitted user work are never changed.

The rollback sprint receives one already-settled audit task and starts normal finalization. Automatic rollbacks skip task/sprint provider work, completion QA, and memory-remediation invocation. They still use the normal remote main-merge gate for PR creation, CI observation, conflict repair, and final completion.
The rollback sprint receives one already-settled audit task and starts normal finalization. Automatic rollback cycles have a hard dispatch boundary: they skip task dispatch, task QA, agent intervention, completion QA, and memory-remediation invocation even if a stale runtime projection temporarily presents the audit task as pending. They still use the normal remote main-merge gate for PR creation, CI observation, conflict repair, and final completion.

If the deterministic revert conflicts or any Git step cannot complete safely, the same rollback sprint is changed to `agent_assisted` and receives a pending rollback task.

Expand All @@ -40,7 +40,7 @@ The generated task prompt includes the source sprint key and branch, rollback br

## Pull-request invariant

Rollback finalization forces remote PR monitoring even if ordinary sprint monitoring is disabled. For a rollback sprint, `mainBranchAutoMergeMode=OFF` behaves as `CREATE_PR`: Code UX creates the PR and pauses until a human merges it. Other configured modes retain their normal CI and auto-merge behavior. A rollback run is not marked complete until the Git host reports the rollback PR merged.
Rollback finalization forces remote PR monitoring even if ordinary sprint monitoring is disabled. Automatic rollbacks use `WHEN_GREEN` finalization unless the project already uses `ALWAYS`: Code UX creates the PR, waits for green checks, merges it automatically, and completes only after the Git host reports the merge. Agent-assisted rollbacks retain configured merge behavior; `mainBranchAutoMergeMode=OFF` becomes `CREATE_PR` so they still cannot bypass the PR boundary.

## Persistence

Expand Down
27 changes: 23 additions & 4 deletions src/domain/sprint/orchestrator/cycle-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ export class CycleRunner {
projectId: args.executionContext.project.id,
sprintId: args.executionContext.sprint.id,
});
const isAutomaticRollback = args.executionContext.sprint.kind === "rollback"
&& args.executionContext.sprint.rollbackMode === "automatic";

// Advance the conflict debouncer once per cycle so per-PR `DIRTY` streaks are
// counted per cycle even though several call sites observe the same PR below.
Expand All @@ -109,6 +111,23 @@ export class CycleRunner {
args.sprintRunId,
)
: [];
if (isAutomaticRollback) {
for (const task of subtasks) {
const needsRepair = task.status !== "COMPLETED"
|| !task.is_merged
|| task.merge_indicator !== "MERGED";
task.status = "COMPLETED";
task.is_merged = true;
task.merge_indicator = "MERGED";
if (needsRepair && task.record_id) {
this.deps.projectManagementRepository.updateTask(task.record_id, {
status: "completed",
isMerged: true,
mergeIndicator: "MERGED",
});
}
}
}
let activeProjectAttentionItems = typeof this.deps.projectAttentionService?.listActiveProjectItems === "function"
? this.deps.projectAttentionService.listActiveProjectItems(args.executionContext.project.id)
: [];
Expand Down Expand Up @@ -198,7 +217,7 @@ export class CycleRunner {
let reportText = "";
let qaFinishedTaskIds = new Set<string>();
if (subtasks.length > 0) {
if (args.loopSteps.statusDerivation) {
if (args.loopSteps.statusDerivation && !isAutomaticRollback) {
qaFinishedTaskIds = await this.reviewCompletedTasks(subtasks, cycleEntryStates, args, dashboardSettings);
}
const taskStateBeforeFastBranchGate = snapshotTaskState(subtasks);
Expand All @@ -225,13 +244,13 @@ export class CycleRunner {
}
}

if (args.loopSteps.startReadyTasks && subtasks.length > 0) {
if (args.loopSteps.startReadyTasks && subtasks.length > 0 && !isAutomaticRollback) {
const startResult = await this.runStartReadyTasks(subtasks, args, dashboardSettings);
subtasks = startResult.subtasks;
reportText += startResult.reportText;
}

if (subtasks.length > 0) {
if (subtasks.length > 0 && !isAutomaticRollback) {
const preAutomationTasks = new Map<string, TaskActionRequiredSnapshot>(
subtasks.map((task) => [
task.id,
Expand Down Expand Up @@ -450,7 +469,7 @@ export class CycleRunner {
});
}

if (ciGateRefreshNeeded && args.loopSteps.startReadyTasks) {
if (ciGateRefreshNeeded && args.loopSteps.startReadyTasks && !isAutomaticRollback) {
const startResult = await this.runStartReadyTasks(subtasks, args, dashboardSettings);
subtasks = startResult.subtasks;
reportText += startResult.reportText;
Expand Down
12 changes: 7 additions & 5 deletions src/domain/sprint/orchestrator/rollback-finalization-policy.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import type { CiIntelligenceSettings } from "../../../contracts/app-types.js";
import type { SprintRollbackMode } from "../../../contracts/project-management-types.js";

export function resolveRollbackFinalizationCiIntelligence(
configured: CiIntelligenceSettings,
isRollback: boolean,
rollbackMode: SprintRollbackMode | null,
): CiIntelligenceSettings {
if (!isRollback) return configured;
if (!rollbackMode) return configured;
const mainBranchAutoMergeMode = rollbackMode === "automatic"
? configured.mainBranchAutoMergeMode === "ALWAYS" ? "ALWAYS" : "WHEN_GREEN"
: configured.mainBranchAutoMergeMode === "OFF" ? "CREATE_PR" : configured.mainBranchAutoMergeMode;
return {
...configured,
enabled: true,
enableLivePrMonitoring: true,
mainBranchAutoMergeMode: configured.mainBranchAutoMergeMode === "OFF"
? "CREATE_PR"
: configured.mainBranchAutoMergeMode,
mainBranchAutoMergeMode,
};
}
10 changes: 6 additions & 4 deletions src/domain/sprint/orchestrator/watch-loop-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -522,12 +522,14 @@ export class WatchLoopRunner {
activeMainMergeAttentionItems,
} = params;

// Rollbacks are never allowed to bypass the remote PR boundary, even when the
// project has disabled normal sprint PR monitoring. OFF becomes CREATE_PR so
// the rollback pauses for a human merge instead of completing on branch push.
// Rollbacks are never allowed to bypass the remote PR boundary. Automatic
// rollbacks force green-check auto-merge; agent-assisted rollbacks retain the
// configured policy, with OFF promoted to a human CREATE_PR handoff.
const ciIntelligence = resolveRollbackFinalizationCiIntelligence(
configuredCiIntelligence,
scopedExecutionContext.sprint.kind === "rollback",
scopedExecutionContext.sprint.kind === "rollback"
? scopedExecutionContext.sprint.rollbackMode
: null,
);

let report = "";
Expand Down
14 changes: 11 additions & 3 deletions src/services/sprint-rollback-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,14 +276,22 @@ export class SprintRollbackService {
`refs/remotes/origin/${args.defaultBranch}`,
], args.repoPath);
worktreeAdded = true;
await this.gitRunner("git", ["switch", "-c", args.rollbackBranch], worktreePath);
// Keep every worktree command rooted at the source repository. Git commands
// run in the containerized Git helper, where the source checkout is always
// mounted at /workspace. Running a later command with the host worktree as
// cwd remounts that directory as /workspace and invalidates the .git pointer
// written by `git worktree add` (for example /workspace/.git/worktrees/0).
// `git -C` preserves one mount context while still operating on the worktree.
await this.gitRunner("git", ["-C", worktreePath, "switch", "-c", args.rollbackBranch], args.repoPath);
await this.gitRunner("git", [
"-C", worktreePath,
...GIT_IDENTITY_ARGS,
"revert", "--no-edit", "-m", "1", args.integrationCommitSha,
], worktreePath);
], args.repoPath);
await this.gitRunner("git", [
"-C", worktreePath,
"push", "--set-upstream", "origin", `HEAD:refs/heads/${args.rollbackBranch}`,
], worktreePath, authEnv);
], args.repoPath, authEnv);
} finally {
if (worktreeAdded) {
await this.gitRunner("git", ["worktree", "remove", "--force", worktreePath], args.repoPath).catch(() => undefined);
Expand Down
81 changes: 81 additions & 0 deletions tests/backend/domain/sprint/orchestrator/cycle-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,87 @@ function buildDeps(): SprintOrchestratorDependencies {
}

describe("CycleRunner attention sync", () => {
it("never dispatches or reviews the audit task of an automatic rollback", async () => {
const deps = buildDeps();
const reviewCompletedTask = vi.fn();
deps.qualityAssuranceService = {
reconcileRunningTaskQaReviews: vi.fn(),
getTaskMergeGateStatus: vi.fn().mockReturnValue({
mergeAllowed: false,
reason: "pending_review",
latestRun: null,
runsUsed: 0,
maxRuns: 3,
}),
reviewCompletedTask,
} as any;
vi.mocked(deps.sprintExecutionStateService.loadSubtasks).mockResolvedValue([{
id: "ROLLBACK",
record_id: "rollback-audit-task",
title: "Automatic rollback audit",
prompt: "No provider invocation is required.",
depends_on: [],
// Deliberately use PENDING to prove that even a stale/corrupt projection
// cannot make an automatic rollback audit task dispatchable.
status: "PENDING",
is_independent: true,
is_merged: true,
merge_indicator: "MERGED",
}] as any);

const result = await new CycleRunner(deps).run({
action: "orchestrate",
automationLevel: "SEMI_AUTO",
automationInterventions: DEFAULT_DASHBOARD_SETTINGS.automationInterventions,
executionContext: {
project: { id: "project-1", name: "Project 1" } as any,
sprint: {
id: "rollback-sprint",
name: "Rollback Sprint",
kind: "rollback",
rollbackMode: "automatic",
} as any,
sprintNumber: 2,
repoPath: "/repo/project-1",
featureBranch: "rollback/1-test",
defaultBranch: "main",
},
repoPath: "/repo/project-1",
defaultFeatureBranch: "rollback/1-test",
retryFailed: true,
loopSteps: {
loadSubtasks: true,
sessionSync: false,
statusDerivation: true,
startReadyTasks: true,
statusTable: false,
mergeProtocol: false,
actionRequiredProtocol: false,
} as any,
ciIntelligence: { enabled: false } as any,
githubMode: "REMOTE",
defaultBranch: "main",
featureBranchPrefix: "feature/",
sprintRunId: "run-1",
});

expect(deps.startTask).not.toHaveBeenCalled();
expect(reviewCompletedTask).not.toHaveBeenCalled();
expect(deps.approveSessionPlan).not.toHaveBeenCalled();
expect(deps.sendSessionMessage).not.toHaveBeenCalled();
expect(deps.projectManagementRepository.updateTask).toHaveBeenCalledWith("rollback-audit-task", {
status: "completed",
isMerged: true,
mergeIndicator: "MERGED",
});
expect(result.subtasks[0]).toMatchObject({
id: "ROLLBACK",
status: "COMPLETED",
is_merged: true,
merge_indicator: "MERGED",
});
});

it("opens a resettable human handoff when the coding guardrail is exhausted", () => {
const deps = buildDeps();
vi.mocked(deps.guardrailService!.evaluate).mockReturnValue({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,30 @@ const configured = {
} as CiIntelligenceSettings;

describe("resolveRollbackFinalizationCiIntelligence", () => {
it("forces a remote PR handoff for rollback sprints", () => {
expect(resolveRollbackFinalizationCiIntelligence(configured, true)).toMatchObject({
it("forces automatic rollbacks to auto-merge through a green remote PR", () => {
expect(resolveRollbackFinalizationCiIntelligence(configured, "automatic")).toMatchObject({
enabled: true,
enableLivePrMonitoring: true,
mainBranchAutoMergeMode: "CREATE_PR",
mainBranchAutoMergeMode: "WHEN_GREEN",
});
});

it("preserves configured auto-merge behavior for rollback sprints", () => {
it("preserves an explicit always-auto-merge policy for automatic rollbacks", () => {
expect(resolveRollbackFinalizationCiIntelligence({
...configured,
mainBranchAutoMergeMode: "WHEN_GREEN",
}, true).mainBranchAutoMergeMode).toBe("WHEN_GREEN");
mainBranchAutoMergeMode: "ALWAYS",
}, "automatic").mainBranchAutoMergeMode).toBe("ALWAYS");
});

it("uses a human PR handoff for agent rollbacks when normal auto-merge is off", () => {
expect(resolveRollbackFinalizationCiIntelligence(configured, "agent_assisted")).toMatchObject({
enabled: true,
enableLivePrMonitoring: true,
mainBranchAutoMergeMode: "CREATE_PR",
});
});

it("does not alter standard sprint settings", () => {
expect(resolveRollbackFinalizationCiIntelligence(configured, false)).toBe(configured);
expect(resolveRollbackFinalizationCiIntelligence(configured, null)).toBe(configured);
});
});
19 changes: 18 additions & 1 deletion tests/backend/services/sprint-rollback-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,27 @@ describe("SprintRollbackService", () => {
expect.objectContaining({ status: "completed", isMerged: true, sourceType: "sprint_rollback" }),
]);
expect(calls.some(({ args }) => args.includes("revert") && args.includes("-m") && args.includes("merge-sha"))).toBe(true);
expect(calls.some(({ args }) => args[0] === "push" && args.some((arg) => arg.startsWith("HEAD:refs/heads/rollback/")))).toBe(true);
expect(calls.some(({ args }) => args.includes("push") && args.some((arg) => arg.startsWith("HEAD:refs/heads/rollback/")))).toBe(true);
expect(orchestrateSprint).toHaveBeenCalledWith(project.id, result.rollbackSprint.id);
});

it("keeps temporary worktree commands in the source repository Git context", async () => {
const { project, sourceSprint, service, calls } = await createHarness();

const result = await service.create(project.id, sourceSprint.id);

expect(result.mode).toBe("automatic");
const worktreeCommands = calls.filter(({ args }) => (
args.includes("switch") || args.includes("revert") || args.includes("push")
));
expect(worktreeCommands).toHaveLength(3);
for (const call of worktreeCommands) {
expect(call.cwd).toBe(project.baseDir);
expect(call.args[0]).toBe("-C");
expect(call.args[1]).toContain("code-ux-rollback-");
}
});

it("always routes custom rollback instructions through an agent task", async () => {
const { repository, project, sourceSprint, service, calls } = await createHarness();

Expand Down
Loading