mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 02:16:45 +08:00
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.
125 lines
3.1 KiB
TypeScript
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();
|
|
});
|
|
});
|