test(qa): cover session and Workboard managed-worktree lifecycles; fix symlinked state-dir lock blindness (#120362)

* fix(agents): canonicalize managed worktree paths

* test(qa): cover managed worktree owner lifecycles

* test(agents): split worktree canonical-path regression into focused file

* fix(doctor): canonicalize persisted managed-worktree paths for symlinked state dirs

* fix(doctor): keep worktree path migration detection read-only

* test(doctor): bound managed-worktree migration product proof

* fix(doctor): keep worktree path detection from creating state dirs

* test(doctor): allow state migration detection under CI load

* test(agents): split embedded-runner steering runs into focused file

Unblocks the check-lint gate broken by #120285's max-lines overflow.
This commit is contained in:
Peter Steinberger
2026-08-07 18:57:36 -07:00
committed by GitHub
parent 0463d1bd83
commit 6a6cd7859f
16 changed files with 1281 additions and 350 deletions
@@ -0,0 +1,27 @@
title: Managed worktrees session-owner lifecycle
scenario:
id: managed-worktrees-session-lifecycle
surface: runtime
category: agent-runtime.agent-turn-execution
coverage:
primary:
- agent-runtime.managed-worktrees-session-lifecycle
objective: Prove the real child gateway creates and persists a session-owned managed worktree, runs a turn from it, protects it during live-session garbage collection, snapshots and removes clean and dirty checkouts on session deletion, restores dirty state without synthetic branch history, and reports locked checkouts as preserved.
successCriteria:
- sessions.create returns the managed checkout and sessions.list persists its id, branch, and repository root while a real mock-provider turn remains usable.
- Garbage collection returns its structured result while preserving the live session-owned worktree.
- Clean and dirty session deletion remove the checkout with a pinned restorable snapshot, and restore rebuilds dirty untracked state without adding the synthetic snapshot commit to branch history.
- A foreign git worktree lock makes session deletion report worktreePreserved with the checkout still present.
docsRefs:
- docs/concepts/managed-worktrees.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- src/gateway/server-methods/sessions-create.ts
- src/gateway/server-methods/sessions-delete.ts
- src/agents/worktrees/service.ts
- test/e2e/qa-lab/runtime/managed-worktrees-session-lifecycle-product-proof.e2e.test.ts
execution:
kind: vitest
path: test/e2e/qa-lab/runtime/managed-worktrees-session-lifecycle-product-proof.e2e.test.ts
summary: Run the real child gateway through session worktree creation, turn execution, live-owner garbage collection, snapshot deletion and restoration, and locked-checkout preservation.
@@ -0,0 +1,26 @@
title: Managed worktrees Workboard-owner lifecycle
scenario:
id: managed-worktrees-workboard-lifecycle
surface: runtime
category: agent-runtime.agent-turn-execution
coverage:
primary:
- agent-runtime.managed-worktrees-workboard-lifecycle
objective: Prove the real child gateway materializes a Workboard card workspace as a managed wb-<card-id> worktree, writes the resolved checkout and branch back to the card, runs its subagent, and removes the clean checkout losslessly at run end.
successCriteria:
- Dispatch materializes the card's source workspace under the managed state directory as wb-<card-id> on branch openclaw/wb-<card-id>.
- The dispatched card persists the resolved managed checkout path and branch while retaining its source workspace metadata.
- The real mock-provider subagent run reaches a terminal outcome and run-end cleanup removes the clean checkout losslessly.
docsRefs:
- docs/concepts/managed-worktrees.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- src/agents/worktrees/service.ts
- extensions/workboard/src/dispatcher.ts
- extensions/workboard/src/dispatcher-workspace.ts
- test/e2e/qa-lab/runtime/managed-worktrees-workboard-lifecycle-product-proof.e2e.test.ts
execution:
kind: vitest
path: test/e2e/qa-lab/runtime/managed-worktrees-workboard-lifecycle-product-proof.e2e.test.ts
summary: Run the real child gateway through Workboard card worktree materialization, workspace writeback, subagent completion, and run-end lossless removal.
@@ -0,0 +1,329 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createReplyOperation } from "../../auto-reply/reply/reply-run-registry.js";
import { testing as replyRunTesting } from "../../auto-reply/reply/reply-run-registry.test-support.js";
import { setDiagnosticsEnabledForProcess } from "../../infra/diagnostic-events.js";
import { resetDiagnosticRunActivityForTest } from "../../logging/diagnostic-run-activity.js";
import { markDiagnosticToolStartedForTest } from "../../logging/diagnostic-run-activity.test-support.js";
import { resetDiagnosticSessionStateForTest } from "../../logging/diagnostic-session-state.js";
import { queueEmbeddedAgentMessageWithOutcome, setActiveEmbeddedRun } from "./runs.js";
import { testing } from "./runs.test-support.js";
type RunHandle = Parameters<typeof setActiveEmbeddedRun>[1];
function createSteeringRunHandle(
overrides: {
isStreaming?: boolean;
isStopped?: () => boolean;
queueMessage?: RunHandle["queueMessage"];
supportsQueueMessageImages?: boolean;
} = {},
): RunHandle {
return {
queueMessage: overrides.queueMessage ?? (async () => {}),
isStreaming: () => overrides.isStreaming ?? true,
...(overrides.isStopped ? { isStopped: overrides.isStopped } : {}),
isCompacting: () => false,
supportsQueueMessageImages: overrides.supportsQueueMessageImages,
abort: () => {},
};
}
describe("embedded-agent runner steering admission", () => {
afterEach(() => {
testing.resetActiveEmbeddedRuns();
replyRunTesting.resetReplyRunRegistry();
resetDiagnosticSessionStateForTest();
setDiagnosticsEnabledForProcess(false);
vi.restoreAllMocks();
});
it("passes steering options to active embedded runs", () => {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun("session-steer", {
...createSteeringRunHandle(),
sourceReplyDeliveryMode: "message_tool_only",
queueMessage,
});
expect(
queueEmbeddedAgentMessageWithOutcome("session-steer", "continue", {
steeringMode: "all",
sourceReplyDeliveryMode: "message_tool_only",
}).queued,
).toBe(true);
expect(queueMessage).toHaveBeenCalledWith("continue", {
steeringMode: "all",
sourceReplyDeliveryMode: "message_tool_only",
});
});
it("rejects images when the active run cannot preserve them", () => {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun("session-images", {
...createSteeringRunHandle(),
queueMessage,
});
const outcome = queueEmbeddedAgentMessageWithOutcome("session-images", "inspect", {
images: [{ type: "image", data: "png", mimeType: "image/png" }],
});
expect(outcome).toEqual({
queued: false,
sessionId: "session-images",
reason: "image_input_unsupported",
gatewayHealth: "live",
});
expect(queueMessage).not.toHaveBeenCalled();
setActiveEmbeddedRun(
"session-images",
createSteeringRunHandle({ queueMessage, supportsQueueMessageImages: true }),
);
expect(
queueEmbeddedAgentMessageWithOutcome("session-images", "inspect", {
images: [{ type: "image", data: "png", mimeType: "image/png" }],
}).queued,
).toBe(true);
expect(queueMessage).toHaveBeenCalledWith("inspect", {
images: [{ type: "image", data: "png", mimeType: "image/png" }],
});
});
it("rejects message-tool-only steering for active runs created without that mode", () => {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun("session-automatic-source-reply", {
...createSteeringRunHandle(),
queueMessage,
});
const outcome = queueEmbeddedAgentMessageWithOutcome(
"session-automatic-source-reply",
"continue",
{
steeringMode: "all",
sourceReplyDeliveryMode: "message_tool_only",
},
);
expect(outcome).toEqual({
queued: false,
sessionId: "session-automatic-source-reply",
reason: "source_reply_delivery_mode_mismatch",
gatewayHealth: "live",
});
expect(queueMessage).not.toHaveBeenCalled();
});
it.each([
{
label: "capable prompt into an incapable run",
handleMode: undefined,
requestMode: "gateway" as const,
},
{
label: "incapable prompt into a capable run",
handleMode: "gateway" as const,
requestMode: undefined,
},
])("rejects $label", ({ handleMode, requestMode }) => {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun("session-task-suggestions", {
...createSteeringRunHandle(),
taskSuggestionDeliveryMode: handleMode,
queueMessage,
});
const outcome = queueEmbeddedAgentMessageWithOutcome("session-task-suggestions", "continue", {
steeringMode: "all",
taskSuggestionDeliveryMode: requestMode,
});
expect(outcome).toEqual({
queued: false,
sessionId: "session-task-suggestions",
reason: "task_suggestion_delivery_mode_mismatch",
gatewayHealth: "live",
});
expect(queueMessage).not.toHaveBeenCalled();
});
it("defaults active embedded steering to all pending messages", () => {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun("session-default-steer", {
...createSteeringRunHandle(),
queueMessage,
});
expect(queueEmbeddedAgentMessageWithOutcome("session-default-steer", "continue").queued).toBe(
true,
);
expect(queueMessage).toHaveBeenCalledWith("continue", { steeringMode: "all" });
});
it("queues into active non-streaming handles that expose live stopped state", () => {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun(
"session-active-non-streaming",
createSteeringRunHandle({
isStreaming: false,
isStopped: () => false,
queueMessage,
}),
);
expect(
queueEmbeddedAgentMessageWithOutcome("session-active-non-streaming", "continue").queued,
).toBe(true);
expect(queueMessage).toHaveBeenCalledWith("continue", { steeringMode: "all" });
});
it("refuses embedded steering when diagnostic evidence is stale", () => {
vi.useFakeTimers();
try {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun("session-stale-steer", createSteeringRunHandle({ queueMessage }));
vi.advanceTimersByTime(10 * 60_000 + 1);
const outcome = queueEmbeddedAgentMessageWithOutcome("session-stale-steer", "continue");
expect(outcome).toEqual({
queued: false,
sessionId: "session-stale-steer",
reason: "stale_run",
gatewayHealth: "live",
});
expect(queueMessage).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("keeps steering into a quiet tool phase until the blocked-tool floor", () => {
vi.useFakeTimers();
try {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun("session-quiet-tool-steer", createSteeringRunHandle({ queueMessage }));
markDiagnosticToolStartedForTest({
sessionId: "session-quiet-tool-steer",
toolName: "exec",
toolCallId: "tool-quiet-steer",
});
vi.advanceTimersByTime(12 * 60_000);
expect(
queueEmbeddedAgentMessageWithOutcome("session-quiet-tool-steer", "status?").queued,
).toBe(true);
vi.advanceTimersByTime(4 * 60_000);
const late = queueEmbeddedAgentMessageWithOutcome("session-quiet-tool-steer", "status?");
expect(late).toMatchObject({ queued: false, reason: "stale_run" });
} finally {
vi.useRealTimers();
}
});
it("refuses reply-backed steering with stale registry evidence as stale_run", () => {
vi.useFakeTimers();
try {
const operation = createReplyOperation({
sessionKey: "agent:main:cli-stale-steer",
sessionId: "session-cli-stale-steer",
resetTriggered: false,
});
operation.attachBackend({
kind: "cli",
cancel: () => {},
isStreaming: () => true,
});
operation.setPhase("running");
vi.advanceTimersByTime(10 * 60_000 + 1);
const outcome = queueEmbeddedAgentMessageWithOutcome("session-cli-stale-steer", "hello");
expect(outcome).toEqual({
queued: false,
sessionId: "session-cli-stale-steer",
reason: "stale_run",
gatewayHealth: "live",
});
operation.complete();
} finally {
vi.useRealTimers();
}
});
it("accepts embedded steering with fresh or missing diagnostic evidence", () => {
const freshQueueMessage = vi.fn(async () => {});
setActiveEmbeddedRun(
"session-fresh-steer",
createSteeringRunHandle({ queueMessage: freshQueueMessage }),
);
expect(queueEmbeddedAgentMessageWithOutcome("session-fresh-steer", "continue").queued).toBe(
true,
);
expect(freshQueueMessage).toHaveBeenCalledWith("continue", { steeringMode: "all" });
const missingSnapshotQueueMessage = vi.fn(async () => {});
setActiveEmbeddedRun(
"session-no-diagnostic-snapshot",
createSteeringRunHandle({ queueMessage: missingSnapshotQueueMessage }),
);
resetDiagnosticRunActivityForTest();
expect(
queueEmbeddedAgentMessageWithOutcome("session-no-diagnostic-snapshot", "continue").queued,
).toBe(true);
expect(missingSnapshotQueueMessage).toHaveBeenCalledWith("continue", { steeringMode: "all" });
});
it("does not queue into stopped handles", () => {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun(
"session-stopped",
createSteeringRunHandle({
isStreaming: true,
isStopped: () => true,
queueMessage,
}),
);
const outcome = queueEmbeddedAgentMessageWithOutcome("session-stopped", "continue");
expect(outcome).toEqual({
queued: false,
sessionId: "session-stopped",
reason: "not_streaming",
gatewayHealth: "live",
});
expect(queueMessage).not.toHaveBeenCalled();
});
it("fails closed when stopped state checks throw", () => {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun(
"session-bad-state",
createSteeringRunHandle({
isStopped: () => {
throw new Error("bad stopped state");
},
queueMessage,
}),
);
const outcome = queueEmbeddedAgentMessageWithOutcome("session-bad-state", "continue");
expect(outcome).toEqual({
queued: false,
sessionId: "session-bad-state",
reason: "not_streaming",
gatewayHealth: "live",
});
expect(queueMessage).not.toHaveBeenCalled();
});
});
@@ -7,8 +7,6 @@ import {
} from "../../auto-reply/reply/reply-run-registry.js";
import { testing as replyRunTesting } from "../../auto-reply/reply/reply-run-registry.test-support.js";
import { setDiagnosticsEnabledForProcess } from "../../infra/diagnostic-events.js";
import { resetDiagnosticRunActivityForTest } from "../../logging/diagnostic-run-activity.js";
import { markDiagnosticToolStartedForTest } from "../../logging/diagnostic-run-activity.test-support.js";
import {
getDiagnosticSessionState,
resetDiagnosticSessionStateForTest,
@@ -396,296 +394,6 @@ describe("embedded-agent runner run registry", () => {
);
});
it("passes steering options to active embedded runs", () => {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun("session-steer", {
...createRunHandle(),
sourceReplyDeliveryMode: "message_tool_only",
queueMessage,
});
expect(
queueEmbeddedAgentMessageWithOutcome("session-steer", "continue", {
steeringMode: "all",
sourceReplyDeliveryMode: "message_tool_only",
}).queued,
).toBe(true);
expect(queueMessage).toHaveBeenCalledWith("continue", {
steeringMode: "all",
sourceReplyDeliveryMode: "message_tool_only",
});
});
it("rejects images when the active run cannot preserve them", () => {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun("session-images", {
...createRunHandle(),
queueMessage,
});
const outcome = queueEmbeddedAgentMessageWithOutcome("session-images", "inspect", {
images: [{ type: "image", data: "png", mimeType: "image/png" }],
});
expect(outcome).toEqual({
queued: false,
sessionId: "session-images",
reason: "image_input_unsupported",
gatewayHealth: "live",
});
expect(queueMessage).not.toHaveBeenCalled();
setActiveEmbeddedRun(
"session-images",
createRunHandle({ queueMessage, supportsQueueMessageImages: true }),
);
expect(
queueEmbeddedAgentMessageWithOutcome("session-images", "inspect", {
images: [{ type: "image", data: "png", mimeType: "image/png" }],
}).queued,
).toBe(true);
expect(queueMessage).toHaveBeenCalledWith("inspect", {
images: [{ type: "image", data: "png", mimeType: "image/png" }],
});
});
it("rejects message-tool-only steering for active runs created without that mode", () => {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun("session-automatic-source-reply", {
...createRunHandle(),
queueMessage,
});
const outcome = queueEmbeddedAgentMessageWithOutcome(
"session-automatic-source-reply",
"continue",
{
steeringMode: "all",
sourceReplyDeliveryMode: "message_tool_only",
},
);
expect(outcome).toEqual({
queued: false,
sessionId: "session-automatic-source-reply",
reason: "source_reply_delivery_mode_mismatch",
gatewayHealth: "live",
});
expect(queueMessage).not.toHaveBeenCalled();
});
it.each([
{
label: "capable prompt into an incapable run",
handleMode: undefined,
requestMode: "gateway" as const,
},
{
label: "incapable prompt into a capable run",
handleMode: "gateway" as const,
requestMode: undefined,
},
])("rejects $label", ({ handleMode, requestMode }) => {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun("session-task-suggestions", {
...createRunHandle(),
taskSuggestionDeliveryMode: handleMode,
queueMessage,
});
const outcome = queueEmbeddedAgentMessageWithOutcome("session-task-suggestions", "continue", {
steeringMode: "all",
taskSuggestionDeliveryMode: requestMode,
});
expect(outcome).toEqual({
queued: false,
sessionId: "session-task-suggestions",
reason: "task_suggestion_delivery_mode_mismatch",
gatewayHealth: "live",
});
expect(queueMessage).not.toHaveBeenCalled();
});
it("defaults active embedded steering to all pending messages", () => {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun("session-default-steer", {
...createRunHandle(),
queueMessage,
});
expect(queueEmbeddedAgentMessageWithOutcome("session-default-steer", "continue").queued).toBe(
true,
);
expect(queueMessage).toHaveBeenCalledWith("continue", { steeringMode: "all" });
});
it("queues into active non-streaming handles that expose live stopped state", () => {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun(
"session-active-non-streaming",
createRunHandle({
isStreaming: false,
isStopped: () => false,
queueMessage,
}),
);
expect(
queueEmbeddedAgentMessageWithOutcome("session-active-non-streaming", "continue").queued,
).toBe(true);
expect(queueMessage).toHaveBeenCalledWith("continue", { steeringMode: "all" });
});
it("refuses embedded steering when diagnostic evidence is stale", () => {
vi.useFakeTimers();
try {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun("session-stale-steer", createRunHandle({ queueMessage }));
vi.advanceTimersByTime(10 * 60_000 + 1);
const outcome = queueEmbeddedAgentMessageWithOutcome("session-stale-steer", "continue");
expect(outcome).toEqual({
queued: false,
sessionId: "session-stale-steer",
reason: "stale_run",
gatewayHealth: "live",
});
expect(queueMessage).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("keeps steering into a quiet tool phase until the blocked-tool floor", () => {
vi.useFakeTimers();
try {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun("session-quiet-tool-steer", createRunHandle({ queueMessage }));
markDiagnosticToolStartedForTest({
sessionId: "session-quiet-tool-steer",
toolName: "exec",
toolCallId: "tool-quiet-steer",
});
vi.advanceTimersByTime(12 * 60_000);
expect(
queueEmbeddedAgentMessageWithOutcome("session-quiet-tool-steer", "status?").queued,
).toBe(true);
vi.advanceTimersByTime(4 * 60_000);
const late = queueEmbeddedAgentMessageWithOutcome("session-quiet-tool-steer", "status?");
expect(late).toMatchObject({ queued: false, reason: "stale_run" });
} finally {
vi.useRealTimers();
}
});
it("refuses reply-backed steering with stale registry evidence as stale_run", () => {
vi.useFakeTimers();
try {
const operation = createReplyOperation({
sessionKey: "agent:main:cli-stale-steer",
sessionId: "session-cli-stale-steer",
resetTriggered: false,
});
operation.attachBackend({
kind: "cli",
cancel: () => {},
isStreaming: () => true,
});
operation.setPhase("running");
vi.advanceTimersByTime(10 * 60_000 + 1);
const outcome = queueEmbeddedAgentMessageWithOutcome("session-cli-stale-steer", "hello");
expect(outcome).toEqual({
queued: false,
sessionId: "session-cli-stale-steer",
reason: "stale_run",
gatewayHealth: "live",
});
operation.complete();
} finally {
vi.useRealTimers();
}
});
it("accepts embedded steering with fresh or missing diagnostic evidence", () => {
const freshQueueMessage = vi.fn(async () => {});
setActiveEmbeddedRun(
"session-fresh-steer",
createRunHandle({ queueMessage: freshQueueMessage }),
);
expect(queueEmbeddedAgentMessageWithOutcome("session-fresh-steer", "continue").queued).toBe(
true,
);
expect(freshQueueMessage).toHaveBeenCalledWith("continue", { steeringMode: "all" });
const missingSnapshotQueueMessage = vi.fn(async () => {});
setActiveEmbeddedRun(
"session-no-diagnostic-snapshot",
createRunHandle({ queueMessage: missingSnapshotQueueMessage }),
);
resetDiagnosticRunActivityForTest();
expect(
queueEmbeddedAgentMessageWithOutcome("session-no-diagnostic-snapshot", "continue").queued,
).toBe(true);
expect(missingSnapshotQueueMessage).toHaveBeenCalledWith("continue", { steeringMode: "all" });
});
it("does not queue into stopped handles", () => {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun(
"session-stopped",
createRunHandle({
isStreaming: true,
isStopped: () => true,
queueMessage,
}),
);
const outcome = queueEmbeddedAgentMessageWithOutcome("session-stopped", "continue");
expect(outcome).toEqual({
queued: false,
sessionId: "session-stopped",
reason: "not_streaming",
gatewayHealth: "live",
});
expect(queueMessage).not.toHaveBeenCalled();
});
it("fails closed when stopped state checks throw", () => {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun(
"session-bad-state",
createRunHandle({
isStopped: () => {
throw new Error("bad stopped state");
},
queueMessage,
}),
);
const outcome = queueEmbeddedAgentMessageWithOutcome("session-bad-state", "continue");
expect(outcome).toEqual({
queued: false,
sessionId: "session-bad-state",
reason: "not_streaming",
gatewayHealth: "live",
});
expect(queueMessage).not.toHaveBeenCalled();
});
it("returns a structured no-active-run queue failure", () => {
const outcome = queueEmbeddedAgentMessageWithOutcome("session-missing", "continue");
+52
View File
@@ -2,6 +2,8 @@ import type { DatabaseSync } from "node:sqlite";
import type { Insertable, Selectable } from "kysely";
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../../infra/kysely-sync.js";
import { isLockOwnerDefinitelyStale } from "../../infra/stale-lock-file.js";
import { withExistingOpenClawStateDatabaseReadOnly } from "../../state/openclaw-state-db-readonly.js";
import { tableExists } from "../../state/openclaw-state-db-schema-helpers.js";
import type { DB as OpenClawStateKyselyDatabase } from "../../state/openclaw-state-db.generated.js";
import {
openOpenClawStateDatabase,
@@ -119,6 +121,25 @@ export function listRegistryWorktrees(env: NodeJS.ProcessEnv): ManagedWorktreeRe
return executeSqliteQuerySync(db, query).rows.map(rowToRecord);
}
export function listRegistryWorktreesForMigration(env: NodeJS.ProcessEnv): ManagedWorktreeRecord[] {
return (
withExistingOpenClawStateDatabaseReadOnly(
({ db }) => {
if (!tableExists(db, "worktrees")) {
return [];
}
const query = kyselyFor(db)
.selectFrom("worktrees")
.selectAll()
.orderBy("created_at", "desc")
.orderBy("id", "asc");
return executeSqliteQuerySync(db, query).rows.map(rowToRecord);
},
{ env },
) ?? []
);
}
export function getRegistryWorktree(
env: NodeJS.ProcessEnv,
id: string,
@@ -170,6 +191,37 @@ export function discardLegacyRegistryWorktrees(env: NodeJS.ProcessEnv): number {
);
}
export function rewriteRegistryWorktreePathsForMigration(
env: NodeJS.ProcessEnv,
rewrites: readonly { id: string; fromPath: string; toPath: string }[],
): number {
if (rewrites.length === 0) {
return 0;
}
const db = dbFor(env);
// Only the state-migration owner may rewrite persisted worktree identity paths.
// Runtime updates deliberately keep `path` outside their patch surface.
return runOpenClawStateWriteTransaction(
() =>
rewrites.reduce(
(count, rewrite) =>
count +
Number(
executeSqliteQuerySync(
db,
kyselyFor(db)
.updateTable("worktrees")
.set({ path: rewrite.toPath })
.where("id", "=", rewrite.id)
.where("path", "=", rewrite.fromPath),
).numAffectedRows ?? 0n,
),
0,
),
{ env },
);
}
export function getRegistryWorktreeProvisionedState(
env: NodeJS.ProcessEnv,
id: string,
@@ -0,0 +1,91 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
import { ManagedWorktreeService } from "./service.js";
import { initializeManagedWorktreeTestRepository } from "./service.test-support.js";
const execFileAsync = promisify(execFile);
async function git(cwd: string, ...args: string[]): Promise<string> {
const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], {
encoding: "utf8",
});
return stdout.trim();
}
describe("ManagedWorktreeService canonical paths", () => {
let root: string;
let repo: string;
let stateDir: string;
let service: ManagedWorktreeService;
beforeEach(async () => {
root = await fs.mkdtemp(
path.join(await fs.realpath(os.tmpdir()), "openclaw-worktree-canonical-paths-"),
);
repo = await initializeManagedWorktreeTestRepository(root);
stateDir = path.join(root, "state");
await fs.mkdir(stateDir, { recursive: true });
service = new ManagedWorktreeService({
env: { ...process.env, OPENCLAW_STATE_DIR: stateDir },
});
});
afterEach(async () => {
closeOpenClawStateDatabaseForTest();
await fs.rm(root, { recursive: true, force: true });
});
it("keeps registry operations anchored to the primary checkout", async () => {
const linked = path.join(root, "linked-source");
await git(repo, "worktree", "add", "-b", "linked-source", linked, "HEAD");
const linkedRoot = await fs.realpath(linked);
const created = await service.create({
repoRoot: linkedRoot,
name: "linked-task",
baseRef: "HEAD",
});
expect(created.repoRoot).toBe(repo);
await git(repo, "worktree", "remove", "--force", linkedRoot);
await service.acquire(created.id);
await service.release(created.id);
await service.remove({ id: created.id, reason: "linked-source-removed" });
const restored = await service.restore({ id: created.id });
expect(await fs.readFile(path.join(restored.path, "README.md"), "utf8")).toBe("base\n");
});
it.skipIf(process.platform === "win32")(
"canonicalizes managed paths minted below a symlinked state directory",
async () => {
const realStateDir = await fs.mkdtemp(path.join(root, "real-state-"));
const linkedStateDir = path.join(root, "linked-state");
await fs.symlink(realStateDir, linkedStateDir, "dir");
const linkedStateService = new ManagedWorktreeService({
env: { ...process.env, OPENCLAW_STATE_DIR: linkedStateDir },
});
const created = await linkedStateService.create({
repoRoot: repo,
name: "canonical-state",
baseRef: "HEAD",
});
const expectedPath = path.join(
await fs.realpath(realStateDir),
"worktrees",
created.repoFingerprint,
"canonical-state",
);
expect(created.path).toBe(expectedPath);
await linkedStateService.acquire(created.id);
await expect(linkedStateService.removeIfLossless(created.id)).resolves.toBe(true);
await expect(fs.stat(expectedPath)).rejects.toMatchObject({ code: "ENOENT" });
},
);
});
@@ -12,6 +12,22 @@ async function git(cwd: string, ...args: string[]): Promise<void> {
await execFileAsync("git", ["-C", cwd, ...args]);
}
export async function initializeManagedWorktreeTestRepository(root: string): Promise<string> {
const repo = path.join(root, "repo");
const remote = path.join(root, "remote.git");
await fs.mkdir(repo, { recursive: true });
await git(repo, "init", "-b", "main");
await git(repo, "config", "user.name", "OpenClaw Test");
await git(repo, "config", "user.email", "openclaw-test@example.invalid");
await fs.writeFile(path.join(repo, "README.md"), "base\n");
await git(repo, "add", "README.md");
await git(repo, "commit", "-m", "initial");
await git(root, "init", "--bare", remote);
await git(repo, "remote", "add", "origin", remote);
await git(repo, "push", "-u", "origin", "main");
return await fs.realpath(repo);
}
async function copyProvisionedFiles(params: {
repoRoot: string;
worktreePath: string;
+3 -23
View File
@@ -341,9 +341,9 @@ describe("ManagedWorktreeService", () => {
expect(await git(repo, "worktree", "list", "--porcelain")).toBe(before);
expect(await git(repo, "branch", "--list", `openclaw/${name}`)).toBe("");
expect(await service.list()).toEqual([]);
await expect(fs.stat(path.join(env.OPENCLAW_STATE_DIR!, "worktrees"))).rejects.toMatchObject({
code: "ENOENT",
});
await expect(fs.readdir(path.join(env.OPENCLAW_STATE_DIR!, "worktrees"))).resolves.toEqual(
[],
);
},
);
@@ -405,26 +405,6 @@ describe("ManagedWorktreeService", () => {
expect(await fs.readFile(path.join(created.path, "README.md"), "utf8")).toBe("base\n");
});
it("keeps registry operations anchored to the primary checkout", async () => {
const linked = path.join(root, "linked-source");
await git(repo, "worktree", "add", "-b", "linked-source", linked, "HEAD");
const linkedRoot = await fs.realpath(linked);
const created = await service.create({
repoRoot: linkedRoot,
name: "linked-task",
baseRef: "HEAD",
});
expect(created.repoRoot).toBe(repo);
await git(repo, "worktree", "remove", "--force", linkedRoot);
await service.acquire(created.id);
await service.release(created.id);
await service.remove({ id: created.id, reason: "linked-source-removed" });
const restored = await service.restore({ id: created.id });
expect(await fs.readFile(path.join(restored.path, "README.md"), "utf8")).toBe("base\n");
});
it("retries worktree add from local HEAD when the resolved remote base is stale", async () => {
await addRemote(root, repo);
const blob = await git(repo, "rev-parse", "HEAD:README.md");
+11 -6
View File
@@ -570,6 +570,14 @@ export class ManagedWorktreeService {
this.now = options.now ?? Date.now;
}
private async worktreesRoot(): Promise<string> {
const root = path.join(resolveStateDir(this.env), "worktrees");
await fs.mkdir(root, { recursive: true });
// Git canonicalizes paths in `git worktree list`; minting below the real root keeps
// lock-state and adoption comparisons aligned when the state path traverses symlinks.
return await fs.realpath(root);
}
async create(params: CreateManagedWorktreeParams): Promise<ManagedWorktreeRecord> {
const repository = await resolveRepository(params.repoRoot);
if (params.ownerId) {
@@ -633,7 +641,7 @@ export class ManagedWorktreeService {
repository: Awaited<ReturnType<typeof resolveRepository>>,
inferredName: string,
): Promise<ManagedWorktreeRecord> {
const root = path.join(resolveStateDir(this.env), "worktrees", repository.fingerprint);
const root = path.join(await this.worktreesRoot(), repository.fingerprint);
const name = validateName(
params.name ??
(await generateName(
@@ -962,10 +970,7 @@ export class ManagedWorktreeService {
throw commandError("git branch -D", branchDelete);
}
await requireGit(record.repoRoot, ["worktree", "prune"]);
await removeEmptyParents(
path.dirname(record.path),
path.join(resolveStateDir(this.env), "worktrees"),
);
await removeEmptyParents(path.dirname(record.path), await this.worktreesRoot());
const removedAt = this.now();
updateRegistryWorktree(this.env, record.id, { removedAt, snapshotRef });
finalizeWorktreeRemoval(this.env, record.id);
@@ -1283,7 +1288,7 @@ export class ManagedWorktreeService {
}
}
}
const worktreesRoot = path.join(resolveStateDir(this.env), "worktrees");
const worktreesRoot = await this.worktreesRoot();
const fingerprints = await fs.readdir(worktreesRoot, { withFileTypes: true }).catch(() => []);
let deleted = 0;
for (const fingerprint of fingerprints) {
+1 -1
View File
@@ -267,7 +267,7 @@ function createLegacyStateMigrationDetectionResult(params?: {
hasLegacy: false,
preview: [],
},
worktrees: { hasLegacy: false },
worktrees: { hasLegacy: false, pathRewrites: [] },
taskStateSidecars: {
taskRunsPath: "/tmp/state/tasks/runs.sqlite",
flowRunsPath: "/tmp/state/flows/registry.sqlite",
+96 -27
View File
@@ -1,9 +1,12 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import {
discardLegacyRegistryWorktrees,
hasLegacyRegistryWorktrees,
listRegistryWorktreesForMigration,
rewriteRegistryWorktreePathsForMigration,
} from "../agents/worktrees/registry.js";
import { listBundledChannelLegacyStateMigrationDetectors } from "../channels/plugins/bundled.js";
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
@@ -315,6 +318,57 @@ function resolveConcreteBindingAccountId(value: unknown): string | undefined {
return accountId && accountId !== "*" ? accountId : undefined;
}
async function detectManagedWorktreeStateMigration(params: {
env: NodeJS.ProcessEnv;
stateDir: string;
stateSchemaMigrations: readonly OpenClawStateDatabaseSchemaMigration[];
doctorOnlyStateMigrations?: boolean;
}): Promise<LegacyStateDetection["worktrees"]> {
const rawRoot = path.join(params.stateDir, "worktrees");
const stateEnv = { ...params.env, OPENCLAW_STATE_DIR: params.stateDir };
const databaseExists = fileExists(resolveOpenClawStateSqlitePath(stateEnv));
const hasCurrentSchema = params.stateSchemaMigrations.length === 0;
const hasLegacy =
params.doctorOnlyStateMigrations === true &&
hasCurrentSchema &&
databaseExists &&
hasLegacyRegistryWorktrees(stateEnv);
// Detection is read-only for the doctor --lint contract. ManagedWorktreeService.worktreesRoot()
// owns directory creation; absent roots are canonicalized through their existing state parent.
let canonicalRoot: string;
try {
canonicalRoot = await fs.realpath(rawRoot);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
try {
canonicalRoot = path.join(await fs.realpath(params.stateDir), "worktrees");
} catch (stateDirError) {
if ((stateDirError as NodeJS.ErrnoException).code === "ENOENT") {
return { hasLegacy, pathRewrites: [] };
}
throw stateDirError;
}
}
if (rawRoot === canonicalRoot || !hasCurrentSchema || !databaseExists) {
return { hasLegacy, pathRewrites: [] };
}
const pathRewrites = listRegistryWorktreesForMigration(stateEnv).flatMap((row) => {
const fromPath = path.join(rawRoot, row.repoFingerprint, row.name);
return row.path === fromPath
? [
{
id: row.id,
fromPath,
toPath: path.join(canonicalRoot, row.repoFingerprint, row.name),
},
]
: [];
});
return { hasLegacy, pathRewrites };
}
export async function detectLegacyStateMigrations(params: {
cfg: OpenClawConfig;
pluginDoctorConfig?: OpenClawConfig;
@@ -414,12 +468,12 @@ export async function detectLegacyStateMigrations(params: {
const stateSchemaMigrations = detectOpenClawStateDatabaseSchemaMigrations({
env: { ...env, OPENCLAW_STATE_DIR: stateDir },
});
const stateEnv = { ...env, OPENCLAW_STATE_DIR: stateDir };
const hasLegacyWorktrees =
params.doctorOnlyStateMigrations === true &&
stateSchemaMigrations.length === 0 &&
fileExists(resolveOpenClawStateSqlitePath(stateEnv)) &&
hasLegacyRegistryWorktrees(stateEnv);
const worktrees = await detectManagedWorktreeStateMigration({
env,
stateDir,
stateSchemaMigrations,
doctorOnlyStateMigrations: params.doctorOnlyStateMigrations,
});
const taskRunsSidecarPath = resolveLegacyTaskRunsSidecarPath(stateDir);
const flowRunsSidecarPath = resolveLegacyFlowRunsSidecarPath(stateDir);
const hasPendingTaskRunsSidecarArchive = hasPendingSqliteSidecarArchive(
@@ -619,9 +673,14 @@ export async function detectLegacyStateMigrations(params: {
"- Rerun doctor after shared SQLite schema repair to detect plugin state migrations",
);
}
if (hasLegacyWorktrees) {
if (worktrees.hasLegacy) {
preview.push("- Managed worktrees: discard rows without provisioned-file ledgers");
}
if (worktrees.pathRewrites.length > 0) {
preview.push(
`- Managed worktrees: canonicalize ${worktrees.pathRewrites.length} persisted ${worktrees.pathRewrites.length === 1 ? "path" : "paths"} for symlinked state directories`,
);
}
if (fileExists(taskRunsSidecarPath)) {
preview.push(`- Task registry sidecar: ${taskRunsSidecarPath} → shared SQLite state`);
} else if (hasPendingTaskRunsSidecarArchive) {
@@ -745,7 +804,7 @@ export async function detectLegacyStateMigrations(params: {
hasLegacy: stateSchemaMigrations.length > 0,
preview: stateSchemaMigrations.map((migration) => migration.path),
},
worktrees: { hasLegacy: hasLegacyWorktrees },
worktrees,
taskStateSidecars: {
taskRunsPath: taskRunsSidecarPath,
flowRunsPath: flowRunsSidecarPath,
@@ -1020,24 +1079,32 @@ function buildLegacyStateMigrationSteps(
run: () => migrate({ detected: detection, env, stateDir }),
});
const doctorPrelude: LegacyStateMigrationStep[] = isDoctor
? [
finalStep(() => {
const discardedWorktrees = detected.worktrees.hasLegacy
? discardLegacyRegistryWorktrees({ ...env, OPENCLAW_STATE_DIR: stateDir })
: 0;
return {
changes:
discardedWorktrees > 0
? [
`Discarded ${discardedWorktrees} legacy managed worktree ${discardedWorktrees === 1 ? "row" : "rows"}; affected worktrees will provision fresh on next use`,
]
: [],
warnings: [],
};
}),
]
: [];
const managedWorktreePrelude: LegacyStateMigrationStep[] = [
finalStep(() => {
const stateEnv = { ...env, OPENCLAW_STATE_DIR: stateDir };
const discardedWorktrees =
isDoctor && detected.worktrees.hasLegacy ? discardLegacyRegistryWorktrees(stateEnv) : 0;
const canonicalizedWorktrees = rewriteRegistryWorktreePathsForMigration(
stateEnv,
detected.worktrees.pathRewrites,
);
return {
changes: [
...(discardedWorktrees > 0
? [
`Discarded ${discardedWorktrees} legacy managed worktree ${discardedWorktrees === 1 ? "row" : "rows"}; affected worktrees will provision fresh on next use`,
]
: []),
...(canonicalizedWorktrees > 0
? [
`Canonicalized ${canonicalizedWorktrees} managed worktree ${canonicalizedWorktrees === 1 ? "path" : "paths"} for symlinked state directories`,
]
: []),
],
warnings: [],
};
}),
];
const sharedSteps: LegacyStateMigrationStep[] = [
sharedStep(() => migrateLegacyPluginStateSidecar({ stateDir })),
@@ -1157,7 +1224,7 @@ function buildLegacyStateMigrationSteps(
);
}
return [...doctorPrelude, ...sharedSteps, ...doctorStateSteps, ...finalSteps];
return [...managedWorktreePrelude, ...sharedSteps, ...doctorStateSteps, ...finalSteps];
}
async function runLegacyStateMigrationSteps(steps: readonly LegacyStateMigrationStep[]): Promise<{
@@ -1409,6 +1476,8 @@ export async function autoMigrateLegacyState(params: {
!detected.pluginInstallIndex.hasLegacy &&
!detected.debugProxyCaptureSidecar.hasLegacy &&
!detected.stateSchema.hasLegacy &&
!detected.worktrees.hasLegacy &&
detected.worktrees.pathRewrites.length === 0 &&
!detected.taskStateSidecars.hasLegacy &&
!detected.deliveryQueues.hasLegacy &&
!detected.voiceWake.hasLegacy &&
+4 -1
View File
@@ -69,7 +69,10 @@ export type LegacyStateDetection = {
hasLegacy: boolean;
preview: string[];
};
worktrees: { hasLegacy: boolean };
worktrees: {
hasLegacy: boolean;
pathRewrites: Array<{ id: string; fromPath: string; toPath: string }>;
};
taskStateSidecars: {
taskRunsPath: string;
flowRunsPath: string;
@@ -0,0 +1,131 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { getRegistryWorktree, insertRegistryWorktree } from "../agents/worktrees/registry.js";
import { ManagedWorktreeService } from "../agents/worktrees/service.js";
import { initializeManagedWorktreeTestRepository } from "../agents/worktrees/service.test-support.js";
import type { OpenClawConfig } from "../config/config.js";
import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
} from "../state/openclaw-state-db.js";
import { detectLegacyStateMigrations, runLegacyStateMigrations } from "./state-migrations.js";
describe("managed worktree path state migrations", () => {
const tempDirs = useAutoCleanupTempDirTracker((cleanup) => {
afterEach(() => {
closeOpenClawStateDatabaseForTest();
cleanup();
});
});
it("does not create the worktrees directory during detection", { timeout: 240_000 }, async () => {
const root = tempDirs.make("openclaw-worktree-path-detection-");
const stateDir = path.join(root, "state");
const worktreesDir = path.join(stateDir, "worktrees");
await fs.mkdir(stateDir, { recursive: true });
const env = { ...process.env, HOME: root, OPENCLAW_STATE_DIR: stateDir };
const detected = await detectLegacyStateMigrations({
cfg: {} as OpenClawConfig,
env,
homedir: () => root,
});
expect(detected.worktrees.pathRewrites).toStrictEqual([]);
await expect(fs.stat(worktreesDir)).rejects.toMatchObject({ code: "ENOENT" });
});
it.skipIf(process.platform === "win32")(
"canonicalizes persisted paths from symlinked state directories",
{ timeout: 240_000 },
async () => {
const root = tempDirs.make(
"openclaw-worktree-path-migration-",
await fs.realpath(os.tmpdir()),
);
const repo = await initializeManagedWorktreeTestRepository(root);
const realStateDir = path.join(root, "real-state");
const linkedStateDir = path.join(root, "linked-state");
await fs.mkdir(realStateDir, { recursive: true });
await fs.symlink(realStateDir, linkedStateDir, "dir");
const env = { ...process.env, HOME: root, OPENCLAW_STATE_DIR: linkedStateDir };
const service = new ManagedWorktreeService({ env });
const live = await service.create({ repoRoot: repo, name: "live", baseRef: "HEAD" });
const canonicalRoot = path.dirname(path.dirname(live.path));
const rawLivePath = path.join(linkedStateDir, "worktrees", live.repoFingerprint, live.name);
const rawRemovedPath = path.join(
linkedStateDir,
"worktrees",
live.repoFingerprint,
"removed",
);
const db = openOpenClawStateDatabase({ env }).db;
db.prepare("UPDATE worktrees SET path = ? WHERE id = ?").run(rawLivePath, live.id);
const removed = {
...live,
id: "legacy-removed",
name: "removed",
path: rawRemovedPath,
branch: "openclaw/removed",
removedAt: 1,
};
const canonical = {
...live,
id: "canonical-row",
name: "canonical",
path: path.join(canonicalRoot, live.repoFingerprint, "canonical"),
branch: "openclaw/canonical",
};
const movedPath = path.join(root, "relocated-worktrees", "moved");
const moved = {
...live,
id: "moved-row",
name: "moved",
path: movedPath,
branch: "openclaw/moved",
};
insertRegistryWorktree(env, removed, { provisionedPaths: [] });
insertRegistryWorktree(env, canonical, { provisionedPaths: [] });
insertRegistryWorktree(env, moved, { provisionedPaths: [] });
const cfg = {} as OpenClawConfig;
const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root });
expect(detected.preview).toContain(
"- Managed worktrees: canonicalize 2 persisted paths for symlinked state directories",
);
const result = await runLegacyStateMigrations({ detected, config: cfg, env });
expect(result.warnings).toStrictEqual([]);
expect(result.changes).toContain(
"Canonicalized 2 managed worktree paths for symlinked state directories",
);
expect(getRegistryWorktree(env, live.id)?.path).toBe(live.path);
expect(getRegistryWorktree(env, removed.id)?.path).toBe(
path.join(canonicalRoot, live.repoFingerprint, removed.name),
);
expect(getRegistryWorktree(env, canonical.id)?.path).toBe(canonical.path);
expect(getRegistryWorktree(env, moved.id)?.path).toBe(movedPath);
const secondDetection = await detectLegacyStateMigrations({
cfg,
env,
homedir: () => root,
});
expect(secondDetection.worktrees.pathRewrites).toStrictEqual([]);
const secondResult = await runLegacyStateMigrations({
detected: secondDetection,
config: cfg,
env,
});
expect(secondResult.changes).not.toContain(
"Canonicalized 2 managed worktree paths for symlinked state directories",
);
await service.acquire(live.id);
await expect(service.removeIfLossless(live.id)).resolves.toBe(true);
await expect(fs.stat(live.path)).rejects.toMatchObject({ code: "ENOENT" });
},
);
});
+6
View File
@@ -1533,6 +1533,12 @@ surfaces:
- name: Managed worktrees
coverageIds: [agent-runtime.managed-worktrees-lifecycle]
description: Managed worktree create with ignored-file provisioning and repository setup hooks, dirty-removal snapshots, snapshot restore, and manual-owner gc retention.
- name: "Managed worktrees — Session owner"
coverageIds: [agent-runtime.managed-worktrees-session-lifecycle]
description: Session worktree creation through sessions.create, persisted session worktree metadata, gc owner protection while the session is live, session-delete snapshot removal, and worktreePreserved reporting for locked checkouts.
- name: "Managed worktrees — Workboard owner"
coverageIds: [agent-runtime.managed-worktrees-workboard-lifecycle]
description: Workboard card workspace materialization as wb-<card-id> worktrees, card workspace writeback, and run-end lossless removal.
docs:
- docs/concepts/agent-loop.md
- docs/cli/agent.md
@@ -0,0 +1,250 @@
// QA Lab product proof for the session-owned managed-worktree lifecycle.
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import { startQaLiveLaneGateway } from "../../../../extensions/qa-lab/runtime-api.js";
import type {
ManagedWorktreeGcResult,
ManagedWorktreeRecord,
} from "../../../../src/agents/worktrees/types.js";
import { useAutoCleanupTempDirTracker } from "../../../helpers/temp-dir.js";
const execFileAsync = promisify(execFile);
type SessionWorktree = { id: string; branch: string; repoRoot: string };
type SessionCreateResult = {
key: string;
entry: { spawnedCwd?: string; worktree?: SessionWorktree };
worktree: { id: string; branch: string; path: string };
};
type SessionListResult = {
sessions: Array<{
key: string;
spawnedCwd?: string;
worktree?: SessionWorktree;
}>;
};
type SessionDeleteResult = {
deleted: boolean;
worktreePreserved?: { id: string; branch: string; path: string };
};
type WorktreeListResult = { worktrees: ManagedWorktreeRecord[] };
type GatewayRunResult = { runId?: unknown; status?: unknown };
let harness: Awaited<ReturnType<typeof startQaLiveLaneGateway>> | undefined;
afterEach(async () => {
await harness?.stop().catch(() => undefined);
harness = undefined;
});
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
async function git(cwd: string, ...args: string[]): Promise<string> {
const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], { encoding: "utf8" });
return stdout.trimEnd();
}
async function initializeRepository(root: string): Promise<{ baseCommit: string; repo: string }> {
const repo = path.join(root, "source");
await fs.mkdir(repo, { recursive: true });
await git(repo, "init", "-b", "main");
await git(repo, "config", "user.name", "OpenClaw Test");
await git(repo, "config", "user.email", "openclaw-test@example.invalid");
await fs.writeFile(path.join(repo, "README.md"), "base\n");
await git(repo, "add", "README.md");
await git(repo, "commit", "-m", "initialize session worktree fixture");
return {
baseCommit: await git(repo, "rev-parse", "HEAD"),
repo: await fs.realpath(repo),
};
}
async function createSessionWorktree(params: {
name: string;
repo: string;
}): Promise<SessionCreateResult> {
if (!harness) {
throw new Error("QA gateway harness is not running");
}
return (await harness.gateway.call(
"sessions.create",
{
agentId: "qa",
worktree: true,
worktreeName: params.name,
worktreeBaseRef: "main",
cwd: params.repo,
},
{ timeoutMs: 30_000 },
)) as SessionCreateResult;
}
async function listWorktrees(): Promise<WorktreeListResult> {
if (!harness) {
throw new Error("QA gateway harness is not running");
}
return (await harness.gateway.call("worktrees.list", {})) as WorktreeListResult;
}
describe("managed worktrees session-owner product proof", () => {
it(
"creates, protects, snapshots, restores, and reports preserved session worktrees",
{ timeout: 240_000 },
async () => {
const canonicalTmp = await fs.realpath(os.tmpdir());
const fixtureRoot = tempDirs.make("openclaw-managed-worktree-session-", canonicalTmp);
const { baseCommit, repo } = await initializeRepository(fixtureRoot);
harness = await startQaLiveLaneGateway({
repoRoot: process.cwd(),
providerMode: "mock-openai",
primaryModel: "mock-openai/gpt-5.6-luna",
alternateModel: "mock-openai/gpt-5.6-luna",
transport: {
requiredPluginIds: [],
createGatewayConfig: () => ({}),
},
transportBaseUrl: "http://127.0.0.1",
controlUiEnabled: false,
});
const stateDir = path.join(await fs.realpath(harness.gateway.tempRoot), "state");
const clean = await createSessionWorktree({ name: "qa-session-clean", repo });
expect(clean.worktree).toMatchObject({
id: expect.any(String),
branch: "openclaw/qa-session-clean",
path: expect.any(String),
});
expect(clean.entry.worktree).toEqual({
id: clean.worktree.id,
branch: clean.worktree.branch,
repoRoot: repo,
});
expect(clean.entry.spawnedCwd).toBe(clean.worktree.path);
const createdList = await listWorktrees();
const cleanRecord = createdList.worktrees.find((record) => record.id === clean.worktree.id);
expect(cleanRecord).toMatchObject({
name: "qa-session-clean",
repoRoot: repo,
branch: clean.worktree.branch,
ownerKind: "session",
ownerId: clean.key,
});
expect(await fs.realpath(clean.worktree.path)).toBe(
path.join(stateDir, "worktrees", cleanRecord?.repoFingerprint ?? "", "qa-session-clean"),
);
expect(await git(repo, "rev-parse", `refs/heads/${clean.worktree.branch}`)).toBe(baseCommit);
const sessions = (await harness.gateway.call("sessions.list", {
agentId: "qa",
})) as SessionListResult;
expect(sessions.sessions).toContainEqual(
expect.objectContaining({
key: clean.key,
spawnedCwd: clean.worktree.path,
worktree: {
id: clean.worktree.id,
branch: clean.worktree.branch,
repoRoot: repo,
},
}),
);
const started = (await harness.gateway.call(
"chat.send",
{
sessionKey: clean.key,
message: "Session worktree QA. Reply exactly `SESSION_WORKTREE_OK`.",
deliver: false,
idempotencyKey: randomUUID(),
},
{ timeoutMs: 30_000 },
)) as GatewayRunResult;
expect(started).toMatchObject({ runId: expect.any(String), status: "started" });
const terminal = (await harness.gateway.call(
"agent.wait",
{ runId: started.runId, timeoutMs: 30_000 },
{ timeoutMs: 35_000 },
)) as GatewayRunResult;
expect(terminal.status).toBe("ok");
const gc = (await harness.gateway.call("worktrees.gc", {})) as ManagedWorktreeGcResult;
expect(gc).toEqual({
removed: expect.not.arrayContaining([clean.worktree.id]),
orphansDeleted: expect.any(Number),
snapshotsPruned: expect.any(Number),
});
expect((await listWorktrees()).worktrees).toContainEqual(
expect.objectContaining({ id: clean.worktree.id, ownerId: clean.key }),
);
const cleanDeleted = (await harness.gateway.call("sessions.delete", {
key: clean.key,
})) as SessionDeleteResult;
expect(cleanDeleted.deleted).toBe(true);
expect(cleanDeleted).not.toHaveProperty("worktreePreserved");
await expect(fs.access(clean.worktree.path)).rejects.toMatchObject({ code: "ENOENT" });
const cleanSnapshotRef = `refs/openclaw/snapshots/${clean.worktree.id}`;
await expect(git(repo, "show-ref", "--verify", cleanSnapshotRef)).resolves.toContain(
cleanSnapshotRef,
);
expect((await listWorktrees()).worktrees).toContainEqual(
expect.objectContaining({
id: clean.worktree.id,
snapshotRef: cleanSnapshotRef,
removedAt: expect.any(Number),
}),
);
const dirty = await createSessionWorktree({ name: "qa-session-dirty", repo });
const dirtyFile = path.join(dirty.worktree.path, "untracked-note.txt");
await fs.writeFile(dirtyFile, "restore this note\n");
const dirtyDeleted = (await harness.gateway.call("sessions.delete", {
key: dirty.key,
})) as SessionDeleteResult;
expect(dirtyDeleted.deleted).toBe(true);
expect(dirtyDeleted).not.toHaveProperty("worktreePreserved");
await expect(fs.access(dirty.worktree.path)).rejects.toMatchObject({ code: "ENOENT" });
const dirtySnapshotRef = `refs/openclaw/snapshots/${dirty.worktree.id}`;
const dirtySnapshotCommit = await git(repo, "rev-parse", dirtySnapshotRef);
const restored = (await harness.gateway.call("worktrees.restore", {
id: dirty.worktree.id,
})) as ManagedWorktreeRecord;
expect(restored).toMatchObject({
id: dirty.worktree.id,
branch: dirty.worktree.branch,
path: dirty.worktree.path,
});
await expect(fs.readFile(dirtyFile, "utf8")).resolves.toBe("restore this note\n");
expect((await git(restored.path, "status", "--porcelain")).split("\n")).toContain(
"?? untracked-note.txt",
);
expect((await git(repo, "log", "--format=%H", restored.branch)).split("\n")).not.toContain(
dirtySnapshotCommit,
);
const locked = await createSessionWorktree({ name: "qa-session-locked", repo });
await git(repo, "worktree", "lock", locked.worktree.path);
const lockedDeleted = (await harness.gateway.call("sessions.delete", {
key: locked.key,
})) as SessionDeleteResult;
expect(lockedDeleted).toEqual(
expect.objectContaining({
deleted: true,
worktreePreserved: {
id: locked.worktree.id,
branch: locked.worktree.branch,
path: locked.worktree.path,
},
}),
);
await expect(fs.access(locked.worktree.path)).resolves.toBeUndefined();
},
);
});
@@ -0,0 +1,238 @@
// QA Lab product proof for the Workboard-owned managed-worktree lifecycle.
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import { startQaLiveLaneGateway } from "../../../../extensions/qa-lab/runtime-api.js";
import type { ManagedWorktreeRecord } from "../../../../src/agents/worktrees/types.js";
import { useAutoCleanupTempDirTracker } from "../../../helpers/temp-dir.js";
const execFileAsync = promisify(execFile);
type WorkboardWorkspace = {
kind: "worktree";
path: string;
branch?: string;
sourcePath?: string;
sourceBranch?: string;
};
type WorkboardCard = {
id: string;
runId?: string;
metadata?: { automation?: { workspace?: WorkboardWorkspace } };
};
type WorkboardCreateResult = { card: WorkboardCard };
type WorkboardListResult = { cards: WorkboardCard[] };
type WorkboardDispatchResult = {
started: Array<{ cardId: string; runId: string; sessionKey: string; title: string }>;
startFailures: Array<{ cardId: string; error: string; title: string }>;
};
type WorktreeListResult = { worktrees: ManagedWorktreeRecord[] };
type GatewayRunResult = { status?: unknown };
let harness: Awaited<ReturnType<typeof startQaLiveLaneGateway>> | undefined;
afterEach(async () => {
await harness?.stop().catch(() => undefined);
harness = undefined;
});
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
async function git(cwd: string, ...args: string[]): Promise<string> {
const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], { encoding: "utf8" });
return stdout.trimEnd();
}
async function initializeRepository(root: string): Promise<string> {
const repo = path.join(root, "source");
const remote = path.join(root, "origin.git");
await fs.mkdir(repo, { recursive: true });
await git(repo, "init", "-b", "main");
await git(repo, "config", "user.name", "OpenClaw Test");
await git(repo, "config", "user.email", "openclaw-test@example.invalid");
await fs.writeFile(path.join(repo, "README.md"), "base\n");
await git(repo, "add", "README.md");
await git(repo, "commit", "-m", "initialize Workboard worktree fixture");
await git(root, "init", "--bare", remote);
await git(repo, "remote", "add", "origin", remote);
await git(repo, "push", "-u", "origin", "main");
await git(remote, "symbolic-ref", "HEAD", "refs/heads/main");
return await fs.realpath(repo);
}
async function startHarness() {
harness = await startQaLiveLaneGateway({
repoRoot: process.cwd(),
providerMode: "mock-openai",
primaryModel: "mock-openai/gpt-5.6-luna",
alternateModel: "mock-openai/gpt-5.6-luna",
transport: {
requiredPluginIds: [],
createGatewayConfig: () => ({}),
},
transportBaseUrl: "http://127.0.0.1",
controlUiEnabled: false,
mutateConfig: (config) => ({
...config,
plugins: {
...config.plugins,
allow: [...new Set([...(config.plugins?.allow ?? []), "workboard"])],
entries: {
...config.plugins?.entries,
workboard: { enabled: true },
},
},
}),
});
return harness;
}
function managedWorktreeName(cardId: string): string {
const suffix = cardId
.toLowerCase()
.replace(/[^a-z0-9-]/g, "-")
.replace(/-+/g, "-");
return `wb-${suffix}`.slice(0, 64).replace(/-$/, "");
}
async function createCard(params: {
boardId: string;
repo: string;
title: string;
}): Promise<WorkboardCard> {
if (!harness) {
throw new Error("QA gateway harness is not running");
}
const created = (await harness.gateway.call("workboard.cards.create", {
title: params.title,
status: "ready",
agentId: "qa",
boardId: params.boardId,
workspace: { kind: "worktree", path: params.repo, branch: "main" },
})) as WorkboardCreateResult;
return created.card;
}
async function listWorktrees(): Promise<WorktreeListResult> {
if (!harness) {
throw new Error("QA gateway harness is not running");
}
return (await harness.gateway.call("worktrees.list", {})) as WorktreeListResult;
}
async function waitForMaterializedWorktree(params: {
name: string;
stateDir: string;
timeoutMs?: number;
}): Promise<string> {
const worktreesRoot = path.join(params.stateDir, "worktrees");
const deadline = Date.now() + (params.timeoutMs ?? 15_000);
while (Date.now() < deadline) {
const fingerprints = await fs.readdir(worktreesRoot, { withFileTypes: true }).catch(() => []);
for (const fingerprint of fingerprints) {
if (!fingerprint.isDirectory()) {
continue;
}
const candidate = path.join(worktreesRoot, fingerprint.name, params.name);
try {
return await fs.realpath(candidate);
} catch {
// The dispatcher has not materialized this checkout yet.
}
}
await sleep(20);
}
throw new Error(`timed out waiting for managed worktree ${params.name}`);
}
async function waitForWorktreeState(params: {
id: string;
predicate: (record: ManagedWorktreeRecord | undefined) => boolean;
timeoutMs?: number;
}): Promise<ManagedWorktreeRecord | undefined> {
const deadline = Date.now() + (params.timeoutMs ?? 10_000);
let record: ManagedWorktreeRecord | undefined;
while (Date.now() < deadline) {
record = (await listWorktrees()).worktrees.find((entry) => entry.id === params.id);
if (params.predicate(record)) {
return record;
}
await sleep(50);
}
throw new Error(`timed out waiting for managed worktree state ${params.id}`);
}
describe("managed worktrees Workboard-owner product proof", () => {
it(
"materializes, writes back, runs, and removes a clean card worktree losslessly",
{ timeout: 240_000 },
async () => {
const canonicalTmp = await fs.realpath(os.tmpdir());
const fixtureRoot = tempDirs.make("openclaw-managed-worktree-workboard-", canonicalTmp);
const repo = await initializeRepository(fixtureRoot);
const activeHarness = await startHarness();
const stateDir = path.join(await fs.realpath(activeHarness.gateway.tempRoot), "state");
const boardId = "qa-worktree-clean";
const card = await createCard({ boardId, repo, title: "Clean worktree lifecycle" });
const name = managedWorktreeName(card.id);
const dispatchPromise = activeHarness.gateway.call("workboard.cards.dispatch", { boardId });
const materializedPath = await waitForMaterializedWorktree({ name, stateDir });
const dispatch = (await dispatchPromise) as WorkboardDispatchResult;
expect(dispatch.startFailures).toEqual([]);
expect(dispatch.started).toEqual([
expect.objectContaining({ cardId: card.id, runId: expect.any(String) }),
]);
const started = dispatch.started[0]!;
const cards = (await activeHarness.gateway.call("workboard.cards.list", {
boardId,
})) as WorkboardListResult;
const dispatchedCard = cards.cards.find((entry) => entry.id === card.id);
const dispatchedWorkspace = dispatchedCard?.metadata?.automation?.workspace;
expect(dispatchedWorkspace).toMatchObject({
kind: "worktree",
branch: `openclaw/${name}`,
sourcePath: repo,
sourceBranch: "main",
});
expect(await fs.realpath(dispatchedWorkspace?.path ?? "")).toBe(materializedPath);
expect(dispatchedCard?.runId).toBe(started.runId);
const activeRecord = (await listWorktrees()).worktrees.find(
(record) => record.ownerKind === "workboard" && record.ownerId === card.id,
);
expect(activeRecord).toMatchObject({
name,
branch: `openclaw/${name}`,
repoRoot: repo,
ownerKind: "workboard",
ownerId: card.id,
});
expect(await fs.realpath(activeRecord?.path ?? "")).toBe(materializedPath);
const terminal = (await activeHarness.gateway.call(
"agent.wait",
{ runId: started.runId, timeoutMs: 30_000 },
{ timeoutMs: 35_000 },
)) as GatewayRunResult;
expect(terminal.status).toBe("ok");
const removed = await waitForWorktreeState({
id: activeRecord?.id ?? "",
predicate: (record) => record?.removedAt !== undefined,
timeoutMs: 30_000,
});
expect(removed).toMatchObject({
id: activeRecord?.id,
snapshotRef: `refs/openclaw/snapshots/${activeRecord?.id}`,
removedAt: expect.any(Number),
});
await expect(fs.access(materializedPath)).rejects.toMatchObject({ code: "ENOENT" });
},
);
});