Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/explanation/waves-lanes-and-worktrees.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,12 @@ All task work is merged onto this branch — the user's working branch (e.g.,
In workspace mode, the orch branch is created in **every** workspace repo that
has tasks, and `/orch-integrate` integrates across all repos.

Before each wave allocates worktrees, uncommitted task files are staged on the
orch branch in the configured task packet repository.
This may differ from the default repository or the repository where the task
executes. Only the current wave's task folders are staged; unrelated user changes
and the checked-out branch remain untouched when staging on the orch branch.

---

## 5) Worktree isolation
Expand Down
27 changes: 21 additions & 6 deletions extensions/taskplane/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1621,24 +1621,39 @@ export function batchTaskScope(wavePlan: string[][] | undefined | null): Set<str
* won't have those files and the worker will fail with "file not found".
*
* This function checks each wave task's folder for untracked or modified files,
* stages them, and creates a commit on the current branch. This must run BEFORE
* allocateLanes() so that worktrees (which are based on the batch's base branch)
* include the task files.
* stages them, and commits them on the orch branch when one is provided.
* In workspace mode, task files belong to routing.taskPacketRepo, which may
* differ from both the default repo and the task's execution target.
* This must run BEFORE allocateLanes() so that worktrees based on the batch's
* orch branch include the task files.
*
* Only task-specific folders are staged — no other working tree changes are touched.
*
* @param waveTasks - Task IDs in this wave
* @param pending - Full pending task map from discovery
* @param repoRoot - Main repository root
* @param primaryRepoRoot - Main/default repository root
* @param waveIndex - Wave number for commit message
* @param workspaceConfig - Workspace routing for task packet ownership (absent in repo mode)
*/
export function ensureTaskFilesCommitted(
waveTasks: string[],
pending: Map<string, ParsedTask>,
repoRoot: string,
primaryRepoRoot: string,
waveIndex: number,
orchBranch?: string,
workspaceConfig?: WorkspaceConfig | null,
): void {
const packetRepo = workspaceConfig?.repos.get(workspaceConfig.routing.taskPacketRepo);
if (workspaceConfig && !packetRepo) {
throw new ExecutionError(
"EXEC_TASK_STAGE_FAILED",
`Task packet repository '${workspaceConfig.routing.taskPacketRepo}' is not configured.`,
"wave",
`W${waveIndex}`,
);
}
const repoRoot = packetRepo?.path ?? primaryRepoRoot;

// Collect task folder paths for this wave
const foldersToCheck: { taskId: string; relPath: string }[] = [];
for (const taskId of waveTasks) {
Expand Down Expand Up @@ -2020,7 +2035,7 @@ export async function executeWave(
// Pass orchBranch so the staging commit is reflected in the orch branch
// before worktrees are allocated from it.
try {
ensureTaskFilesCommitted(waveTasks, pending, repoRoot, waveIndex, orchBranch);
ensureTaskFilesCommitted(waveTasks, pending, repoRoot, waveIndex, orchBranch, workspaceConfig);
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
execLog("wave", `W${waveIndex}`, `task file commit failed: ${errMsg}`);
Expand Down
192 changes: 192 additions & 0 deletions extensions/tests/task-file-staging.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { strict as assert } from "node:assert";
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, it } from "node:test";
import { ensureTaskFilesCommitted, executeWave } from "../taskplane/execution.ts";
import { DEFAULT_ORCHESTRATOR_CONFIG } from "../taskplane/types.ts";
import type { ParsedTask, WorkspaceConfig } from "../taskplane/types.ts";

const allocationReached = new Error("stop before spawning workers");

describe("task file staging in the packet-home repository (#479)", () => {
const orchBranch = "orch/test-batch";
let root: string;
let api: string;
let docs: string;
let workspace: WorkspaceConfig;

function git(repo: string, ...args: string[]): string {
return execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: "pipe" }).trim();
}

function createRepo(name: string): string {
const repo = join(root, name);
mkdirSync(repo);
git(repo, "init", "-b", "main");
git(repo, "config", "user.name", "Test User");
git(repo, "config", "user.email", "test@example.com");
git(repo, "config", "commit.gpgsign", "false");
writeFileSync(join(repo, "README.md"), "initial\n");
git(repo, "add", "README.md");
git(repo, "commit", "-m", "initial");
git(repo, "branch", orchBranch);
return repo;
}

function createTask(repo: string, taskId = "TEST-001"): ParsedTask {
const taskFolder = join(repo, "tasks", taskId);
mkdirSync(taskFolder, { recursive: true });
const promptPath = join(taskFolder, "PROMPT.md");
writeFileSync(promptPath, "# Implement the API change\n");
writeFileSync(join(taskFolder, "STATUS.md"), "- [ ] Implement\n");
return {
taskId,
taskName: taskId,
taskFolder,
promptPath,
reviewLevel: 1,
size: "S",
dependencies: [],
fileScope: [],
areaName: "tasks",
status: "pending",
resolvedRepoId: "api",
};
}

function pending(...tasks: ParsedTask[]): Map<string, ParsedTask> {
return new Map(tasks.map((task) => [task.taskId, task]));
}

beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "tp-task-staging-"));
api = createRepo("api");
docs = createRepo("docs");
workspace = {
mode: "workspace",
repos: new Map([
["api", { id: "api", path: api }],
["docs", { id: "docs", path: docs }],
]),
routing: { tasksRoot: join(docs, "tasks"), defaultRepo: "api", taskPacketRepo: "docs" },
configPath: join(root, ".pi", "taskplane-workspace.yaml"),
};
});

afterEach(() => {
rmSync(root, { recursive: true, force: true });
});

it("stages the current wave in the packet repo while preserving both checkouts and indexes", () => {
const task = createTask(docs);
const laterTask = createTask(docs, "TEST-002");
const before = new Map<string, { head: string; index: Buffer; status: string }>();
for (const repo of [api, docs]) {
writeFileSync(join(repo, "unrelated.txt"), "staged user work\n");
git(repo, "add", "unrelated.txt");
writeFileSync(join(repo, "README.md"), "unstaged user work\n");
const status = git(repo, "status", "--porcelain");
before.set(repo, {
head: git(repo, "rev-parse", "HEAD"),
index: readFileSync(join(repo, ".git", "index")),
status,
});
}

ensureTaskFilesCommitted([task.taskId], pending(task, laterTask), api, 1, orchBranch, workspace);

assert.equal(
git(docs, "show", `${orchBranch}:tasks/TEST-001/PROMPT.md`),
"# Implement the API change",
);
assert.equal(git(docs, "show", `${orchBranch}:tasks/TEST-001/STATUS.md`), "- [ ] Implement");
assert.equal(
git(docs, "diff", "--name-only", `main..${orchBranch}`),
"tasks/TEST-001/PROMPT.md\ntasks/TEST-001/STATUS.md",
);
assert.equal(git(api, "rev-parse", orchBranch), before.get(api)!.head);
for (const repo of [api, docs]) {
assert.equal(git(repo, "rev-parse", "HEAD"), before.get(repo)!.head);
assert.deepEqual(readFileSync(join(repo, ".git", "index")), before.get(repo)!.index);
assert.equal(git(repo, "status", "--porcelain"), before.get(repo)!.status);
}

const worktree = join(root, "packet-lane");
git(docs, "worktree", "add", "--detach", worktree, orchBranch);
assert.equal(
readFileSync(join(worktree, "tasks", task.taskId, "PROMPT.md"), "utf8"),
readFileSync(task.promptPath, "utf8"),
);
});

it("threads the workspace context through wave preflight before allocating lanes", async () => {
const task = createTask(docs);
await assert.rejects(
executeWave(
[task.taskId],
1,
pending(task),
DEFAULT_ORCHESTRATOR_CONFIG,
api,
"test-batch",
{ paused: false },
{ dependencies: new Map(), dependents: new Map(), nodes: new Set([task.taskId]) },
orchBranch,
undefined,
() => {
// Exercise real allocation, then stop before any worker can spawn.
throw allocationReached;
},
workspace,
),
allocationReached,
);
assert.equal(
git(docs, "show", `${orchBranch}:tasks/TEST-001/PROMPT.md`),
"# Implement the API change",
);
});

it("includes modifications to tracked task files in the packet repo", () => {
const task = createTask(docs);
git(docs, "add", "tasks");
git(docs, "commit", "-m", "add task");
git(docs, "branch", "-f", orchBranch, "HEAD");
const mainTip = git(docs, "rev-parse", "HEAD");
writeFileSync(join(task.taskFolder, "STATUS.md"), "- [x] Implement\n");

ensureTaskFilesCommitted([task.taskId], pending(task), api, 2, orchBranch, workspace);

assert.equal(git(docs, "show", `${orchBranch}:tasks/TEST-001/STATUS.md`), "- [x] Implement");
assert.equal(git(docs, "show", "HEAD:tasks/TEST-001/STATUS.md"), "- [ ] Implement");
assert.equal(git(docs, "rev-parse", "HEAD"), mainTip);
});

it("preserves repo-mode staging when workspace context is absent", () => {
const task = createTask(api);
const mainTip = git(api, "rev-parse", "HEAD");

ensureTaskFilesCommitted([task.taskId], pending(task), api, 1, orchBranch);

assert.equal(
git(api, "show", `${orchBranch}:tasks/TEST-001/PROMPT.md`),
"# Implement the API change",
);
assert.equal(git(api, "rev-parse", "HEAD"), mainTip);
});

it("rejects an unknown packet repo before changing any branch", () => {
const task = createTask(api);
workspace.routing.taskPacketRepo = "missing";
const mainTip = git(api, "rev-parse", "HEAD");

assert.throws(
() => ensureTaskFilesCommitted([task.taskId], pending(task), api, 1, orchBranch, workspace),
/Task packet repository.*missing.*not configured/,
);
assert.equal(git(api, "rev-parse", orchBranch), mainTip);
assert.equal(git(api, "rev-parse", "HEAD"), mainTip);
});
});