mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
0463d1bd83
commit
6a6cd7859f
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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" });
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
+250
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
+238
@@ -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" });
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user