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
6 changes: 6 additions & 0 deletions bin/fixlab.js
Original file line number Diff line number Diff line change
Expand Up @@ -950,6 +950,12 @@ function launch(repository, request, runtime, targetEnvironment = "", model = ""
);
return 1;
}
if (profile.worktreeSetup?.mode === "shared" && !readOnly) {
console.error(
"Shared worktree jobs require dashboard-owned branch acquisition and resumption. Use fixlab dashboard or fixlab chat for this repository."
);
return 1;
}

let selectedEnvironment;
try {
Expand Down
2 changes: 1 addition & 1 deletion dashboard/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ function renderJob(job, currentActiveJob = job) {
jobStatusElement.textContent = job
? `${job.status} · ${job.requestType}${job.model ? ` · ${job.model}` : ""} · ${
job.targetEnvironment || "profile default"
} · ${formatDuration(job.durationMs)}${
}${job.sharedWorkspace ? ` · shared: ${job.sharedWorkspace.branch}` : ""} · ${formatDuration(job.durationMs)}${
lastActivityAge === null
? ""
: ` · last output ${formatDuration(lastActivityAge)} ago`
Expand Down
63 changes: 57 additions & 6 deletions dashboard/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { createSessionRegistry } from "./sessions.js";
import { createExecutorLease } from "./executor-lease.js";
import { spawnWindowsExecutor } from "./windows-executor.js";
import { browserConfigurationErrors } from "./repository-setup.js";
import { prepareSharedWorktree, sharedWorktreeConfig, sharedWorktreeGuidance, verifySharedWorktreeBranch } from "./shared-worktree.js";
const windowsOwnedTrees = new WeakMap();

export const DASHBOARD_HOST = "127.0.0.1";
Expand Down Expand Up @@ -399,6 +400,7 @@ function publicQueue(jobs) {
pullRequestStrategy: job.pullRequestStrategy,
runAllUiScenarios: job.runAllUiScenarios,
targetEnvironment: job.targetEnvironment,
sharedWorkspace: job.sharedWorkspace ?? null,
prestartFrontend: job.prestartFrontend ?? false,
recordPlaywrightVideo: job.recordPlaywrightVideo,
manualLocalhostTest: job.manualLocalhostTest,
Expand Down Expand Up @@ -465,6 +467,7 @@ function dashboardSnapshot(job) {
scenario: safePersistedSummary(job.testEvidence.scenario)
},
branchNaming: job.branchNaming ?? null,
sharedWorkspace: job.sharedWorkspace ?? null,
intakeSource: job.intakeSource,
workItem: persistedWorkItem(job.workItem),
workItems: job.workItems.map(persistedWorkItem),
Expand Down Expand Up @@ -733,6 +736,7 @@ export function inspectRepository(repository) {
throw new Error("profile root must be a JSON object");
}
const liveTestProfile = validateLiveTestProfile(profile, resolvedRepository);
sharedWorktreeConfig(profile, resolvedRepository);
if (!liveTestProfile.ok) {
return {
repository: resolvedRepository,
Expand Down Expand Up @@ -1769,6 +1773,7 @@ function publicJob(job) {
pullRequestStrategy: job.pullRequestStrategy,
runAllUiScenarios: job.runAllUiScenarios,
targetEnvironment: job.targetEnvironment,
sharedWorkspace: job.sharedWorkspace ?? null,
recordPlaywrightVideo: job.recordPlaywrightVideo,
manualLocalhostTest: job.manualLocalhostTest,
manualLocalhostUrl: job.manualLocalhostUrl,
Expand Down Expand Up @@ -2948,6 +2953,7 @@ export function createDashboardServer({
createdAt,
startedAt: status === "running" ? createdAt : null,
heldAt: null,
sharedWorkspace: null,
finishedAt: null,
lastActivityAt: status === "running" ? createdAt : null,
error: null,
Expand Down Expand Up @@ -3017,6 +3023,35 @@ export function createDashboardServer({
let leaseMetadataError = null;
let handle;
const lease = executorLease.acquire(job.id);
try {
if (job.mode === "fix-and-validate") {
job.sharedWorkspace = prepareSharedWorktree({
repository: resolvedRepository,
profile: loadRepositoryProfile(resolvedRepository),
jobId: job.id,
saved: job.sharedWorkspace,
resume,
resourcesRunning: Boolean(frontendConnection.handle) ||
frontendConnection.starting || Boolean(playwrightConnection.handle)
Comment on lines +3034 to +3035
});
if (job.sharedWorkspace) {
persistDashboardState();
const readiness = inspectRepository(resolvedRepository);
Comment on lines +3037 to +3039
if (!readiness.profileReady) {
throw new Error(`Shared checkout configuration is not ready after branch acquisition: ${readiness.error}`);
}
pushLog(job, {
index: job.nextLogIndex,
timestamp: new Date().toISOString(),
stream: "dashboard",
message: `Acquired shared checkout on ${job.sharedWorkspace.branch}; base ${job.sharedWorkspace.remote}/${job.sharedWorkspace.baseBranch} at ${job.sharedWorkspace.baseCommit}.`
});
}
}
} catch (error) {
lease.release();
throw error;
}
const idleCompletion = new Promise((resolveCompletion) => {
resolveIdleCompletion = resolveCompletion;
});
Expand Down Expand Up @@ -3089,9 +3124,9 @@ export function createDashboardServer({
handle = executor({
repository: resolvedRepository,
artifactDirectories: job.artifactDirectory ? [job.artifactDirectory] : [],
prompt: job.prestartFrontend && job.targetEnvironment.toLowerCase() === "local"
prompt: (job.prestartFrontend && job.targetEnvironment.toLowerCase() === "local"
? `${prompt}\nReact warm-up is requested in parallel in the dashboard repository. Before live testing, reuse that server only after its profile health check passes and you verify it serves the checkout containing the changes being validated. If using another isolated worktree, start the correct checkout instead; a healthy baseline server does not prove the fix. Never mark local-stack passed merely because warm-up was requested.`
: prompt,
: prompt) + sharedWorktreeGuidance(job.sharedWorkspace),
sessionId: job.id,
resume,
model: job.model,
Expand Down Expand Up @@ -3150,8 +3185,16 @@ export function createDashboardServer({
if (handle.hasStopped && !handle.hasStopped()) {
throw new Error("Executor exit was observed but owned descendants may still be running; the repository lease remains reserved.");
}
let branchError = null;
try {
verifySharedWorktreeBranch(resolvedRepository, job.sharedWorkspace);
} catch (error) {
branchError = error;
}
lease.release();
return leaseMetadataError ? { code: null, error: leaseMetadataError } : result;
return leaseMetadataError || branchError
? { code: null, error: leaseMetadataError ?? branchError }
: result;
});
activeTermination = actualCompletion;
try {
Expand Down Expand Up @@ -3300,7 +3343,11 @@ export function createDashboardServer({
try {
const owners = await sessions.list();
const conflict = owners.find((owner) =>
owner.id !== sessions.id && owner.available && owner.job?.running
owner.id !== sessions.id && owner.available && (
owner.job?.running ||
(loadRepositoryProfile(resolvedRepository).worktreeSetup?.mode === "shared" &&
(owner.frontend?.running || owner.authentication?.running))
)
);
if (conflict) {
throw new Error(`Queued job cannot start while FixLab dashboard ${conflict.id} owns the repository's agent session. Reconnect or confirm stopping that owner.`);
Expand Down Expand Up @@ -3460,13 +3507,17 @@ export function createDashboardServer({

if (
request.method === "POST" &&
["/api/jobs", "/api/job/input", "/api/frontend/start"].includes(requestUrl.pathname)
["/api/jobs", "/api/job/input", "/api/jobs/resume", "/api/jobs/restart", "/api/frontend/start"].includes(requestUrl.pathname)
) {
try {
const resource = requestUrl.pathname === "/api/frontend/start" ? "frontend" : "job";
const owners = await sessions.list();
const shared = loadRepositoryProfile(resolvedRepository).worktreeSetup?.mode === "shared";
const conflict = owners.find((owner) =>
owner.id !== sessions.id && owner.available && owner[resource]?.running
owner.id !== sessions.id && owner.available && (
owner[resource]?.running ||
(shared && resource === "job" && (owner.frontend?.running || owner.authentication?.running))
)
);
if (conflict) {
sendJson(response, 409, {
Expand Down
148 changes: 148 additions & 0 deletions dashboard/shared-worktree.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { spawnSync } from "node:child_process";
import { existsSync, realpathSync } from "node:fs";
import { isAbsolute, resolve } from "node:path";

function git(repository, args, description) {
const result = spawnSync("git", args, {
cwd: repository,
encoding: "utf8",
windowsHide: true,
timeout: 30000,
maxBuffer: 1024 * 1024,
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GCM_INTERACTIVE: "never" }
});
if (result.status !== 0 || result.error) {
throw new Error(
`${description} failed (${result.error?.code ?? `Git exit ${result.status}`}). ` +
"Resolve the Git prerequisite and retry; FixLab will not reset, stash, clean or force-switch this checkout."
);
}
return result.stdout.trim();
}

function branchRef(repository, branch, description) {
if (typeof branch !== "string" || !branch || branch.startsWith("-")) {
throw new Error(`${description} must be a valid Git branch name`);
}
git(repository, ["check-ref-format", `refs/heads/${branch}`], description);
return branch;
}

export function sharedWorktreeConfig(profile, repository) {
const mode = profile.worktreeSetup?.mode;
if (mode === undefined || mode === "isolated") {
return null;
}
if (mode !== "shared") {
throw new Error("worktreeSetup.mode must be shared or isolated");
}
const root = realpathSync.native(repository);
const topLevel = realpathSync.native(git(root, ["rev-parse", "--show-toplevel"], "Shared worktree repository verification"));
if (root !== topLevel) {
throw new Error("Shared worktree mode requires the Git checkout root");
}
const remote = profile.worktreeSetup.remote ?? "origin";
if (typeof remote !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(remote)) {
throw new Error("worktreeSetup.remote must be a Git remote name");
}
const baseBranch = branchRef(
root,
profile.worktreeSetup.baseBranch ?? profile.pullRequests?.defaultTargetBranch,
"worktreeSetup.baseBranch"
);
const naming = profile.pullRequests?.branchNaming;
if (
typeof naming?.userId !== "string" ||
!/^[A-Za-z0-9._-]+$/u.test(naming.userId) ||
typeof naming.prefixTemplate !== "string" ||
!naming.prefixTemplate.includes("{userId}")
) {
throw new Error("Shared worktree mode requires pullRequests.branchNaming.userId and prefixTemplate");
}
const prefix = branchRef(root, naming.prefixTemplate.replaceAll("{userId}", naming.userId), "Shared branch prefix");
return { remote, baseBranch, prefix };
}

export function prepareSharedWorktree({ repository, profile, jobId, saved = null, resume = false, resourcesRunning = false }) {
const config = sharedWorktreeConfig(profile, repository);
if (!config) {
if (saved) {
throw new Error("This job owns a shared worktree branch; restore shared mode before resuming it");
}
return null;
}
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test(jobId)) {
throw new Error("Shared worktree jobs require a valid job ID");
}
const gitPath = (name) => {
const value = git(repository, ["rev-parse", "--git-path", name], "Git operation verification");
return isAbsolute(value) ? value : resolve(repository, value);
};
if (["MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-merge", "rebase-apply", "sequencer"].some((name) => existsSync(gitPath(name)))) {
throw new Error("Finish the in-progress Git operation before reusing the shared worktree");
}
const currentBranch = git(repository, ["branch", "--show-current"], "Current branch verification");
const dirty = git(repository, ["status", "--porcelain", "--untracked-files=all"], "Shared worktree cleanliness check");
if (saved) {
const branch = branchRef(repository, saved.branch, "Saved job branch");
if (
saved.mode !== "shared" ||
!branch.endsWith(`/fixlab-${jobId}`) ||
!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(saved.baseCommit) ||
typeof saved.remote !== "string" ||
!/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(saved.remote)
) {
throw new Error("Saved shared-worktree ownership is invalid; refusing to select a different branch");
}
branchRef(repository, saved.baseBranch, "Saved base branch");
git(repository, ["rev-parse", "--verify", `refs/heads/${branch}^{commit}`], "Saved branch verification");
git(repository, ["merge-base", "--is-ancestor", saved.baseCommit, `refs/heads/${branch}`], "Saved branch ancestry verification");
if (currentBranch !== branch) {
if (resourcesRunning) {
throw new Error("Stop the shared checkout's owned React and authentication sessions before reacquiring a different job branch");
}
if (dirty) {
throw new Error("The shared worktree has uncommitted or untracked files; preserve them before reacquiring this job's branch");
}
git(repository, ["switch", "--no-guess", "--no-overwrite-ignore", branch], "Saved branch reacquisition");
}
return saved;
}
if (resume) {
throw new Error("This older job has no saved shared-worktree branch. Resume it with its original workspace policy; do not guess its branch");
Comment on lines +111 to +112
}
if (resourcesRunning) {
throw new Error("Stop the shared checkout's owned React and authentication sessions before starting another job branch");
}
if (dirty) {
throw new Error("The shared worktree has uncommitted or untracked files; preserve them before starting another job");
}
const branch = branchRef(repository, `${config.prefix}/fixlab-${jobId}`, "New job branch");
git(repository, [
"fetch", "--no-tags", config.remote,
`+refs/heads/${config.baseBranch}:refs/remotes/${config.remote}/${config.baseBranch}`
], "Shared worktree base refresh");
const baseCommit = git(repository, [
"rev-parse", "--verify", `refs/remotes/${config.remote}/${config.baseBranch}^{commit}`
], "Fetched base verification");
git(repository, ["switch", "--no-guess", "--no-overwrite-ignore", "-c", branch, baseCommit], "New job branch creation");
return { mode: "shared", branch, baseCommit, remote: config.remote, baseBranch: config.baseBranch };
}

export function verifySharedWorktreeBranch(repository, workspace) {
if (workspace && git(repository, ["branch", "--show-current"], "Completed job branch verification") !== workspace.branch) {
throw new Error("The agent changed the shared checkout's assigned branch. Reconcile the saved job branch before resuming; its result cannot be accepted");
}
}

export function sharedWorktreeGuidance(workspace) {
if (!workspace) {
return "";
}
return `\nShared worktree contract:
- The dashboard has exclusively acquired this checkout on branch ${workspace.branch}, based on ${workspace.remote}/${workspace.baseBranch} at ${workspace.baseCommit}.
- Work in this checkout and on this exact branch. Do not create another worktree, switch branches, reset, stash, clean, or merge/rebase the base branch.
- Reuse this job's existing PR on resume. Another job can use the same folder only after owned execution stops and the checkout is clean.
- Check dependency and configuration readiness for the selected branch; cached dependencies are not proof that changed manifests were restored.
- Recheck frontend health and authenticated access after reacquisition. Browser/session state and build outputs are not evidence that a previous branch is still loaded.`;
}
10 changes: 10 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,16 @@ enable resume before the executor's termination promise settles. Unverified
termination, partial reservations and owner crashes remain fail-closed; neither
an HTTP timeout nor PID reuse authorizes automatic reclamation.

An opt-in `worktreeSetup.mode: "shared"` profile lets sequential dashboard jobs
reuse the selected checkout. Admission holds that same executor lease while
checking cleanliness, refreshing the configured remote base and acquiring a
job-specific branch. Its branch and base commit are persisted with the job.
Held/resumed jobs reacquire their saved branch without syncing it to the base;
restarted jobs acquire a new branch. PRs can remain pending while another job
uses the folder, but dirty files, in-progress Git operations and owned
frontend/authentication processes prevent branch changes. The executor must
leave its assigned branch selected. See [shared checkout setup](repository-onboarding.md#reusing-one-checkout-for-sequential-jobs).

Windows executors launch suspended, enter a non-breakaway Windows Job Object,
then resume. Each executor has one hidden console that ordinary tooling
descendants inherit, preventing transient PowerShell and command-prompt windows
Expand Down
Loading
Loading