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
2 changes: 2 additions & 0 deletions docs-web/architecture/virtual-workers.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ Docker-backed planning uses a read-only snapshot workspace instead of a mutable

Provider CLI workspace preparation is centralized through `InvocationWorkspacePreparer`. Its shared provider-invocation option builder constructs snapshot checkout, git policy, and fresh/continue lifecycle values for Docker provider calls, while its continuation resolver locates preserved workspaces and their current branches. Fresh Docker invocations in `REMOTE` git mode use explicit remote refs only: planning, project setup, dashboard/chat replies, worker inbox replies, node-flow provider prompts, QA review snapshots, task coding, QA follow-up, CI autofix, and merge-conflict repair all materialize from `origin/<branch>` refs rather than local branches or the host repo's current checkout. Dashboard/chat replies resolve dashboard settings with the project scope before building this policy, so local Git projects keep `LOCAL` snapshot behavior and do not require `origin/<defaultBranch>`. Continuation/restart flows may reuse a preserved workspace for provider-session continuity; if a preserved workspace is missing and a new workspace must be materialized, the same remote-only branch policy applies.

When a LOCAL branch advances while an isolated worker is running, patch materialization applies the worker diff on top of that current descendant tip so concurrent task merges are retained.

## Session lifecycle

Within a dispatch, the session poll loop runs:
Expand Down
2 changes: 2 additions & 0 deletions docs-web/content/docs/architecture-virtual-workers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ Docker-backed planning uses a read-only snapshot workspace instead of a mutable

Provider CLI workspace preparation is centralized through `InvocationWorkspacePreparer`. Its shared provider-invocation option builder constructs snapshot checkout, git policy, and fresh/continue lifecycle values for Docker provider calls, while its continuation resolver locates preserved workspaces and their current branches. Fresh Docker invocations in `REMOTE` git mode use explicit remote refs only: planning, project setup, dashboard/chat replies, worker inbox replies, node-flow provider prompts, QA review snapshots, task coding, QA follow-up, CI autofix, and merge-conflict repair all materialize from `origin/<branch>` refs rather than local branches or the host repo's current checkout. Dashboard/chat replies resolve dashboard settings with the project scope before building this policy, so local Git projects keep `LOCAL` snapshot behavior and do not require `origin/<defaultBranch>`. Continuation/restart flows may reuse a preserved workspace for provider-session continuity; if a preserved workspace is missing and a new workspace must be materialized, the same remote-only branch policy applies.

When a LOCAL branch advances while an isolated worker is running, patch materialization applies the worker diff on top of that current descendant tip so concurrent task merges are retained.

## Session lifecycle

Within a dispatch, the session poll loop runs:
Expand Down
2 changes: 2 additions & 0 deletions docs-web/content/docs/user-automation-and-ci.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ If a QA agent preset is wired to `qa_review` in routing, completed tasks pass th

Provider or infrastructure errors do not immediately create this handoff. Recovered failed, cancelled, and errored attempts retry within a bounded infrastructure grace, and every terminal attempt counts toward the hard ceiling. A CLI QA fix that produces no new patch is escalated as no progress instead of starting another QA cycle; existing branch commits do not renew the cycle. A coding run that produces no changes must explicitly confirm completion or it is blocked for attention. The sprint watch loop stays alive while worker or human attention is active.

Task and sprint summary badges select one reviewer from the newest QA cycle. Within that cycle, running reviews appear first, followed by requested changes, provider failures (`failed`, `cancelled`, or `errored`), passes, and other states. This keeps a passing reviewer from hiding another reviewer that is still active, has blocked the work, or did not return a usable verdict.

## Attention items: who handles them

Anything not auto-handled becomes an attention item. The dashboard's **Live Session** page surfaces them.
Expand Down
2 changes: 2 additions & 0 deletions docs-web/user/automation-and-ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ If a QA agent preset is wired to `qa_review` in routing, completed tasks pass th

Provider or infrastructure errors do not immediately create this handoff. Recovered failed, cancelled, and errored attempts retry within a bounded infrastructure grace, and every terminal attempt counts toward the hard ceiling. A CLI QA fix that produces no new patch is escalated as no progress instead of starting another QA cycle; existing branch commits do not renew the cycle. A coding run that produces no changes must explicitly confirm completion or it is blocked for attention. The sprint watch loop stays alive while worker or human attention is active.

Task and sprint summary badges select one reviewer from the newest QA cycle. Within that cycle, running reviews appear first, followed by requested changes, provider failures (`failed`, `cancelled`, or `errored`), passes, and other states. This keeps a passing reviewer from hiding another reviewer that is still active, has blocked the work, or did not return a usable verdict.

## Attention items: who handles them

Anything not auto-handled becomes an attention item. The dashboard's **Live Session** page surfaces them.
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture/quality-assurance-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ Runtime aggregation is fail-closed for the latest cycle:
- review budgets count distinct `run_index` values, not reviewer rows, so two reviewers in one cycle consume one QA attempt
- reviewer rows stay visible independently in `qa_review_runs`, task events, and dashboard history with their own agent identity and payload details

Task and sprint cards project one representative reviewer from the newest `run_index`. Within that cycle, the shared projection prioritizes `running`, then `changes_requested`, provider failures (`failed`, `cancelled`, or `errored`), `pass`, and finally other states. A passing reviewer therefore cannot hide an active, blocking, or provider-failed reviewer from the summary badge.

Example trigger settings:

```json
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/virtual-workers.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ Merge-conflict handling intentionally stays isolated from the original task work

For task-scoped CI autofix, Code UX defaults to continuing the original task coding session exactly like a QA follow-up: it reuses the logical/native provider session, provider family, effective model, coding-agent instructions, and preserved task workspace. Settings → AI Models → CI fix exposes **Continue from same session and model as coding task** as an opt-out; disabling it uses the standalone CI Fix route. Sprint-level final-merge repair has no originating task session and always uses that route. The CI-fix prompt also receives the active agent's memory context and writes new durable learnings back into memory from the reused workspace.

Workspace artifact export captures both tracked edits and newly created untracked files from the worker workspace. This matters for CI autofix follow-ups that add missing modules or tests after the original task run; the exporter uses a temporary Git index for untracked files and still excludes the transient `.task-learnings.md` memory-capture file, legacy `.code-ux-home/` provider state, and root `.pnpm-store/` package-cache state from commits. It asks Git to discover untracked files internally before diffing, so preserved Docker workspaces with many untracked paths cannot exceed command argument limits. Current Docker workers keep provider HOME and Code UX-managed npm/pnpm cache paths in a paired runtime volume mounted outside `/workspace`, so fresh workspaces contain only the coding checkout.
Workspace artifact export captures both tracked edits and newly created untracked files from the worker workspace. This matters for CI autofix follow-ups that add missing modules or tests after the original task run; the exporter uses a temporary Git index for untracked files and still excludes the transient `.task-learnings.md` memory-capture file, legacy `.code-ux-home/` provider state, and root `.pnpm-store/` package-cache state from commits. It asks Git to discover untracked files internally before diffing, so preserved Docker workspaces with many untracked paths cannot exceed command argument limits. When a LOCAL branch advances while an isolated worker is running, patch materialization applies the worker diff on top of that current descendant tip so concurrent task merges are retained. Current Docker workers keep provider HOME and Code UX-managed npm/pnpm cache paths in a paired runtime volume mounted outside `/workspace`, so fresh workspaces contain only the coding checkout.

Immediately before every Docker provider launch attempt, Code UX reasserts ownership of that runtime volume for the container's effective non-root UID/GID. Performing this at the atomic `docker run` boundary covers newly created, previously root-owned, and concurrently recreated volumes, preventing restart recovery or startup pruning from leaving CI/QA repair unable to create provider configuration or cache directories. Workspace seed helpers explicitly trust the mounted `/workspace` path while initializing Git, then restore the provider UID/GID; this keeps restart recovery from tripping Git's dubious-ownership protection on a correctly non-root-owned volume.

Expand Down
13 changes: 13 additions & 0 deletions src/infrastructure/providers/cli/workspace-artifact-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,19 @@ export class WorkspaceArtifactService {
gitAuth?: GitHttpAuthOptions;
}): Promise<string> {
if (args.githubMode === "LOCAL") {
const localRef = `refs/heads/${args.workerBranch}`;
try {
const currentTip = (await runCommandStrict(
"git",
["rev-parse", "--verify", localRef],
args.repoPath,
)).stdout.trim();
if (currentTip && await this.isAncestor(args.repoPath, args.baseRef, currentTip)) {
return currentTip;
}
} catch {
// A fresh worker branch may not exist in the host repository yet.
}
return args.baseRef;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const REPRESENTATIVE_REVIEW_ORDER = `
CASE
WHEN q.status = 'running' THEN 0
WHEN q.outcome = 'changes_requested' THEN 1
WHEN q.status = 'failed' THEN 2
WHEN q.status IN ('failed', 'cancelled', 'errored') THEN 2
WHEN q.outcome = 'pass' THEN 3
ELSE 4
END,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,63 @@ describe("WorkspaceArtifactService", () => {
.toBe("remote worker tip\n");
});

it("preserves concurrent local branch commits when materializing a stale workspace patch", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "workspace-artifact-service-"));
cleanupPaths.push(tempRoot);

const hostRepoPath = path.join(tempRoot, "host-repo");
const workspaceRepoPath = path.join(tempRoot, "workspace-repo");

await runCommandStrict("git", ["init", hostRepoPath], tempRoot);
await runGit(hostRepoPath, ["config", "user.name", "Code UX Test"]);
await runGit(hostRepoPath, ["config", "user.email", "code-ux@example.com"]);
await runGit(hostRepoPath, ["checkout", "-b", "feature/test"]);
await fs.writeFile(path.join(hostRepoPath, "base.txt"), "base\n", "utf8");
await runGit(hostRepoPath, ["add", "base.txt"]);
await runGit(hostRepoPath, ["commit", "-m", "base"]);
const baseRef = (await runGit(hostRepoPath, ["rev-parse", "HEAD"])).trim();

await runCommandStrict("git", ["clone", hostRepoPath, workspaceRepoPath], tempRoot);
await runGit(workspaceRepoPath, ["checkout", "feature/test"]);
await fs.writeFile(path.join(workspaceRepoPath, "ci-fix.txt"), "ci fix\n", "utf8");

await fs.writeFile(path.join(hostRepoPath, "task-output.txt"), "task output\n", "utf8");
await runGit(hostRepoPath, ["add", "task-output.txt"]);
await runGit(hostRepoPath, ["commit", "-m", "merge task output"]);
const concurrentTip = (await runGit(hostRepoPath, ["rev-parse", "HEAD"])).trim();

const workspaceManager = {
runWorkspaceCommand: async (
_worktreePath: string,
command: string,
args: string[],
options: WorkspaceCommandOptions = {},
) => await runCommandStrict(command, args, workspaceRepoPath, options.env ?? process.env, {
trimOutput: options.trimOutput,
signal: options.signal,
stdinFile: options.stdinFile,
}),
} as IWorkspaceManager;

const service = new WorkspaceArtifactService(workspaceManager);
const patchText = await service.exportBinaryPatch("workspace", baseRef);
const result = await service.applyPatchToBranch({
repoPath: hostRepoPath,
baseRef,
workerBranch: "feature/test",
patchText,
commitMessage: "fix local CI",
githubMode: "LOCAL",
});

expect(result.hasChanges).toBe(true);
expect((await runGit(hostRepoPath, ["show", "-s", "--format=%P", result.commitSha!])).trim()).toBe(concurrentTip);
expect(await runGit(hostRepoPath, ["show", "refs/heads/feature/test:task-output.txt"], { trimOutput: false }))
.toBe("task output\n");
expect(await runGit(hostRepoPath, ["show", "refs/heads/feature/test:ci-fix.txt"], { trimOutput: false }))
.toBe("ci fix\n");
});

it("keeps a checked-out worker branch clean after materializing a patch", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "workspace-artifact-service-"));
cleanupPaths.push(tempRoot);
Expand Down
30 changes: 29 additions & 1 deletion tests/backend/repositories/qa-review-summary-query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,11 +217,35 @@ describe("QA review summary query", () => {
});
});

it("projects a cancelled task reviewer ahead of a passing reviewer in the latest cycle", async () => {
const { storage, repository } = await createFixture();
const project = repository.createProject({ name: "Cancelled task QA", sourceType: "local", sourceRef: "/workspace/cancelled-task-qa" });
const sprint = repository.createSprint(project.id, { name: "Sprint" });
const task = repository.createTask(project.id, { sprintId: sprint.id, taskKey: "T01", title: "Task" });
const db = storage.getDatabase();
const insert = db.prepare(`
INSERT INTO qa_review_runs (
id, project_id, sprint_id, task_id, trigger_type, status, outcome, run_index,
summary_markdown, agent_name, started_at, finished_at, created_at, updated_at
) VALUES (?, ?, ?, ?, 'task_completion', ?, ?, 2, ?, ?, ?, ?, ?, ?)
`);
insert.run("task-pass", project.id, sprint.id, task.id, "completed", "pass", "Pass", "Pass reviewer", "2026-07-13T11:05:00.000Z", "2026-07-13T11:06:00.000Z", "2026-07-13T11:05:00.000Z", "2026-07-13T11:06:00.000Z");
insert.run("task-cancelled", project.id, sprint.id, task.id, "cancelled", null, "Provider cancelled", "Cancelled reviewer", "2026-07-13T11:00:00.000Z", "2026-07-13T11:01:00.000Z", "2026-07-13T11:00:00.000Z", "2026-07-13T11:01:00.000Z");

expect(loadLatestTaskReviewSummaryMap(storage, [task.id]).get(task.id)).toMatchObject({
status: "cancelled",
outcome: null,
summary: "Provider cancelled",
reviewer: "Cancelled reviewer",
});
});

it("uses blocking-state precedence for sprint reviewers in the latest run index", async () => {
const { storage, repository } = await createFixture();
const project = repository.createProject({ name: "Sprint QA", sourceType: "local", sourceRef: "/workspace/sprint-qa" });
const runningSprint = repository.createSprint(project.id, { name: "Running review" });
const failedSprint = repository.createSprint(project.id, { name: "Failed review" });
const erroredSprint = repository.createSprint(project.id, { name: "Errored review" });
const db = storage.getDatabase();
const insert = db.prepare(`
INSERT INTO qa_review_runs (
Expand All @@ -233,11 +257,15 @@ describe("QA review summary query", () => {
insert.run("running-blocker", project.id, runningSprint.id, "running", null, 3, "Still reviewing", "Running reviewer", "2026-07-13T12:00:00.000Z", null, "2026-07-13T12:00:00.000Z", "2026-07-13T12:00:00.000Z");
insert.run("failed-pass", project.id, failedSprint.id, "completed", "pass", 2, "Pass", "Pass reviewer", "2026-07-13T12:05:00.000Z", "2026-07-13T12:06:00.000Z", "2026-07-13T12:05:00.000Z", "2026-07-13T12:06:00.000Z");
insert.run("failed-blocker", project.id, failedSprint.id, "failed", null, 2, "Reviewer failed", "Failed reviewer", "2026-07-13T12:00:00.000Z", "2026-07-13T12:01:00.000Z", "2026-07-13T12:00:00.000Z", "2026-07-13T12:01:00.000Z");
insert.run("errored-pass", project.id, erroredSprint.id, "completed", "pass", 4, "Pass", "Pass reviewer", "2026-07-13T12:05:00.000Z", "2026-07-13T12:06:00.000Z", "2026-07-13T12:05:00.000Z", "2026-07-13T12:06:00.000Z");
insert.run("errored-blocker", project.id, erroredSprint.id, "errored", null, 4, "Reviewer errored", "Errored reviewer", "2026-07-13T12:00:00.000Z", "2026-07-13T12:01:00.000Z", "2026-07-13T12:00:00.000Z", "2026-07-13T12:01:00.000Z");

const summaries = loadLatestSprintReviewSummaryMap(storage, [runningSprint.id, failedSprint.id]);
const summaries = loadLatestSprintReviewSummaryMap(storage, [runningSprint.id, failedSprint.id, erroredSprint.id]);
expect(summaries.get(runningSprint.id)?.status).toBe("running");
expect(summaries.get(runningSprint.id)?.reviewer).toBe("Running reviewer");
expect(summaries.get(failedSprint.id)?.status).toBe("failed");
expect(summaries.get(failedSprint.id)?.reviewer).toBe("Failed reviewer");
expect(summaries.get(erroredSprint.id)?.status).toBe("errored");
expect(summaries.get(erroredSprint.id)?.reviewer).toBe("Errored reviewer");
});
});
Loading