Files
multica/packages/core/issues/workdir.test.ts
Wu Changming 7fdc854c26 fix(issues): copy durable local path for worktree tasks (MUL-6378) (#7176)
A worktree-mode local_directory task runs in a disposable git worktree that
Finalize removes on every exit path, so the task's `work_dir` pointed at a
directory that no longer existed. "Copy local workdir path" handed the user a
path they could not `cd` into.

The daemon now reports a `durable_work_dir` handoff instead of having clients
infer one:

- `removeLocalWorktreeDir` returns an error and confirms removal with `Lstat`,
  so a broken symlink cannot be mistaken for an absent directory. Finalize
  fails when removal cannot be confirmed.
- `durable_work_dir` is written only after Finalize succeeds and the worktree
  is verifiably gone. A preserved worktree, an in-place run, a running task, a
  squad-leader task, and any older daemon all leave it unset, keeping the real
  `work_dir` authoritative.
- The field travels on the complete, fail, and cancel-ack callbacks and is
  persisted on the task row (migration 362, additive nullable column). Writes
  use COALESCE for replay idempotency; the cancel-ack path CASes on
  `status = 'cancelled'` so a late ack cannot touch a completed or failed row.
- The server derives `relative_durable_work_dir` through the same
  home-stripping helper as `relative_work_dir`; the absolute value is reserved
  for explicit clipboard actions and is never rendered.
- Clients select the durable path only for a terminal task that carries the
  field. The issue actions menu and the transcript dialog share one resolver,
  and the label and toast say the task worktree was reclaimed, naming the
  delivery branch when one exists.

The stored path is a point-in-time delivery snapshot: later renames or
detachments of the project resource do not rewrite historical tasks.
2026-08-20 01:30:58 +08:00

125 lines
3.1 KiB
TypeScript

// @vitest-environment node
import { describe, expect, it } from "vitest";
import type { AgentTask } from "../types/agent";
import { resolveWorkdirCopyTarget } from "./workdir";
const terminalStatuses = ["completed", "failed", "cancelled"] as const;
function task(
status: AgentTask["status"],
createdAt: string,
workDir?: string,
durableWorkDir?: string,
) {
return {
status,
created_at: createdAt,
work_dir: workDir,
relative_work_dir: workDir
? `relative/${workDir.split("/").pop()}`
: undefined,
durable_work_dir: durableWorkDir,
relative_durable_work_dir: durableWorkDir ? "project" : undefined,
branch_name: durableWorkDir ? "agent/j/abc12345" : undefined,
};
}
describe("resolveWorkdirCopyTarget", () => {
it.each(terminalStatuses)(
"uses a daemon-confirmed durable directory for a %s task",
(status) => {
expect(
resolveWorkdirCopyTarget([
task(
status,
"2026-08-18T10:00:00Z",
"/managed/task/worktree",
"/Users/dev/project",
),
]),
).toEqual({
path: "/Users/dev/project",
relativePath: "project",
branchName: "agent/j/abc12345",
source: "durable_project_directory",
});
},
);
it("keeps a running task on its actual workdir", () => {
expect(
resolveWorkdirCopyTarget([
task(
"running",
"2026-08-18T10:00:00Z",
"/managed/task/worktree",
"/Users/dev/project",
),
]),
).toMatchObject({
path: "/managed/task/worktree",
source: "task_workdir",
});
});
it("treats an in-place task workdir as durable without inventing a handoff", () => {
expect(
resolveWorkdirCopyTarget([
task(
"completed",
"2026-08-18T10:00:00Z",
"/Users/dev/project",
),
]),
).toMatchObject({
path: "/Users/dev/project",
source: "task_workdir",
});
});
it.each(terminalStatuses)(
"keeps a terminal %s task on work_dir when no durable handoff was reported",
(status) => {
expect(
resolveWorkdirCopyTarget([
task(status, "2026-08-18T10:00:00Z", "/preserved/worktree"),
]),
).toMatchObject({
path: "/preserved/worktree",
source: "task_workdir",
});
},
);
it("uses the newest task with a usable path", () => {
expect(
resolveWorkdirCopyTarget([
task("completed", "2026-08-18T09:00:00Z", "/older"),
task(
"completed",
"2026-08-18T10:00:00Z",
"/newer-worktree",
"/newer-project",
),
]),
).toMatchObject({ path: "/newer-project" });
});
it("does not trust a durable path on a live task without work_dir", () => {
expect(
resolveWorkdirCopyTarget([
task("running", "2026-08-18T10:00:00Z", undefined, "/project"),
]),
).toBeUndefined();
});
it("returns undefined when no task has a usable path", () => {
expect(
resolveWorkdirCopyTarget([
task("completed", "2026-08-18T10:00:00Z"),
]),
).toBeUndefined();
});
});