diff --git a/qa/scenarios/runtime/managed-worktrees-session-lifecycle.yaml b/qa/scenarios/runtime/managed-worktrees-session-lifecycle.yaml new file mode 100644 index 000000000000..3f5baffa4ede --- /dev/null +++ b/qa/scenarios/runtime/managed-worktrees-session-lifecycle.yaml @@ -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. diff --git a/qa/scenarios/runtime/managed-worktrees-workboard-lifecycle.yaml b/qa/scenarios/runtime/managed-worktrees-workboard-lifecycle.yaml new file mode 100644 index 000000000000..c7d106dcb0dc --- /dev/null +++ b/qa/scenarios/runtime/managed-worktrees-workboard-lifecycle.yaml @@ -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- 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- on branch openclaw/wb-. + - 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. diff --git a/src/agents/embedded-agent-runner/runs.steering.test.ts b/src/agents/embedded-agent-runner/runs.steering.test.ts new file mode 100644 index 000000000000..b1018f2aa4ef --- /dev/null +++ b/src/agents/embedded-agent-runner/runs.steering.test.ts @@ -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[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(); + }); +}); diff --git a/src/agents/embedded-agent-runner/runs.test.ts b/src/agents/embedded-agent-runner/runs.test.ts index 88095c9f9107..f89cb18e9f65 100644 --- a/src/agents/embedded-agent-runner/runs.test.ts +++ b/src/agents/embedded-agent-runner/runs.test.ts @@ -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"); diff --git a/src/agents/worktrees/registry.ts b/src/agents/worktrees/registry.ts index 2aa0b9296998..aceb9aaa45b3 100644 --- a/src/agents/worktrees/registry.ts +++ b/src/agents/worktrees/registry.ts @@ -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, diff --git a/src/agents/worktrees/service.canonical-paths.test.ts b/src/agents/worktrees/service.canonical-paths.test.ts new file mode 100644 index 000000000000..ef9fdc6210f9 --- /dev/null +++ b/src/agents/worktrees/service.canonical-paths.test.ts @@ -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 { + 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" }); + }, + ); +}); diff --git a/src/agents/worktrees/service.test-support.ts b/src/agents/worktrees/service.test-support.ts index cb0eb61b2bb4..946491622c20 100644 --- a/src/agents/worktrees/service.test-support.ts +++ b/src/agents/worktrees/service.test-support.ts @@ -12,6 +12,22 @@ async function git(cwd: string, ...args: string[]): Promise { await execFileAsync("git", ["-C", cwd, ...args]); } +export async function initializeManagedWorktreeTestRepository(root: string): Promise { + 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; diff --git a/src/agents/worktrees/service.test.ts b/src/agents/worktrees/service.test.ts index 4a6a998887fb..0c5dc96515f0 100644 --- a/src/agents/worktrees/service.test.ts +++ b/src/agents/worktrees/service.test.ts @@ -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"); diff --git a/src/agents/worktrees/service.ts b/src/agents/worktrees/service.ts index f2b63c54ad32..e989fa30cea9 100644 --- a/src/agents/worktrees/service.ts +++ b/src/agents/worktrees/service.ts @@ -570,6 +570,14 @@ export class ManagedWorktreeService { this.now = options.now ?? Date.now; } + private async worktreesRoot(): Promise { + 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 { const repository = await resolveRepository(params.repoRoot); if (params.ownerId) { @@ -633,7 +641,7 @@ export class ManagedWorktreeService { repository: Awaited>, inferredName: string, ): Promise { - 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) { diff --git a/src/commands/doctor.e2e-harness.ts b/src/commands/doctor.e2e-harness.ts index abd4b1a317cd..e9acbdd7d2a2 100644 --- a/src/commands/doctor.e2e-harness.ts +++ b/src/commands/doctor.e2e-harness.ts @@ -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", diff --git a/src/infra/state-migrations.doctor.ts b/src/infra/state-migrations.doctor.ts index e3089874869a..742eb23c6250 100644 --- a/src/infra/state-migrations.doctor.ts +++ b/src/infra/state-migrations.doctor.ts @@ -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 { + 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 && diff --git a/src/infra/state-migrations.types.ts b/src/infra/state-migrations.types.ts index 97c82138601f..d083b2524538 100644 --- a/src/infra/state-migrations.types.ts +++ b/src/infra/state-migrations.types.ts @@ -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; diff --git a/src/infra/state-migrations.worktree-paths.test.ts b/src/infra/state-migrations.worktree-paths.test.ts new file mode 100644 index 000000000000..52aa0337ec64 --- /dev/null +++ b/src/infra/state-migrations.worktree-paths.test.ts @@ -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" }); + }, + ); +}); diff --git a/taxonomy.yaml b/taxonomy.yaml index 1066825ca501..aebc800f24b2 100644 --- a/taxonomy.yaml +++ b/taxonomy.yaml @@ -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- worktrees, card workspace writeback, and run-end lossless removal. docs: - docs/concepts/agent-loop.md - docs/cli/agent.md diff --git a/test/e2e/qa-lab/runtime/managed-worktrees-session-lifecycle-product-proof.e2e.test.ts b/test/e2e/qa-lab/runtime/managed-worktrees-session-lifecycle-product-proof.e2e.test.ts new file mode 100644 index 000000000000..9f069263d167 --- /dev/null +++ b/test/e2e/qa-lab/runtime/managed-worktrees-session-lifecycle-product-proof.e2e.test.ts @@ -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> | undefined; + +afterEach(async () => { + await harness?.stop().catch(() => undefined); + harness = undefined; +}); + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +async function git(cwd: string, ...args: string[]): Promise { + 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 { + 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 { + 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(); + }, + ); +}); diff --git a/test/e2e/qa-lab/runtime/managed-worktrees-workboard-lifecycle-product-proof.e2e.test.ts b/test/e2e/qa-lab/runtime/managed-worktrees-workboard-lifecycle-product-proof.e2e.test.ts new file mode 100644 index 000000000000..f31521cc3d28 --- /dev/null +++ b/test/e2e/qa-lab/runtime/managed-worktrees-workboard-lifecycle-product-proof.e2e.test.ts @@ -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> | undefined; + +afterEach(async () => { + await harness?.stop().catch(() => undefined); + harness = undefined; +}); + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +async function git(cwd: string, ...args: string[]): Promise { + const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], { encoding: "utf8" }); + return stdout.trimEnd(); +} + +async function initializeRepository(root: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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" }); + }, + ); +});