diff --git a/.gitignore b/.gitignore index 97370c44..116c5017 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ Thumbs.db *~ .vscode/ .idea/ +tmp/ diff --git a/bin/taskplane.mjs b/bin/taskplane.mjs index 7937e4eb..c59d6a33 100644 --- a/bin/taskplane.mjs +++ b/bin/taskplane.mjs @@ -2560,6 +2560,177 @@ function resolveDoctorConfigLocation(projectRoot, isWorkspaceMode) { }; } +const DOCTOR_WORKSPACE_REPO_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; + +function normalizeDoctorPath(value) { + try { + return fs.realpathSync.native(path.resolve(value)).replace(/\\/g, "/").toLowerCase(); + } catch { + return path.resolve(value).replace(/\\/g, "/").toLowerCase(); + } +} + +function runGitForDoctor(args, cwd) { + try { + const stdout = execFileSync("git", args, { + cwd, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + timeout: 5000, + }).trim(); + return { ok: true, stdout }; + } catch (error) { + const stderr = error && typeof error === "object" && error.stderr + ? String(error.stderr).trim() + : ""; + return { ok: false, stdout: "", stderr }; + } +} + +function uniqueSorted(values) { + return [...new Set(values)].sort((left, right) => left.localeCompare(right)); +} + +function listConfiguredSubmodulePathsForDoctor(repoRoot) { + const result = runGitForDoctor(["config", "-f", ".gitmodules", "--get-regexp", "^submodule\\..*\\.path$"], repoRoot); + if (!result.ok || !result.stdout) return []; + + const paths = []; + for (const line of result.stdout.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + const value = trimmed.replace(/^submodule\.[^.]+\.path\s+/, "").trim(); + if (value) paths.push(value); + } + return uniqueSorted(paths); +} + +function listGitlinkPathsForDoctor(repoRoot) { + const result = runGitForDoctor(["ls-files", "--stage"], repoRoot); + if (!result.ok || !result.stdout) return []; + + const paths = []; + for (const line of result.stdout.split(/\r?\n/)) { + const match = line.match(/^160000\s+[0-9a-f]+\s+\d+\t(.+)$/i); + if (match?.[1]) paths.push(match[1]); + } + return uniqueSorted(paths); +} + +function parseSubmoduleStatusLineForDoctor(line) { + if (!line) return null; + const prefix = line[0]; + const trimmed = line.slice(1).trim(); + if (!trimmed) return null; + const firstSpace = trimmed.indexOf(" "); + if (firstSpace <= 0) return null; + + let pathAndDescription = trimmed.slice(firstSpace + 1).trim(); + const descriptionMatch = pathAndDescription.match(/^(.*)\s+\((.*)\)$/); + if (descriptionMatch) { + pathAndDescription = descriptionMatch[1].trim(); + } + if (!pathAndDescription) return null; + + return { + path: pathAndDescription, + state: + prefix === "-" ? "uninitialized" : + prefix === "+" ? "drifted" : + prefix === "U" ? "conflict" : + "ok", + }; +} + +function listSubmoduleStatusForDoctor(repoRoot) { + const result = runGitForDoctor(["submodule", "status", "--recursive"], repoRoot); + if (!result.ok || !result.stdout) return []; + return result.stdout + .split(/\r?\n/) + .map(parseSubmoduleStatusLineForDoctor) + .filter(Boolean) + .sort((left, right) => left.path.localeCompare(right.path)); +} + +function applyDoctorSubmodulePolicy(target, rawOrchestrator) { + if (!rawOrchestrator || typeof rawOrchestrator !== "object" || Array.isArray(rawOrchestrator)) return; + const rawFailure = rawOrchestrator.failure; + const rawCore = rawOrchestrator.orchestrator; + if (rawFailure?.submoduleFailureMode === "permissive" || rawFailure?.submoduleFailureMode === "strict") { + target.failureMode = rawFailure.submoduleFailureMode; + } + if (["manual", "init-only", "recursive-on-drift"].includes(rawFailure?.onSubmoduleDrift)) { + target.onSubmoduleDrift = rawFailure.onSubmoduleDrift; + } + if (rawCore?.submoduleRepoIdStrategy === "path-basename") { + target.repoIdStrategy = rawCore.submoduleRepoIdStrategy; + } +} + +function loadDoctorSubmodulePolicy(configLocation) { + const policy = { + failureMode: "permissive", + onSubmoduleDrift: "manual", + repoIdStrategy: "path-basename", + }; + + try { + const { raw } = readGlobalPreferencesForCli(); + applyDoctorSubmodulePolicy(policy, raw?.orchestrator); + } catch { + // ignore preferences parse failures here; doctor remains read-only best effort + } + + const configPath = path.join(configLocation.root, configLocation.prefix, "taskplane-config.json"); + if (!fs.existsSync(configPath)) return policy; + + try { + const raw = JSON.parse(fs.readFileSync(configPath, "utf-8")); + applyDoctorSubmodulePolicy(policy, raw?.orchestrator); + } catch { + // malformed project config is surfaced elsewhere; keep default policy here + } + + return policy; +} + +function doctorPlannerSyncCommand() { + return "/orch-plan --sync"; +} + +function buildDoctorUninitializedHint(policy, repoRoot, submodulePath) { + const syncCmd = doctorPlannerSyncCommand(); + const initCmd = `git -C "${repoRoot}" submodule update --init -- "${submodulePath}"`; + const recursiveCmd = `git -C "${repoRoot}" submodule update --init --recursive -- "${submodulePath}"`; + if (policy.onSubmoduleDrift === "manual") { + return `On Submodule Drift is manual. Switch it to init-only or recursive-on-drift, then run ${syncCmd}, or run ${initCmd}.`; + } + if (policy.onSubmoduleDrift === "init-only") { + return `Run ${syncCmd} to initialize it, or run ${initCmd}.`; + } + return `Run ${syncCmd} to initialize it recursively, or run ${recursiveCmd}.`; +} + +function buildDoctorDriftHint(policy, repoRoot, submodulePath) { + const syncCmd = doctorPlannerSyncCommand(); + const updateCmd = `git -C "${repoRoot}" submodule update --init --recursive -- "${submodulePath}"`; + if (policy.onSubmoduleDrift === "manual") { + return `On Submodule Drift is manual. Switch it to recursive-on-drift, then run ${syncCmd}, or run ${updateCmd}.`; + } + if (policy.onSubmoduleDrift === "init-only") { + return `On Submodule Drift is init-only, which does not repair drift. Switch it to recursive-on-drift and rerun ${syncCmd}, or run ${updateCmd}.`; + } + return `Run ${syncCmd} to realign the checkout, or run ${updateCmd}.`; +} + +function reportDoctorSubmoduleFinding(strictMode, message, hint) { + console.log(` ${strictMode ? FAIL : WARN} ${message}`); + if (hint) { + console.log(` ${c.dim}→ ${hint}${c.reset}`); + } + return strictMode ? 1 : 0; +} + function cmdDoctor() { const projectRoot = process.cwd(); let issues = 0; @@ -2786,6 +2957,105 @@ function cmdDoctor() { } } + // ── Submodule policy + advisory checks (read-only) ───────────────── + console.log(); + const submodulePolicy = loadDoctorSubmodulePolicy(configLocation); + const strictSubmoduleMode = submodulePolicy.failureMode === "strict"; + console.log( + ` ${INFO} submodule policy ${c.dim}(failure: ${submodulePolicy.failureMode}, on drift: ${submodulePolicy.onSubmoduleDrift}, repo IDs: ${submodulePolicy.repoIdStrategy})${c.reset}`, + ); + + const workspaceRepoPaths = new Map(); + const repoRootsForSubmoduleScan = []; + if (wsResult.config) { + for (const repoId of [...wsResult.config.repos.keys()].sort()) { + const repo = wsResult.config.repos.get(repoId); + const resolvedPath = path.resolve(projectRoot, repo.path); + repoRootsForSubmoduleScan.push({ label: repoId, root: resolvedPath }); + workspaceRepoPaths.set(normalizeDoctorPath(resolvedPath), repoId); + if (!DOCTOR_WORKSPACE_REPO_ID_PATTERN.test(repoId)) { + issues += reportDoctorSubmoduleFinding( + strictSubmoduleMode, + `workspace repo ID '${repoId}' does not match the lowercase letters/digits/hyphen policy`, + "Rename the repo ID before relying on workspace routing or future submodule imports.", + ); + } + } + } else if (isInsideGitRepo(projectRoot)) { + repoRootsForSubmoduleScan.push({ label: path.basename(projectRoot), root: projectRoot }); + } + + let trackedSubmodules = 0; + const collisionCandidates = new Map(); + for (const { label, root } of repoRootsForSubmoduleScan) { + const configuredPaths = listConfiguredSubmodulePathsForDoctor(root); + const gitlinkPaths = listGitlinkPathsForDoctor(root); + const statuses = listSubmoduleStatusForDoctor(root); + const statusByPath = new Map(statuses.map((entry) => [entry.path, entry])); + const allPaths = [...new Set([...configuredPaths, ...gitlinkPaths, ...statuses.map((entry) => entry.path)])] + .sort((left, right) => left.localeCompare(right)); + + trackedSubmodules += allPaths.length; + + for (const submodulePath of allPaths) { + const absolutePath = path.resolve(root, submodulePath); + const mappedRepoId = workspaceRepoPaths.get(normalizeDoctorPath(absolutePath)); + + if (wsResult.config && !mappedRepoId && submodulePolicy.repoIdStrategy === "path-basename") { + const derivedRepoId = path.basename(submodulePath).trim().toLowerCase(); + if (!DOCTOR_WORKSPACE_REPO_ID_PATTERN.test(derivedRepoId)) { + issues += reportDoctorSubmoduleFinding( + strictSubmoduleMode, + `${label}: submodule '${submodulePath}' is not declared in workspace.repos and basename import would derive invalid repo ID '${derivedRepoId}'`, + "Rename the submodule path or add an explicit workspace.repos entry with a valid repo ID, then rerun /orch-plan --sync.", + ); + } else { + const candidates = collisionCandidates.get(derivedRepoId) ?? []; + candidates.push(`${label}:${submodulePath}`); + collisionCandidates.set(derivedRepoId, candidates); + issues += reportDoctorSubmoduleFinding( + strictSubmoduleMode, + `${label}: submodule '${submodulePath}' is not declared in workspace.repos`, + `Run ${doctorPlannerSyncCommand()} to import it, or add a workspace.repos entry for '${submodulePath}' (repo ID '${derivedRepoId}' under path-basename strategy).`, + ); + } + } + + const status = statusByPath.get(submodulePath); + if (status?.state === "uninitialized" || (!fs.existsSync(absolutePath) && (configuredPaths.includes(submodulePath) || gitlinkPaths.includes(submodulePath)))) { + issues += reportDoctorSubmoduleFinding( + strictSubmoduleMode, + `${label}: submodule '${submodulePath}' is not initialized`, + buildDoctorUninitializedHint(submodulePolicy, root, submodulePath), + ); + continue; + } + + if (status?.state === "drifted" || status?.state === "conflict") { + issues += reportDoctorSubmoduleFinding( + strictSubmoduleMode, + `${label}: submodule '${submodulePath}' is ${status.state === "conflict" ? "in conflict" : "drifted from the recorded gitlink commit"}`, + buildDoctorDriftHint(submodulePolicy, root, submodulePath), + ); + } + } + } + + for (const [derivedRepoId, candidates] of collisionCandidates) { + if (candidates.length < 2) continue; + issues += reportDoctorSubmoduleFinding( + strictSubmoduleMode, + `multiple undeclared submodules would map to repo ID '${derivedRepoId}'`, + `Add explicit workspace.repos entries for ${candidates.join(", ")} instead of relying on path-basename imports, then rerun ${doctorPlannerSyncCommand()}.`, + ); + } + + if (trackedSubmodules === 0) { + console.log(` ${OK} no submodules detected`); + } else if (issues === 0 || !strictSubmoduleMode) { + console.log(` ${OK} submodule scan complete ${c.dim}(${trackedSubmodules} tracked)${c.reset}`); + } + // Check project config (common — both modes) console.log(); const hasUnifiedJson = fs.existsSync(path.join(configLocation.root, configLocation.prefix, "taskplane-config.json")); diff --git a/dashboard/public/app.js b/dashboard/public/app.js index 097da7b3..03efbf3a 100644 --- a/dashboard/public/app.js +++ b/dashboard/public/app.js @@ -208,6 +208,8 @@ const $ = (id) => document.getElementById(id); const $batchId = $("batch-id"); const $batchPhase = $("batch-phase"); +const $workspaceMode = $("workspace-mode"); +const $submoduleStatus = $("submodule-status"); const $connDot = $("conn-dot"); const $lastUpdate = $("last-update"); const $progressBarBg = $("progress-bar-bg"); @@ -247,9 +249,8 @@ let lastBatchId = null; // TP-178: track batchId for stale viewer detection (#4 /** * Build a sorted, deduplicated list of repo IDs from the batch payload. - * Returns empty array when mode !== "workspace" or when fewer than 2 repos. */ -function buildRepoSet(batch) { +function collectRepoIds(batch) { if (!batch || batch.mode !== "workspace") return []; const repos = new Set(); @@ -266,6 +267,15 @@ function buildRepoSet(batch) { } } const sorted = Array.from(repos).sort(); + return sorted; +} + +/** + * Build the repo set used by the filter dropdown. + * Returns empty array when mode !== "workspace" or when fewer than 2 repos. + */ +function buildRepoSet(batch) { + const sorted = collectRepoIds(batch); return sorted.length >= 2 ? sorted : []; } @@ -424,11 +434,34 @@ function renderHeader(batch) { $batchId.textContent = "—"; $batchPhase.textContent = "No batch"; $batchPhase.className = "header-badge badge-phase"; + $workspaceMode.style.display = "none"; + $submoduleStatus.style.display = "none"; return; } $batchId.textContent = batch.batchId; $batchPhase.textContent = batch.phase; $batchPhase.className = `header-badge badge-phase phase-${batch.phase}`; + + if (batch.mode === "workspace") { + const repoIds = collectRepoIds(batch); + const repoCount = repoIds.length; + $workspaceMode.style.display = ""; + $workspaceMode.textContent = `${repoCount} repo${repoCount === 1 ? "" : "s"}`; + $workspaceMode.title = repoCount > 0 + ? `Workspace batch spanning ${repoCount} repo${repoCount === 1 ? "" : "s"}` + : "Workspace batch"; + } else { + $workspaceMode.style.display = "none"; + } + + if (batch.workspaceSyncStatus) { + $submoduleStatus.style.display = ""; + $submoduleStatus.textContent = batch.workspaceSyncStatus.label; + $submoduleStatus.title = batch.workspaceSyncStatus.detail || "Workspace repo/submodule sync status"; + $submoduleStatus.className = `header-badge badge-sync sync-${batch.workspaceSyncStatus.state}`; + } else { + $submoduleStatus.style.display = "none"; + } } // ─── Render: Summary ──────────────────────────────────────────────────────── diff --git a/dashboard/public/index.html b/dashboard/public/index.html index 902e4c42..1fc5d2de 100644 --- a/dashboard/public/index.html +++ b/dashboard/public/index.html @@ -15,6 +15,8 @@ + + diff --git a/dashboard/public/style.css b/dashboard/public/style.css index cd947af6..b96d6163 100644 --- a/dashboard/public/style.css +++ b/dashboard/public/style.css @@ -201,6 +201,26 @@ body { color: var(--accent); } +.badge-workspace { + background: var(--accent-tint-bg); + border: 1px solid var(--border); + color: var(--accent); +} + +.badge-sync { + border: 1px solid var(--border); +} + +.sync-clean { + background: var(--green-tint-bg); + color: var(--green); +} + +.sync-none { + background: var(--badge-pending-bg); + color: var(--text-muted); +} + .badge-phase { font-weight: 600; text-transform: uppercase; diff --git a/dashboard/server.cjs b/dashboard/server.cjs index b0236020..37e3388f 100644 --- a/dashboard/server.cjs +++ b/dashboard/server.cjs @@ -1202,6 +1202,7 @@ function buildDashboardState() { // Workspace mode: "repo" (default/v1) or "workspace" (v2 multi-repo). // Additive field — absent in v1 state files, frontend must default to "repo". mode: state.mode || "repo", + workspaceSyncStatus: state.workspaceSyncStatus || null, // TP-148: Segment records for wave display context (v4+). // Each record has taskId, segmentId, repoId, status. segments: state.segments || [], diff --git a/docs/explanation/persistence-and-resume.md b/docs/explanation/persistence-and-resume.md index d9ed144a..b69d9861 100644 --- a/docs/explanation/persistence-and-resume.md +++ b/docs/explanation/persistence-and-resume.md @@ -23,7 +23,7 @@ Contains (high level): - wave plan and current wave index - per-lane records (session/worktree/branch/task IDs, repo ID) - per-task records (status, folder, session, timings, done marker, repo attribution) -- merge summaries (grouped by repo in workspace mode) +- merge summaries (grouped by repo in workspace mode, including rollback and persistence warnings for merge safe-stops) - aggregate counters and error history ### Lane sidecars (`.pi/lane-state-*.json`) @@ -62,6 +62,11 @@ During `/orch` execution, state is persisted at key transitions, including: - pause events - resume reconciliation points +Merge persistence note: + +- Workspace-mode merge checkpoints also preserve repo-scoped merge outcomes. +- If Taskplane rolls back a failed multi-repo wave, the persisted merge result records which repos were rolled back and whether any rollback or persistence warning requires manual inspection before resume. + This keeps recovery point close to live execution. --- @@ -70,14 +75,14 @@ This keeps recovery point close to live execution. `/orch-resume` resumes batches based on their phase: -| Phase | Resumable | Notes | -|-------|-----------|-------| -| `paused` | ✅ | Standard resume | -| `executing` | ✅ | Crash recovery — reconciles in-flight tasks | -| `merging` | ✅ | Interrupted merge recovery | -| `stopped` | ⚠️ | Requires `--force` (planned) | -| `failed` | ⚠️ | Requires `--force` (planned) | -| `completed` | ❌ | Terminal — start a new batch | +| Phase | Resumable | Notes | +| ----------- | --------- | ------------------------------------------- | +| `paused` | ✅ | Standard resume | +| `executing` | ✅ | Crash recovery — reconciles in-flight tasks | +| `merging` | ✅ | Interrupted merge recovery | +| `stopped` | ⚠️ | Requires `--force` (planned) | +| `failed` | ⚠️ | Requires `--force` (planned) | +| `completed` | ❌ | Terminal — start a new batch | --- diff --git a/docs/explanation/waves-lanes-and-worktrees.md b/docs/explanation/waves-lanes-and-worktrees.md index 00216ab7..6dc8d6a7 100644 --- a/docs/explanation/waves-lanes-and-worktrees.md +++ b/docs/explanation/waves-lanes-and-worktrees.md @@ -9,7 +9,7 @@ Parallel orchestration (`/orch`) is built on three concepts: Together with the **orch-managed branch model**, these concepts enable safe parallel task execution without touching the user's working branch. -### Single-repo mode vs workspace mode +## Single-repo mode vs workspace mode Taskplane operates in one of two modes depending on your project structure: @@ -90,11 +90,11 @@ configured `max_lanes` limit. ### Assignment strategies -| Strategy | Behavior | -|----------|----------| +| Strategy | Behavior | +| -------------------------- | --------------------------------------------------------------------------------------------------------- | | `affinity-first` (default) | Tasks sharing overlapping `## File Scope` entries are grouped onto the same lane to avoid merge conflicts | -| `round-robin` | Tasks distributed evenly across lanes | -| `load-balanced` | Tasks distributed by estimated size (`size_weights`: S=1, M=2, L=4) | +| `round-robin` | Tasks distributed evenly across lanes | +| `load-balanced` | Tasks distributed by estimated size (`size_weights`: S=1, M=2, L=4) | ### Why affinity matters @@ -265,6 +265,7 @@ performs the merge intelligently: - Instructions for conflict resolution 2. The merge agent runs in a temporary **merge worktree**: + ``` .worktrees/{batchId}/merge/ ``` @@ -290,6 +291,7 @@ they're complementary, and produces a correct resolution that includes both. This is critical for parallel task execution. Without intelligent merge, you'd need to either: + - Serialize all tasks (slow) - Manually resolve every conflict (defeats automation) - Hope file scopes don't overlap (fragile) @@ -305,6 +307,7 @@ agent every 2 minutes: detect stalls Escalation thresholds: + - **Stale** (10 min no output): emits `merge_health_stale` event - **Stuck** (20 min no output): emits `merge_health_stuck` event with a recommendation to kill and retry @@ -332,27 +335,31 @@ failures from merge resolution errors) before reporting failure. When a merge fails (timeout, unresolvable conflict, verification failure): -| Policy | Behavior | -|--------|----------| +| Policy | Behavior | +| ----------------------------------- | -------------------------------------------------------------- | | `on_merge_failure: pause` (default) | Batch pauses, preserving all state for supervisor intervention | -| `on_merge_failure: abort` | Batch stops entirely | +| `on_merge_failure: abort` | Batch stops entirely | The supervisor can then: + 1. Inspect merge diagnostics (`read_lane_logs`, event log) 2. Manually resolve in the merge worktree if needed 3. Resume the batch with `orch_resume()` ### Per-repo merge (workspace mode) -In workspace mode, merges happen independently per repository: +In workspace mode, merge work still runs per repository, but the wave outcome is atomic across all participating repos: 1. For each repo that had lanes in this wave: - Create a temporary merge worktree on the orch branch - Merge each lane branch sequentially - Run verification commands per repo - Stage task artifacts - - Update the orch branch ref + - Tentatively update the repo's orch branch ref - Clean up +2. If every repo group succeeds, the wave is finalized. +3. If any repo group fails, Taskplane rolls every already-advanced repo ref back to its pre-wave head and reports the wave as failed instead of partially succeeded. +4. If a rollback fails, the batch safe-stops in `paused` state with recovery guidance so the operator can inspect the affected repos manually. ### Artifact staging @@ -380,6 +387,7 @@ In workspace mode, `/orch-integrate` loops over all repos that have an orch branch and integrates each one. After successful integration: + - The local orch branch is deleted - Batch state is preserved (for diagnostics) but marked completed @@ -390,11 +398,11 @@ After successful integration: The `on_task_failure` policy controls what happens to tasks that depend on a failed task: -| Policy | Behavior | -|--------|----------| +| Policy | Behavior | +| --------------------------- | ---------------------------------------------------------- | | `skip-dependents` (default) | Failed task's dependents are blocked; other tasks continue | -| `stop-wave` | Remaining tasks in the current wave are cancelled | -| `stop-all` | Entire batch stops immediately | +| `stop-wave` | Remaining tasks in the current wave are cancelled | +| `stop-all` | Entire batch stops immediately | Blocked and skipped tasks are tracked in batch state counters and visible in the dashboard. @@ -405,15 +413,15 @@ dashboard. Compared to running many agents in one working directory: -| Concern | Taskplane | Shared directory | -|---------|-----------|-----------------| -| **File conflicts** | Impossible — worktree isolation | Frequent — agents overwrite each other | -| **Merge safety** | LLM merge agent resolves conflicts semantically, with test verification | No merge step — conflicts accumulate silently | -| **Conflict resolution** | AI understands both sides' intent, produces correct combined code | Manual resolution or corrupted output | -| **User branch safety** | Untouched until `/orch-integrate` | Modified directly, no rollback | -| **Debugging** | Each lane has its own branch, worktree, and commit history | One tangled history | -| **Resumability** | File-backed state survives any crash | Lost on restart | -| **Parallelism** | Bounded by lanes, safe by design | Unbounded and unsafe | +| Concern | Taskplane | Shared directory | +| ----------------------- | ----------------------------------------------------------------------- | --------------------------------------------- | +| **File conflicts** | Impossible — worktree isolation | Frequent — agents overwrite each other | +| **Merge safety** | LLM merge agent resolves conflicts semantically, with test verification | No merge step — conflicts accumulate silently | +| **Conflict resolution** | AI understands both sides' intent, produces correct combined code | Manual resolution or corrupted output | +| **User branch safety** | Untouched until `/orch-integrate` | Modified directly, no rollback | +| **Debugging** | Each lane has its own branch, worktree, and commit history | One tangled history | +| **Resumability** | File-backed state survives any crash | Lost on restart | +| **Parallelism** | Bounded by lanes, safe by design | Unbounded and unsafe | --- diff --git a/docs/how-to/configure-task-orchestrator.md b/docs/how-to/configure-task-orchestrator.md index f5583f72..a939fe8e 100644 --- a/docs/how-to/configure-task-orchestrator.md +++ b/docs/how-to/configure-task-orchestrator.md @@ -114,6 +114,13 @@ Optional pre-run commands (disabled by default). - `verify` — commands run after each merge (add only safe, deterministic checks). - `order` — lane merge order policy. +Workspace mode note: + +- Merge attempts still run per repo, but the wave commits atomically across participating repos. +- A repo-local merge success is provisional until every repo group in the wave succeeds. +- If any repo group fails, Taskplane rolls already-advanced repo refs back to their pre-wave heads and reports the wave as failed. +- If rollback itself fails, Taskplane forces a pause-safe-stop so the partial recovery state can be inspected manually. + ### `orchestrator.failure` ```json @@ -137,6 +144,7 @@ Optional pre-run commands (disabled by default). - `onMergeFailure`: - `"pause"` (recommended) - `"abort"` +- In workspace mode, `onMergeFailure` applies after atomic rollback is attempted. A rollback failure always forces a pause so refs and worktrees are preserved for recovery. - `stallTimeout` — minutes before a task is considered stalled. - `maxWorkerMinutes` — task-runner timeout budget in orchestrated runs. - `abortGracePeriod` — graceful abort wait (seconds) before force kill. @@ -186,6 +194,7 @@ Start conservative, then increase parallelism after stable runs. - Increase `maxLanes` only if your tests/CI and machine can handle it. - In workspace mode, `maxLanes` is enforced as a **global cap** across all repos — not per-repo. With `maxLanes: 4` and 3 repos, each repo gets at least 1 lane, with remaining lanes allocated proportionally. +- Multi-repo tasks should declare their target repos in `## Execution Target` using `Repo:` or `Repos:`. Use `## Segment DAG` only when you need explicit ordering between those repos. - Keep `onMergeFailure: "pause"` so humans can resolve conflicts and `/orch-resume`. - Keep `verify` short and deterministic to avoid slow merge bottlenecks. diff --git a/docs/reference/commands.md b/docs/reference/commands.md index d0a1e79f..ee28c8e2 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -105,19 +105,28 @@ See also: [`/orch-integrate`](#orch-integrate-orch-branch---merge---pr---force) --- -### `/orch-plan [--refresh]` +### `/orch-plan [--refresh] [--sync]` Preview execution plan without running tasks. **Syntax** ```text -/orch-plan [--refresh] +/orch-plan [--refresh] [--sync] ``` **Options** - `--refresh` — bypass dependency cache and force re-scan +- `--sync` — reconcile workspace repo imports and submodule state before planning + +When Taskplane detects workspace repo import gaps or submodule drift, `/orch-plan` +stops before discovery until those findings are resolved. Re-run the same target with +`--sync` to: + +- add auto-derivable repo entries to `.pi/taskplane-workspace.yaml` +- run `git submodule update ...` according to the configured **On Submodule Drift** policy +- re-run preflight before showing the plan **Output includes** @@ -131,6 +140,7 @@ Preview execution plan without running tasks. ```text /orch-plan all /orch-plan auth billing +/orch-plan all --sync /orch-plan all --refresh ``` @@ -474,7 +484,7 @@ Open the interactive settings TUI for viewing and editing taskplane configuratio **Behavior** - Shows a two-level navigation: section selector → field list -- Displays 14 sections covering orchestrator, supervisor, task-runner, agent extensions, global preferences, and advanced (JSON-only) fields +- Displays 15 sections covering orchestrator, supervisor, task-runner, workspace submodule policy, agent extensions, global preferences, and advanced (JSON-only) fields - Each field shows its current value and source indicator: `(project)` or `(global)` - Enum and boolean fields use toggleable controls; strings and numbers use text input - Global-preference changes write to `~/.pi/agent/taskplane/preferences.json` @@ -500,6 +510,7 @@ Open the interactive settings TUI for viewing and editing taskplane configuratio | Assignment | Task assignment strategy | | Pre-Warm | Auto-detection settings | | Monitoring | Poll interval | +| Submodules | Workspace submodule policy, sync mode, and repo ID derivation | | Global Preferences | Dashboard port and other per-user settings | | Advanced (JSON Only) | Read-only listing of uncovered/non-editable fields | diff --git a/docs/reference/configuration/taskplane-settings.md b/docs/reference/configuration/taskplane-settings.md index a2c1bc47..8977bed4 100644 --- a/docs/reference/configuration/taskplane-settings.md +++ b/docs/reference/configuration/taskplane-settings.md @@ -49,6 +49,7 @@ Settings that control how `/orch` runs parallel task batches. | **Tmux Prefix** | string | `orch` | Any string | Prefix for orchestrator tmux session names. Sessions are named `{prefix}-{opId}-lane-{N}`. Change if you run multiple taskplane instances and need distinct session names. *(L1+L2)* | | **Operator ID** | string | *(auto-detect)* | Any string | Identifier for this operator. Auto-detected from OS username if empty. Used in session names, worktree paths, and branch names for collision resistance when multiple people run batches on the same repo. *(L1+L2)* | | **Integration** | enum | `manual` | `manual`, `supervised`, `auto` | How completed batches are integrated into your working branch. `manual` = you run `/orch-integrate` after the batch completes (gives you full control over timing and integration mode). `supervised` = the supervisor proposes an integration plan, asks for your confirmation, then executes it. `auto` = the supervisor executes integration automatically without asking, pausing only if issues arise (conflicts, CI failures). Both `supervised` and `auto` detect branch protection and default to PR mode when the target branch is protected. See [`/orch-integrate`](../commands.md) for details on the manual integration flow. | +| **Submodule Repo IDs** | enum | `path-basename` | `path-basename` | How Taskplane derives workspace repo IDs when `/orch-plan --sync` imports undeclared submodules into `workspace.repos`. Current behavior lowercases the submodule path basename, so invalid or colliding basenames are surfaced by preflight and doctor. | --- @@ -114,6 +115,8 @@ Settings that control what happens when things go wrong during a batch. |---------|------|---------|---------|-------------| | **On Task Failure** | enum | `skip-dependents` | `skip-dependents`, `stop-wave`, `stop-all` | What happens when a task fails. `skip-dependents` = skip tasks that depend on the failed task, continue others. `stop-wave` = stop the current wave, skip remaining waves. `stop-all` = immediately stop everything. | | **On Merge Failure** | enum | `pause` | `pause`, `abort` | What happens when a merge fails. `pause` = pause the batch so you can inspect and resume. `abort` = terminate the batch entirely. | +| **Submodule Failure Mode** | enum | `permissive` | `permissive`, `strict` | Severity for submodule findings. `permissive` = show warnings during preflight and doctor. `strict` = treat undeclared, invalid, uninitialized, or drifted submodules as failing findings. `/orch` still requires you to resolve workspace sync issues before launch. | +| **On Submodule Drift** | enum | `manual` | `manual`, `init-only`, `recursive-on-drift` | Remediation policy used by `/orch-plan --sync`. `manual` = planner sync only updates importable workspace repo entries and leaves git checkout repair to you. `init-only` = initialize missing submodules but do not repair drift. `recursive-on-drift` = run recursive `git submodule update --init --recursive` for missing or drifted submodules. | | **Stall Timeout (min)** | number | `30` | Any positive number | Minutes of no progress before a task is considered stalled. The monitor checks STATUS.md for changes — if no checkboxes are checked for this duration, the task is killed. | | **Max Worker Min** | number | `30` | Any positive number | Maximum wall-clock minutes a worker can run per task in orchestrated mode. Prevents runaway tasks from blocking the batch indefinitely. | | **Abort Grace (sec)** | number | `60` | Any positive number | Seconds to wait after sending an abort signal before force-killing a session. Gives workers time to commit their current work. | diff --git a/docs/reference/task-format.md b/docs/reference/task-format.md index f305a5c3..e97a8a4c 100644 --- a/docs/reference/task-format.md +++ b/docs/reference/task-format.md @@ -72,6 +72,7 @@ Canonical folder: - `## Dependencies` - `## Context to Read First` - `## File Scope` +- `## Execution Target` (`Repo:` / `Repos:` for repo targeting) - `## Segment DAG` (optional, for explicit multi-repo segment ordering) - `## Completion Criteria` @@ -138,6 +139,7 @@ assign checkboxes to specific repos: ``` Rules: + - Marker format: `#### Segment: ` (case-sensitive, must match workspace config) - Single-repo tasks do not need segment markers (the engine applies a default) - Every step in a multi-repo task should have explicit segment markers @@ -215,11 +217,52 @@ Example: Describe intended modification surface to improve planning/review quality. +Notes: + +- In workspace mode, repo-prefixed entries like `api/src/...` or `web-client/src/...` are used for repo inference when `## Execution Target` is omitted. +- When `## Execution Target` is present, every repo-prefixed `## File Scope` entry must belong to one of the declared target repos. + --- +## `Execution Target` (repo targeting) + +Use `## Execution Target` to declare which repo or repos a task runs against. + +Single-repo example: + +```md +## Execution Target +Repo: api +``` + +Multi-repo example: + +```md +## Execution Target +Repos: +- api +- web-client +``` + +Inline forms are also accepted: + +```md +## Execution Target +**Repo:** api +**Repos:** api, web-client +``` + +Notes: + +- `Repo:` targets one repo. +- `Repos:` targets multiple repos and enables workspace-mode multi-repo routing. +- Repo IDs are normalized to lowercase. +- Repo IDs must exist in the workspace configuration. +- When `## File Scope` uses repo-prefixed paths, those prefixes must agree with `Repo:` or `Repos:`. + ## `Segment DAG` (optional explicit multi-repo ordering) -Use when a task intentionally spans multiple repos and needs explicit intra-task ordering. +Use `## Segment DAG` only when a task already targets multiple repos and needs explicit intra-task ordering. ```md ## Segment DAG @@ -233,9 +276,11 @@ Edges: ``` Notes: -- Optional section — omission keeps legacy behavior. + +- Optional section — omission keeps planner-selected ordering. - `Repos:` and `Edges:` keys may be markdown-decorated (e.g. `**Repos:**`). - Repo IDs are normalized to lowercase. +- `Repos:` should match the repo set already declared in `## Execution Target`. - Edge endpoints must appear in `Repos:`. - Self-edges and cycles are invalid and fail discovery. diff --git a/extensions/.pi-lens/turn-state.json b/extensions/.pi-lens/turn-state.json new file mode 100644 index 00000000..bf4b24ad --- /dev/null +++ b/extensions/.pi-lens/turn-state.json @@ -0,0 +1,17 @@ +{ + "files": { + "taskplane/execution.ts": { + "modifiedRanges": [ + { + "start": 574, + "end": 602 + } + ], + "importsChanged": false, + "lastEdit": "2026-04-23T00:23:10.988Z" + } + }, + "turnCycles": 0, + "maxCycles": 3, + "lastUpdated": "2026-04-23T00:23:10.988Z" +} \ No newline at end of file diff --git a/extensions/taskplane/config-loader.ts b/extensions/taskplane/config-loader.ts index b78d7b13..8b3ea463 100644 --- a/extensions/taskplane/config-loader.ts +++ b/extensions/taskplane/config-loader.ts @@ -360,7 +360,6 @@ function mapOrchestratorYaml(raw: any): Partial { return result; } - /** * Normalize a workspace section loaded from JSON/YAML into camelCase shape. * @@ -375,55 +374,51 @@ function normalizeWorkspaceSection( return undefined; } - const rawRepos = rawWorkspace.repos; - if (!rawRepos || typeof rawRepos !== "object" || Array.isArray(rawRepos)) { - return undefined; - } + const normalized: WorkspaceSectionConfig = {}; + const rawRepos = rawWorkspace.repos; const rawRouting = rawWorkspace.routing; - if (!rawRouting || typeof rawRouting !== "object" || Array.isArray(rawRouting)) { - return undefined; - } - - const repos: WorkspaceSectionConfig["repos"] = {}; - for (const [repoId, repoVal] of Object.entries(rawRepos as Record)) { - if (!repoVal || typeof repoVal !== "object" || Array.isArray(repoVal)) continue; - const repoObj = repoVal as Record; - if (typeof repoObj.path !== "string" || repoObj.path.trim() === "") continue; - repos[repoId] = { - path: repoObj.path, - ...(typeof repoObj.defaultBranch === "string" && repoObj.defaultBranch.trim() - ? { defaultBranch: repoObj.defaultBranch } - : {}), - }; - } + if ( + rawRepos && typeof rawRepos === "object" && !Array.isArray(rawRepos) && + rawRouting && typeof rawRouting === "object" && !Array.isArray(rawRouting) + ) { + const repos: NonNullable = {}; + for (const [repoId, repoVal] of Object.entries(rawRepos as Record)) { + if (!repoVal || typeof repoVal !== "object" || Array.isArray(repoVal)) continue; + const repoObj = repoVal as Record; + if (typeof repoObj.path !== "string" || repoObj.path.trim() === "") continue; + repos[repoId] = { + path: repoObj.path, + ...(typeof repoObj.defaultBranch === "string" && repoObj.defaultBranch.trim() + ? { defaultBranch: repoObj.defaultBranch } + : {}), + }; + } - const defaultRepo = typeof rawRouting.defaultRepo === "string" ? rawRouting.defaultRepo.trim() : ""; - const tasksRoot = typeof rawRouting.tasksRoot === "string" ? rawRouting.tasksRoot.trim() : ""; - let taskPacketRepo = typeof rawRouting.taskPacketRepo === "string" ? rawRouting.taskPacketRepo.trim() : ""; + const defaultRepo = typeof rawRouting.defaultRepo === "string" ? rawRouting.defaultRepo.trim() : ""; + const tasksRoot = typeof rawRouting.tasksRoot === "string" ? rawRouting.tasksRoot.trim() : ""; + let taskPacketRepo = typeof rawRouting.taskPacketRepo === "string" ? rawRouting.taskPacketRepo.trim() : ""; - if (!taskPacketRepo && defaultRepo) { - taskPacketRepo = defaultRepo; - console.error( - `[taskplane] config compatibility: workspace.routing.taskPacketRepo is missing in ${sourcePath}; defaulting to workspace.routing.defaultRepo ('${defaultRepo}'). Add workspace.routing.taskPacketRepo explicitly.`, - ); - } + if (!taskPacketRepo && defaultRepo) { + taskPacketRepo = defaultRepo; + console.error( + `[taskplane] config compatibility: workspace.routing.taskPacketRepo is missing in ${sourcePath}; defaulting to workspace.routing.defaultRepo ('${defaultRepo}'). Add workspace.routing.taskPacketRepo explicitly.`, + ); + } - if (!tasksRoot || !defaultRepo || !taskPacketRepo) { - return undefined; + if (tasksRoot && defaultRepo && taskPacketRepo && Object.keys(repos).length > 0) { + const strict = rawRouting.strict === true; + normalized.repos = repos; + normalized.routing = { + tasksRoot, + defaultRepo, + taskPacketRepo, + ...(strict ? { strict: true } : {}), + }; + } } - const strict = rawRouting.strict === true; - - return { - repos, - routing: { - tasksRoot, - defaultRepo, - taskPacketRepo, - ...(strict ? { strict: true } : {}), - }, - }; + return Object.keys(normalized).length > 0 ? normalized : undefined; } @@ -781,7 +776,10 @@ function extractAllowlistedPreferences(raw: Record, prefsPath: stri const workspaceOverrides = extractConfigOverrideSection(raw.workspace); if (workspaceOverrides) { - prefs.workspace = workspaceOverrides as GlobalPreferences["workspace"]; + const normalizedWorkspace = normalizeWorkspaceSection(workspaceOverrides, prefsPath); + if (normalizedWorkspace) { + prefs.workspace = normalizedWorkspace as GlobalPreferences["workspace"]; + } } // Legacy flat aliases (backward compatibility for existing preferences.json files) @@ -838,10 +836,6 @@ export function applyGlobalPreferences(config: TaskplaneConfig, prefs: GlobalPre // spawnMode: enum — apply if defined (not a string-empty check) if (prefs.spawnMode !== undefined) { - if (prefs.spawnMode === "tmux") { - prefs.spawnMode = "subprocess"; - console.error(`[taskplane] Auto-migrated runtime preference: spawnMode "tmux" → "subprocess"`); - } config.orchestrator.orchestrator.spawnMode = prefs.spawnMode; } @@ -956,7 +950,7 @@ function migrateProjectOverrides(overrides: Partial, configRoot if (_projectMigrationDone) return false; let migrated = false; - const orchestratorCore = overrides.orchestrator?.orchestrator as Record | undefined; + const orchestratorCore = overrides.orchestrator?.orchestrator as unknown as Record | undefined; if (orchestratorCore && hasOwn(orchestratorCore, "tmuxPrefix")) { const currentPrefix = orchestratorCore.sessionPrefix; const isDefault = currentPrefix === undefined || currentPrefix === "orch"; @@ -973,7 +967,7 @@ function migrateProjectOverrides(overrides: Partial, configRoot migrated = true; } - const workerConfig = overrides.taskRunner?.worker as Record | undefined; + const workerConfig = overrides.taskRunner?.worker as unknown as Record | undefined; if (workerConfig?.spawnMode === "tmux") { (workerConfig as any).spawnMode = "subprocess"; console.error(`[taskplane] Auto-migrated: taskRunner.worker.spawnMode "tmux" → "subprocess"`); @@ -1024,8 +1018,8 @@ export function loadProjectOverrides(configRoot: string): Partial = {}; - if (Object.keys(taskRunner).length > 0) overrides.taskRunner = taskRunner; - if (Object.keys(orchestrator).length > 0) overrides.orchestrator = orchestrator; + if (Object.keys(taskRunner).length > 0) overrides.taskRunner = taskRunner as TaskplaneConfig["taskRunner"]; + if (Object.keys(orchestrator).length > 0) overrides.orchestrator = orchestrator as TaskplaneConfig["orchestrator"]; if (workspace) overrides.workspace = workspace; return overrides; } @@ -1054,7 +1048,6 @@ export function loadProjectConfig(cwd: string, pointerConfigRoot?: string): Task _projectMigrationDone = false; migrateProjectOverrides(overrides, configRoot); mergeProjectOverrides(config, overrides); - normalizeInheritanceAliases(config); return config; } @@ -1072,7 +1065,6 @@ export function loadLayer1Config(cwd: string, pointerConfigRoot?: string): Taskp _projectMigrationDone = false; migrateProjectOverrides(overrides, configRoot); mergeProjectOverrides(config, overrides); - normalizeInheritanceAliases(config); return config; } @@ -1102,6 +1094,7 @@ export function toOrchestratorConfig(config: TaskplaneConfig): import("./types.t sessionPrefix: o.orchestrator.sessionPrefix, operator_id: o.orchestrator.operatorId, integration: o.orchestrator.integration, + submodule_repo_id_strategy: o.orchestrator.submoduleRepoIdStrategy, }, dependencies: { source: o.dependencies.source, @@ -1130,6 +1123,8 @@ export function toOrchestratorConfig(config: TaskplaneConfig): import("./types.t failure: { on_task_failure: o.failure.onTaskFailure, on_merge_failure: o.failure.onMergeFailure, + submodule_failure_mode: o.failure.submoduleFailureMode, + on_submodule_drift: o.failure.onSubmoduleDrift, stall_timeout: o.failure.stallTimeout, max_worker_minutes: o.failure.maxWorkerMinutes, abort_grace_period: o.failure.abortGracePeriod, diff --git a/extensions/taskplane/config-schema.ts b/extensions/taskplane/config-schema.ts index 6c2b20cc..979bf25d 100644 --- a/extensions/taskplane/config-schema.ts +++ b/extensions/taskplane/config-schema.ts @@ -272,6 +272,8 @@ export interface OrchestratorCoreConfig { operatorId: string; /** How completed batches are integrated. manual = user runs /orch-integrate. supervised = supervisor proposes plan, asks confirmation. auto = supervisor executes without asking. */ integration: "manual" | "supervised" | "auto"; + /** Strategy used when deriving workspace repo IDs from submodule paths. */ + submoduleRepoIdStrategy: "path-basename"; } /** Dependency resolution settings */ @@ -324,6 +326,10 @@ export interface FailureConfig { onTaskFailure: "skip-dependents" | "stop-wave" | "stop-all"; /** Behavior when a merge step fails */ onMergeFailure: "pause" | "abort"; + /** Whether submodule findings warn or block orchestrator startup. */ + submoduleFailureMode: "permissive" | "strict"; + /** How submodule drift/uninitialized state should be reconciled. */ + onSubmoduleDrift: "manual" | "init-only" | "recursive-on-drift"; /** Stall detection threshold (minutes) */ stallTimeout: number; /** Max worker runtime budget per task in orchestrated mode (minutes) */ @@ -447,10 +453,10 @@ export interface WorkspaceRoutingSectionConfig { /** Optional workspace section in taskplane-config.json. */ export interface WorkspaceSectionConfig { - /** Repo map keyed by repo ID. */ - repos: Record; - /** Routing contract for workspace mode. */ - routing: WorkspaceRoutingSectionConfig; + /** Repo map keyed by repo ID. Present when JSON config carries workspace routing metadata. */ + repos?: Record; + /** Routing contract for workspace mode. Present when JSON config carries workspace routing metadata. */ + routing?: WorkspaceRoutingSectionConfig; } @@ -630,6 +636,7 @@ export const DEFAULT_ORCHESTRATOR_SECTION: OrchestratorSection = { sessionPrefix: "orch", operatorId: "", integration: "manual", + submoduleRepoIdStrategy: "path-basename", }, dependencies: { source: "prompt", @@ -656,6 +663,8 @@ export const DEFAULT_ORCHESTRATOR_SECTION: OrchestratorSection = { failure: { onTaskFailure: "skip-dependents", onMergeFailure: "pause", + submoduleFailureMode: "permissive", + onSubmoduleDrift: "manual", stallTimeout: 60, maxWorkerMinutes: 120, abortGracePeriod: 60, diff --git a/extensions/taskplane/diagnostic-reports.ts b/extensions/taskplane/diagnostic-reports.ts index a40d4b3c..01aa0fdd 100644 --- a/extensions/taskplane/diagnostic-reports.ts +++ b/extensions/taskplane/diagnostic-reports.ts @@ -203,6 +203,12 @@ function formatCost(cost: number): string { return `$${cost.toFixed(4)}`; } +function formatReason(reason: string): string { + const normalized = reason.replace(/\r?\n/g, " / ").replace(/\|/g, "\\|").trim(); + if (!normalized) return "-"; + return normalized.length > 96 ? `${normalized.slice(0, 93)}...` : normalized; +} + /** * Generate a human-readable markdown summary report. */ @@ -244,11 +250,11 @@ export function buildMarkdownReport(input: DiagnosticReportInput, events: Diagno lines.push(`_No task records available._`); lines.push(``); } else { - lines.push(`| Task | Status | Classification | Cost | Duration | Retries |`); - lines.push(`|------|--------|---------------|------|----------|---------|`); + lines.push(`| Task | Status | Classification | Reason | Cost | Duration | Retries |`); + lines.push(`|------|--------|---------------|--------|------|----------|---------|`); for (const evt of events) { lines.push( - `| ${evt.taskId} | ${evt.status} | ${evt.classification} | ${formatCost(evt.cost)} | ${formatDuration(evt.durationSec)} | ${evt.retries} |` + `| ${evt.taskId} | ${evt.status} | ${evt.classification} | ${formatReason(evt.exitReason)} | ${formatCost(evt.cost)} | ${formatDuration(evt.durationSec)} | ${evt.retries} |` ); } lines.push(``); @@ -286,11 +292,11 @@ export function buildMarkdownReport(input: DiagnosticReportInput, events: Diagno lines.push(`- Cost: ${formatCost(repoCost)}`); lines.push(``); - lines.push(`| Task | Status | Classification | Cost | Duration |`); - lines.push(`|------|--------|---------------|------|----------|`); + lines.push(`| Task | Status | Classification | Reason | Cost | Duration |`); + lines.push(`|------|--------|---------------|--------|------|----------|`); for (const evt of repoEvents) { lines.push( - `| ${evt.taskId} | ${evt.status} | ${evt.classification} | ${formatCost(evt.cost)} | ${formatDuration(evt.durationSec)} |` + `| ${evt.taskId} | ${evt.status} | ${evt.classification} | ${formatReason(evt.exitReason)} | ${formatCost(evt.cost)} | ${formatDuration(evt.durationSec)} |` ); } lines.push(``); diff --git a/extensions/taskplane/diagnostics.ts b/extensions/taskplane/diagnostics.ts index d752e8ff..2b53e397 100644 --- a/extensions/taskplane/diagnostics.ts +++ b/extensions/taskplane/diagnostics.ts @@ -47,6 +47,9 @@ export interface SessionTokenCounts { * | `api_error` | API returned error (auth, rate limit, overload) | * | `model_access_error` | Model unavailable (401/403/429, model not found) | * | `context_overflow` | Hit context window limit (compactions + high ctx %) | + * | `unsafe_submodule_dirty` | Task left a submodule worktree with uncommitted changes | + * | `unsafe_submodule_unpublished_commit` | Task advanced a submodule to a local-only commit | + * | `unsafe_submodule_unreachable_ref` | Task/merge introduced a submodule gitlink clones cannot fetch | * | `wall_clock_timeout` | Killed by task-runner's max_worker_minutes timer | * | `process_crash` | Non-zero exit code with no API error indicators | * | `session_vanished` | Session disappeared without exit summary | @@ -59,6 +62,9 @@ export type ExitClassification = | "api_error" | "model_access_error" | "context_overflow" + | "unsafe_submodule_dirty" + | "unsafe_submodule_unpublished_commit" + | "unsafe_submodule_unreachable_ref" | "wall_clock_timeout" | "process_crash" | "session_vanished" @@ -74,6 +80,9 @@ export const EXIT_CLASSIFICATIONS: readonly ExitClassification[] = [ "api_error", "model_access_error", "context_overflow", + "unsafe_submodule_dirty", + "unsafe_submodule_unpublished_commit", + "unsafe_submodule_unreachable_ref", "wall_clock_timeout", "process_crash", "session_vanished", @@ -172,6 +181,8 @@ export interface ExitClassificationInput { timerKilled: boolean; /** Whether the task-runner explicitly killed the session due to context limit (TP-026) */ contextKilled?: boolean; + /** Explicit unsafe-submodule failure category when runtime safety checks reject the result */ + unsafeSubmoduleKind?: "dirty-worktree" | "unpublished-commit" | "unreachable-ref" | null; /** Whether monitoring detected a stall (no STATUS.md progress) */ stallDetected: boolean; /** Whether the user manually killed the session */ @@ -288,12 +299,13 @@ export function isModelAccessError(errorMessage: string): boolean { * | 2c | Error message has model-access pattern (no retries) | `model_access_error` | * | 3 | Compactions > 0 AND contextPct ≥ 90% | `context_overflow` | * | 3b | Task-runner explicitly context-killed | `context_overflow` | - * | 4 | Timer killed the session | `wall_clock_timeout` | - * | 5 | Non-zero exit code, no API error | `process_crash` | - * | 6 | No exit summary file (session vanished) | `session_vanished` | - * | 7 | Stall detected (no STATUS.md progress) | `stall_timeout` | - * | 8 | User manually killed the session | `user_killed` | - * | 9 | None of the above | `unknown` | + * | 4 | Runtime safety rejected unsafe submodule state | `unsafe_submodule_*` | + * | 5 | Timer killed the session | `wall_clock_timeout` | + * | 6 | Non-zero exit code, no API error | `process_crash` | + * | 7 | No exit summary file (session vanished) | `session_vanished` | + * | 8 | Stall detected (no STATUS.md progress) | `stall_timeout` | + * | 9 | User manually killed the session | `user_killed` | + * | 10 | None of the above | `unknown` | * * **Tie-break rationale:** * - `.DONE` always wins because the task succeeded regardless of how messy @@ -302,6 +314,8 @@ export function isModelAccessError(errorMessage: string): boolean { * and enables targeted fallback (retry with session model). * - `api_error` beats `context_overflow` because API failures are more * actionable (auth fix, rate limit backoff). + * - Explicit unsafe-submodule failures beat generic process crashes because + * the runtime safety guard already knows why the task was rejected. * - `wall_clock_timeout` beats `process_crash` because the timer kill * explains the non-zero exit code. * - `session_vanished` (no summary) is checked after exit-code-based @@ -315,6 +329,7 @@ export function isModelAccessError(errorMessage: string): boolean { export function classifyExit(input: ExitClassificationInput): ExitClassification { const { exitSummary, doneFileFound, timerKilled, stallDetected, userKilled, contextPct } = input; const contextKilled = input.contextKilled ?? false; + const unsafeSubmoduleKind = input.unsafeSubmoduleKind ?? null; // 1. .DONE file found → completed (task succeeded, regardless of session state) if (doneFileFound) { @@ -354,32 +369,43 @@ export function classifyExit(input: ExitClassificationInput): ExitClassification return "context_overflow"; } - // 4. Task-runner's wall-clock timer killed the session → wall_clock_timeout + // 4. Runtime safety rejected an unsafe submodule/gitlink state. + if (unsafeSubmoduleKind === "dirty-worktree") { + return "unsafe_submodule_dirty"; + } + if (unsafeSubmoduleKind === "unpublished-commit") { + return "unsafe_submodule_unpublished_commit"; + } + if (unsafeSubmoduleKind === "unreachable-ref") { + return "unsafe_submodule_unreachable_ref"; + } + + // 5. Task-runner's wall-clock timer killed the session → wall_clock_timeout if (timerKilled) { return "wall_clock_timeout"; } - // 5. Non-zero exit code, no API error indicators → process_crash + // 6. Non-zero exit code, no API error indicators → process_crash // Guard with typeof to handle partial summaries where exitCode may be undefined if (exitSummary && typeof exitSummary.exitCode === "number" && exitSummary.exitCode !== 0) { return "process_crash"; } - // 6. No exit summary file found → session_vanished + // 7. No exit summary file found → session_vanished if (exitSummary === null) { return "session_vanished"; } - // 7. Stall detected (no STATUS.md progress) → stall_timeout + // 8. Stall detected (no STATUS.md progress) → stall_timeout if (stallDetected) { return "stall_timeout"; } - // 8. User manually killed the session → user_killed + // 9. User manually killed the session → user_killed if (userKilled) { return "user_killed"; } - // 9. None of the above → unknown + // 10. None of the above → unknown return "unknown"; } diff --git a/extensions/taskplane/discovery.ts b/extensions/taskplane/discovery.ts index 053876e6..b6532a44 100644 --- a/extensions/taskplane/discovery.ts +++ b/extensions/taskplane/discovery.ts @@ -65,6 +65,60 @@ function normalizeSegmentRepoToken(raw: string): string { return token.toLowerCase(); } +function parseExecutionTargetRepoList(raw: string, repoIdPattern: RegExp): string[] | undefined { + const repoIds: string[] = []; + const seen = new Set(); + + for (const entry of raw.split(",")) { + const candidate = normalizeSegmentRepoToken(entry); + if (!candidate) continue; + if (!repoIdPattern.test(candidate)) { + return undefined; + } + if (!seen.has(candidate)) { + seen.add(candidate); + repoIds.push(candidate); + } + } + + return repoIds.length > 0 ? repoIds : undefined; +} + +function parseExecutionTargetReposSection(raw: string, repoIdPattern: RegExp): string[] | undefined { + const inlineMatch = raw.match(/^[ \t]*\*?\*?(?:Repos):?\*?\*?[ \t]+(.+)$/mi); + if (inlineMatch) { + return parseExecutionTargetRepoList(inlineMatch[1], repoIdPattern); + } + + const lines = raw.split(/\r?\n/); + for (let index = 0; index < lines.length; index++) { + const trimmed = lines[index].trim(); + if (!/^\*?\*?Repos:?\*?\*?\s*$/i.test(trimmed)) { + continue; + } + + const entries: string[] = []; + for (let nextIndex = index + 1; nextIndex < lines.length; nextIndex++) { + const nextLine = lines[nextIndex]; + const bulletMatch = nextLine.match(/^\s*[-*]\s+(.+)$/); + if (bulletMatch) { + entries.push(bulletMatch[1]); + continue; + } + if (!nextLine.trim()) { + continue; + } + break; + } + + return entries.length > 0 + ? parseExecutionTargetRepoList(entries.join(","), repoIdPattern) + : undefined; + } + + return undefined; +} + interface ParsedSegmentDagBody { metadata: PromptSegmentDagMetadata | null; error: DiscoveryError | null; @@ -692,11 +746,12 @@ export function parsePromptForOrchestrator( } } - // ── Extract execution target (repo ID) ────────────────────── + // ── Extract execution target (repo ID / repo IDs) ─────────── // Repo ID validation: lowercase alphanumeric + hyphens, starting with alnum const REPO_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; let promptRepoId: string | undefined; + let promptRepoIds: string[] | undefined; // Priority 1: Section-based "## Execution Target" with "Repo: " line // Capture everything from section header to the next heading or --- divider. @@ -715,20 +770,38 @@ export function parsePromptForOrchestrator( } } if (execTargetSectionBody !== null) { + const sectionRepoIds = parseExecutionTargetReposSection(execTargetSectionBody, REPO_ID_PATTERN); + if (sectionRepoIds) { + promptRepoIds = sectionRepoIds; + promptRepoId = sectionRepoIds[0]; + } + // Match "Repo: api" or "**Repo:** api" or "Workspace: api" with whitespace - const repoLineMatch = execTargetSectionBody.match( - /^\s*\*?\*?(?:Repo|Workspace):?\*?\*?\s+(\S+)/mi, - ); - if (repoLineMatch) { - const candidate = repoLineMatch[1].trim().toLowerCase(); - if (REPO_ID_PATTERN.test(candidate)) { - promptRepoId = candidate; + if (!promptRepoIds) { + const repoLineMatch = execTargetSectionBody.match( + /^\s*\*?\*?(?:Repo|Workspace):?\*?\*?\s+(\S+)/mi, + ); + if (repoLineMatch) { + const candidate = repoLineMatch[1].trim().toLowerCase(); + if (REPO_ID_PATTERN.test(candidate)) { + promptRepoId = candidate; + } } } } - // Priority 2 (fallback): Inline "**Repo:** " or "**Workspace:** " anywhere in content - if (!promptRepoId) { + // Priority 2 (fallback): Inline "**Repos:** , " or "**Repo:** " + if (!promptRepoId && !promptRepoIds) { + const inlineReposMatch = content.match(/^\*\*(?:Repos):\*\*\s+(.+)$/m); + if (inlineReposMatch) { + const candidateRepoIds = parseExecutionTargetRepoList(inlineReposMatch[1], REPO_ID_PATTERN); + if (candidateRepoIds) { + promptRepoIds = candidateRepoIds; + promptRepoId = candidateRepoIds[0]; + } + } + } + if (!promptRepoId && !promptRepoIds) { const inlineRepoMatch = content.match( /^\*\*(?:Repo|Workspace):\*\*\s+(\S+)/m, ); @@ -804,6 +877,7 @@ export function parsePromptForOrchestrator( areaName, status: "pending", ...(promptRepoId ? { promptRepoId } : {}), + ...(promptRepoIds ? { promptRepoIds } : {}), ...(explicitSegmentDag ? { explicitSegmentDag } : {}), ...(stepSegmentMap ? { stepSegmentMap } : {}), }, @@ -1413,6 +1487,35 @@ export function resolveDependencies( /** Repo ID validation: lowercase alphanumeric + hyphens, starting with alnum */ const ROUTING_REPO_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; +function extractRoutingRepoPrefix(fileScopeEntry: string): string | null { + const normalized = fileScopeEntry.replace(/\\/g, "/").trim(); + if (!normalized) return null; + const firstSegment = normalized.split("/")[0]?.trim().toLowerCase(); + if (!firstSegment || !ROUTING_REPO_ID_PATTERN.test(firstSegment)) { + return null; + } + return firstSegment; +} + +function collectRoutingRepoIdsFromFileScope( + fileScope: string[], + validRepoIds: ReadonlyMap, +): string[] { + const repoIds: string[] = []; + const seen = new Set(); + + for (const fileScopeEntry of fileScope) { + const repoId = extractRoutingRepoPrefix(fileScopeEntry); + if (!repoId || !validRepoIds.has(repoId) || seen.has(repoId)) { + continue; + } + seen.add(repoId); + repoIds.push(repoId); + } + + return repoIds; +} + /** * Resolve the target repo for each discovered task using the routing * precedence chain: @@ -1454,11 +1557,26 @@ export function resolveTaskRouting( } } + if (task.promptRepoIds && task.promptRepoIds.length > 0) { + const unknownPromptRepos = task.promptRepoIds.filter((repoId) => !validRepoIds.has(repoId)); + if (unknownPromptRepos.length > 0) { + errors.push({ + code: "TASK_REPO_UNKNOWN", + message: + `Task ${task.taskId} declares unknown repo ID(s) in ## Execution Target Repos: ${unknownPromptRepos.join(", ")}. ` + + `Known repos: ${[...validRepoIds.keys()].join(", ")}`, + taskId: task.taskId, + taskPath: task.promptPath, + }); + continue; + } + } + // ── Strict mode enforcement ────────────────────────────── // When strict routing is enabled, every task MUST declare an // explicit execution target in PROMPT.md. Area-level and // workspace-default fallbacks are NOT used for resolution. - if (strictMode && !task.promptRepoId) { + if (strictMode && !task.promptRepoId && !(task.promptRepoIds && task.promptRepoIds.length > 0)) { errors.push({ code: "TASK_ROUTING_STRICT", message: @@ -1469,6 +1587,8 @@ export function resolveTaskRouting( ` ## Execution Target\n` + `\n` + ` Repo: \n` + + ` or\n` + + ` Repos: , \n` + `\n` + `Available repos: ${[...validRepoIds.keys()].join(", ")}`, taskId: task.taskId, @@ -1477,9 +1597,36 @@ export function resolveTaskRouting( continue; } + const declaredExecutionRepoIds = task.promptRepoIds && task.promptRepoIds.length > 0 + ? [...task.promptRepoIds] + : task.promptRepoId + ? [task.promptRepoId] + : []; + const fileScopeRepoIds = collectRoutingRepoIdsFromFileScope(task.fileScope ?? [], validRepoIds); + if (declaredExecutionRepoIds.length > 0 && fileScopeRepoIds.length > 0) { + const unexpectedFileScopeRepos = fileScopeRepoIds.filter( + (repoId) => !declaredExecutionRepoIds.includes(repoId), + ); + if (unexpectedFileScopeRepos.length > 0) { + errors.push({ + code: "TASK_REPO_SCOPE_MISMATCH", + message: + `Task ${task.taskId} declares execution target repo ID(s) ${declaredExecutionRepoIds.join(", ")}, ` + + `but repo-prefixed file scope entries reference ${unexpectedFileScopeRepos.join(", ")}. ` + + `Align ## Execution Target with ## File Scope so every repo-prefixed path belongs to a declared target repo.`, + taskId: task.taskId, + taskPath: task.promptPath, + }); + continue; + } + } + // Precedence 1: prompt-declared repo - let resolvedId = task.promptRepoId; - let source = "prompt"; + let resolvedRepoIds = task.promptRepoIds && task.promptRepoIds.length > 0 + ? [...task.promptRepoIds] + : undefined; + let resolvedId = task.promptRepoIds?.[0] ?? task.promptRepoId; + let source = task.promptRepoIds && task.promptRepoIds.length > 0 ? "prompt:repos" : "prompt"; // Precedence 2: area-level repo if (!resolvedId) { @@ -1493,37 +1640,12 @@ export function resolveTaskRouting( } } - // Precedence 3: file scope inference — match file path prefixes against - // known workspace repo IDs. If file scope entries like "web-client/src/..." - // start with a repo name, route the task to that repo. - if (!resolvedId && task.fileScope && task.fileScope.length > 0) { - const repoIds = [...validRepoIds.keys()]; - const repoCounts = new Map(); - for (const filePath of task.fileScope) { - const normalized = filePath.replace(/\\/g, "/"); - for (const repoId of repoIds) { - if (normalized.startsWith(repoId + "/") || normalized === repoId) { - repoCounts.set(repoId, (repoCounts.get(repoId) || 0) + 1); - break; // first matching repo wins for this path - } - } - } - // Use the repo with the most file scope matches (majority vote) - if (repoCounts.size === 1) { - resolvedId = repoCounts.keys().next().value!; - source = "file-scope"; - } else if (repoCounts.size > 1) { - // Multiple repos in file scope — pick the one with most entries. - // (Future: #51 will handle multi-repo tasks properly) - let maxCount = 0; - for (const [repoId, count] of repoCounts) { - if (count > maxCount) { - maxCount = count; - resolvedId = repoId; - } - } - source = "file-scope"; - } + // Precedence 3: file scope inference — preserve first-appearance repo order + // from repo-prefixed file scope entries like "web-client/src/...". + if (!resolvedId && fileScopeRepoIds.length > 0) { + resolvedId = fileScopeRepoIds[0]; + resolvedRepoIds = fileScopeRepoIds; + source = "file-scope"; } // Precedence 4: workspace default repo @@ -1560,7 +1682,8 @@ export function resolveTaskRouting( continue; } - // Attach resolved repo to the task + // Attach resolved repo(s) to the task + task.resolvedRepoIds = resolvedRepoIds ?? [resolvedId]; task.resolvedRepoId = resolvedId; // ── Step-segment mapping: resolve placeholders and validate repo IDs (TP-173) ── @@ -1779,9 +1902,11 @@ export function formatDiscoveryResults(result: DiscoveryResult): string { ? ` → depends on: ${task.dependencies.join(", ")}` : ""; const repo = - task.resolvedRepoId - ? ` → repo: ${task.resolvedRepoId}` - : ""; + task.resolvedRepoIds && task.resolvedRepoIds.length > 1 + ? ` → repos: ${task.resolvedRepoIds.join(", ")}` + : task.resolvedRepoId + ? ` → repo: ${task.resolvedRepoId}` + : ""; lines.push( ` ${task.taskId} [${task.size}] ${task.taskName}${deps}${repo}`, ); diff --git a/extensions/taskplane/engine-worker.ts b/extensions/taskplane/engine-worker.ts index 868ddc4a..83217645 100644 --- a/extensions/taskplane/engine-worker.ts +++ b/extensions/taskplane/engine-worker.ts @@ -20,10 +20,12 @@ import type { EngineEvent, MonitorState, OrchBatchPhase, + OrchMergePanelState, OrchBatchRuntimeState, OrchestratorConfig, SupervisorAlert, TaskRunnerConfig, + OrchWorkspaceSyncStatus, WorkspaceConfig, WorkspaceRepoConfig, } from "./types.ts"; @@ -74,6 +76,8 @@ export interface SerializedBatchState { errors: string[]; /** Active lanes for the current wave (synced so /orch-sessions works). */ currentLanes: AllocatedLane[]; + workspaceSyncStatus?: OrchWorkspaceSyncStatus; + mergePanel?: OrchMergePanelState; } /** @@ -168,6 +172,8 @@ function serializeBatchState(state: OrchBatchRuntimeState): SerializedBatchState endedAt: state.endedAt, errors: [...state.errors], currentLanes: state.currentLanes, + workspaceSyncStatus: state.workspaceSyncStatus, + mergePanel: state.mergePanel, }; } @@ -197,6 +203,8 @@ export function applySerializedState( batchState.endedAt = serialized.endedAt; batchState.errors = [...serialized.errors]; batchState.currentLanes = serialized.currentLanes ?? []; + batchState.workspaceSyncStatus = serialized.workspaceSyncStatus; + batchState.mergePanel = serialized.mergePanel; } // ── Engine main (runs when launched as a forked child process) ─────── diff --git a/extensions/taskplane/engine.ts b/extensions/taskplane/engine.ts index e59cae08..f942720c 100644 --- a/extensions/taskplane/engine.ts +++ b/extensions/taskplane/engine.ts @@ -3,7 +3,7 @@ * @module orch/engine */ import { existsSync, readdirSync, readFileSync, renameSync, unlinkSync } from "fs"; -import { join, resolve } from "path"; +import { dirname, join, resolve } from "path"; import { formatDiscoveryResults, runDiscovery } from "./discovery.ts"; import { buildReviewerEnv, buildWorkerExcludeEnv, computeTransitiveDependents, execLog, executeLaneV2, executeWave, killV2LaneAgents, resolveCanonicalTaskPaths } from "./execution.ts"; @@ -13,17 +13,18 @@ import type { MonitorUpdateCallback } from "./execution.ts"; // from the diagnostic-reports pipeline (populated by assembleDiagnosticInput). import { getCurrentBranch, runGit } from "./git.ts"; import { killAllMergeAgentsV2, mergeWaveByRepo, MergeHealthMonitor } from "./merge.ts"; -import { applyMergeRetryLoop, computeCleanupGatePolicy, computeMergeFailurePolicy, extractFailedRepoId, formatRepoMergeSummary, ORCH_MESSAGES } from "./messages.ts"; +import { applyMergeRetryLoop, computeCleanupGatePolicy, computeMergeFailurePolicy, extractFailedRepoId, formatRepoAtomicFailureSummary, formatRepoMergeSummary, mergeRequiresRollbackSafeStop, ORCH_MESSAGES } from "./messages.ts"; import type { CleanupGateRepoFailure } from "./messages.ts"; import { assembleDiagnosticInput, emitDiagnosticReports } from "./diagnostic-reports.ts"; import { resolveOperatorId } from "./naming.ts"; import { applyPartialProgressToOutcomes, buildTier0EventBase, deleteBatchState, emitEngineEvent, emitTier0Event, loadBatchHistory, loadBatchState, persistRuntimeState, saveBatchHistory, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts"; import { readRegistrySnapshot, isTerminalStatus, isProcessAlive as registryIsProcessAlive } from "./process-registry.ts"; -import { buildBatchProgressSnapshot, buildEngineEventBase, buildSegmentId, buildSupervisorSegmentFrontierSnapshot, defaultResilienceState, FATAL_DISCOVERY_CODES, generateBatchId, TIER0_RETRYABLE_CLASSIFICATIONS, TIER0_RETRY_BUDGETS, tier0ScopeKey, tier0WaveScopeKey } from "./types.ts"; +import { buildBatchProgressSnapshot, buildEngineEventBase, buildSegmentId, buildSupervisorTaskFailureAlert, defaultResilienceState, FATAL_DISCOVERY_CODES, generateBatchId, TIER0_RETRYABLE_CLASSIFICATIONS, TIER0_RETRY_BUDGETS, tier0ScopeKey, tier0WaveScopeKey } from "./types.ts"; import type { AllocatedLane, AllocatedTask, BatchHistorySummary, BatchTaskSummary, BatchWaveSummary, DiscoveryResult, EngineEventCallback, EscalationContext, LaneExecutionResult, LaneTaskOutcome, MergeWaveResult, OrchBatchPhase, OrchBatchRuntimeState, OrchestratorConfig, ParsedTask, PersistedSegmentRecord, SegmentExpansionRequest, SupervisorAlert, SupervisorAlertCallback, TaskRunnerConfig, TaskSegmentPlan, TaskSegmentPlanMap, TaskSegmentNode, Tier0EscalationPattern, Tier0RecoveryPattern, TokenCounts, WaveExecutionResult, WorkspaceConfig } from "./types.ts"; import { buildDependencyGraph, computeWaveAssignments, resolveBaseBranch, resolveRepoRoot, validateGraph } from "./waves.ts"; import { deleteBranchBestEffort, forceCleanupWorktree, formatPreflightResults, listWorktrees, preserveFailedLaneProgress, preserveSkippedLaneProgress, removeAllWorktrees, removeWorktree, runPreflight, safeResetWorktree, sleepSync } from "./worktree.ts"; -import { runPreflightCleanup, formatPreflightCleanup, enforceTelemetrySizeCap, formatSizeCap, cleanupPriorBatchArtifacts, formatPriorBatchCleanup } from "./cleanup.ts"; +import { runPreflightCleanup, formatPreflightCleanup, enforceTelemetrySizeCap, formatSizeCap, cleanupPriorBatchArtifacts, formatPriorBatchCleanup, sweepStaleArtifacts, formatLogRotation, rotateSupervisorLogs, formatPreflightSweep } from "./cleanup.ts"; +import { buildWorkspaceSyncBadgeStatus, collectWorkspaceSyncSummary } from "./workspace.ts"; // ── Tier 0: Automatic Recovery Helpers (TP-039) ───────────────────── @@ -733,6 +734,19 @@ function ensureSegmentRecords(batchState: OrchBatchRuntimeState): PersistedSegme return batchState.segments; } +function collectOrderedSegmentRepoIds( + orderedSegments: Array<{ repoId: string }>, +): string[] { + const seen = new Set(); + const repoIds: string[] = []; + for (const segment of orderedSegments) { + if (!segment.repoId || seen.has(segment.repoId)) continue; + seen.add(segment.repoId); + repoIds.push(segment.repoId); + } + return repoIds; +} + /** * Persist pending segment records for an approved expansion and resync dependency * metadata for existing pending records touched by subsequent rewires. @@ -1157,6 +1171,8 @@ export function buildSegmentFrontierWaves( const orderedSegments = linearizeTaskSegmentPlan(plan); const dependsOnBySegmentId = buildSegmentDependencyMap(plan); task.segmentIds = orderedSegments.map((segment) => segment.segmentId); + task.participatingRepoIds = collectOrderedSegmentRepoIds(orderedSegments); + task.resolvedRepoIds = [...task.participatingRepoIds]; task.activeSegmentId = null; if (packetRepoId) { task.packetRepoId = packetRepoId; @@ -2021,6 +2037,49 @@ export async function executeOrchBatch( } }; + const openMergePanel = (waveLabel: string, laneCount: number): void => { + batchState.mergePanel = { + status: "running", + waveLabel: `Wave ${waveLabel}`, + events: [{ + level: "info", + message: `Merging ${laneCount} lane(s) into ${batchState.orchBranch || batchState.baseBranch || "target branch"}...`, + }], + }; + }; + + const pushMergePanelEvent = ( + level: "info" | "success" | "warning" | "error", + message: string, + ): void => { + if (!batchState.mergePanel) return; + const normalizedLines = message + .replace(/\r\n/g, "\n") + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + for (const normalizedLine of normalizedLines) { + batchState.mergePanel.events.push({ level, message: normalizedLine }); + } + if (batchState.mergePanel.events.length > 8) { + batchState.mergePanel.events.splice(0, batchState.mergePanel.events.length - 8); + } + if (level === "error") { + batchState.mergePanel.status = "error"; + } else if (level === "warning" && batchState.mergePanel.status === "running") { + batchState.mergePanel.status = "warning"; + } else if (level === "success" && batchState.mergePanel.status === "running") { + batchState.mergePanel.status = "success"; + } + }; + + const closeMergePanel = (waveLabel: string, emitSync = false): void => { + batchState.mergePanel = undefined; + if (emitSync) { + onNotify(`🔀 [Wave ${waveLabel}] Merge status sync`, "info"); + } + }; + // ── Phase 1: Planning ──────────────────────────────────────── batchState.phase = "planning"; batchState.batchId = generateBatchId(); @@ -2030,6 +2089,7 @@ export async function executeOrchBatch( // — e.g., /orch-pause issued between /orch return and engine start if (!batchState.pauseSignal?.paused) batchState.pauseSignal = { paused: false }; batchState.mergeResults = []; + batchState.mergePanel = undefined; batchState.mode = workspaceConfig ? "workspace" : "repo"; // Capture the current branch as the base for worktrees and merge target @@ -2073,9 +2133,20 @@ export async function executeOrchBatch( encounteredRepoRoots.set(repoRoot, undefined); // always include primary execLog("batch", batchState.batchId, "starting batch planning"); + batchState.workspaceSyncStatus = buildWorkspaceSyncBadgeStatus( + collectWorkspaceSyncSummary(repoRoot, workspaceConfig, { + failureMode: orchConfig.failure.submodule_failure_mode, + onSubmoduleDrift: orchConfig.failure.on_submodule_drift, + repoIdStrategy: orchConfig.orchestrator.submodule_repo_id_strategy, + }, args), + ); // Preflight - const preflight = runPreflight(orchConfig, repoRoot); + const preflight = runPreflight(orchConfig, repoRoot, { + workspaceRoot, + pointerConfigRoot: agentRoot ? dirname(agentRoot) : undefined, + workspaceConfig, + }); onNotify(formatPreflightResults(preflight), preflight.passed ? "info" : "error"); if (!preflight.passed) { batchState.phase = "failed"; @@ -2154,11 +2225,11 @@ export async function executeOrchBatch( batchState.errors.push("Discovery had fatal errors — cannot proceed"); onNotify("❌ Cannot execute due to discovery errors above.", "error"); const hasRoutingErrors = fatalErrors.some( - (e) => e.code === "TASK_REPO_UNRESOLVED" || e.code === "TASK_REPO_UNKNOWN", + (e) => e.code === "TASK_REPO_UNRESOLVED" || e.code === "TASK_REPO_UNKNOWN" || e.code === "TASK_REPO_SCOPE_MISMATCH", ); if (hasRoutingErrors) { onNotify( - "💡 Check PROMPT Repo: fields, area repo_id config, and routing.default_repo in workspace config.", + "💡 Check PROMPT Repo:/Repos: fields, repo-prefixed file scope entries, area repo_id config, and routing.default_repo in workspace config.", "info", ); } @@ -2168,7 +2239,7 @@ export async function executeOrchBatch( if (hasStrictErrors) { onNotify( "💡 Strict routing is enabled (routing.strict: true). Every task must declare an explicit execution target.\n" + - " Add a `## Execution Target` section with `Repo: ` to each task's PROMPT.md.\n" + + " Add a `## Execution Target` section with `Repo: ` or `Repos: , ` to each task's PROMPT.md.\n" + " To disable strict routing, set `routing.strict: false` in workspace config.", "info", ); @@ -2365,6 +2436,8 @@ export async function executeOrchBatch( } task.segmentIds = segmentState.orderedSegments.map((segment) => segment.segmentId); + task.participatingRepoIds = collectOrderedSegmentRepoIds(segmentState.orderedSegments); + task.resolvedRepoIds = [...task.participatingRepoIds]; const activeSegment = segmentState.orderedSegments[segmentState.nextSegmentIndex] ?? null; if (!activeSegment) { segmentState.terminalStatus = "succeeded"; @@ -2759,6 +2832,8 @@ export async function executeOrchBatch( segmentState, ); task.segmentIds = segmentState.orderedSegments.map((segment) => segment.segmentId); + task.participatingRepoIds = collectOrderedSegmentRepoIds(segmentState.orderedSegments); + task.resolvedRepoIds = [...task.participatingRepoIds]; const afterSegmentIds = [...task.segmentIds]; const persistedInsertedSegments = upsertPendingExpandedSegmentRecords( batchState, @@ -2998,54 +3073,23 @@ export async function executeOrchBatch( const allocatedTask = laneForTask?.tasks.find(t => t.taskId === taskId)?.task; const exitReason = outcome?.exitReason || "unknown"; const hasPartialProgress = (outcome?.partialProgressCommits ?? 0) > 0; - const segmentFrontier = buildSupervisorSegmentFrontierSnapshot( + emitAlert(buildSupervisorTaskFailureAlert({ taskId, - allocatedTask?.segmentIds, - allocatedTask?.activeSegmentId, - batchState.segments, - outcome?.segmentId, - ); - const segmentId = outcome?.segmentId - ?? allocatedTask?.activeSegmentId - ?? segmentFrontier?.activeSegmentId - ?? undefined; - const repoId = segmentId - ? (segmentFrontier?.segments.find((segment) => segment.segmentId === segmentId)?.repoId ?? laneForTask?.repoId) - : laneForTask?.repoId; - const segmentSummary = segmentId - ? ` Segment: ${segmentId}${repoId ? ` (repo: ${repoId})` : ""}\n` - : ""; - const frontierSummary = segmentFrontier - ? ` Segment frontier: ${segmentFrontier.terminalSegments}/${segmentFrontier.totalSegments} terminal\n` - : ""; - emitAlert({ - category: "task-failure", - summary: - `⚠️ Task failure: ${taskId}\n` + - ` Exit reason: ${exitReason}\n` + - segmentSummary + - frontierSummary + - ` Lane: ${laneForTask?.laneId ?? "unknown"} (lane ${laneForTask?.laneNumber ?? "?"})\n` + - ` Partial progress preserved: ${hasPartialProgress ? "yes" : "no"}\n` + - ` Batch: wave ${resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave}/${taskLevelWaveCount}, ` + - `${batchState.succeededTasks} succeeded, ${batchState.failedTasks} failed\n\n` + - `Available actions:\n` + - ` - orch_status() to inspect current state\n` + - ` - orch_resume(force=true) to retry\n` + - ` - Read STATUS.md and lane logs for diagnosis`, - context: { - taskId, - segmentId, - repoId, - segmentFrontier, - laneId: laneForTask?.laneId, - laneNumber: laneForTask?.laneNumber, - waveIndex: waveIdx, - exitReason, - partialProgress: hasPartialProgress, - batchProgress: buildBatchProgressSnapshot(batchState), - }, - }); + failurePolicy: waveResult.policyApplied, + exitReason, + partialProgress: hasPartialProgress, + laneId: laneForTask?.laneId, + laneNumber: laneForTask?.laneNumber, + laneRepoId: laneForTask?.repoId, + taskSegmentIds: allocatedTask?.segmentIds, + taskActiveSegmentId: allocatedTask?.activeSegmentId, + persistedSegments: batchState.segments, + outcomeSegmentId: outcome?.segmentId, + blockedTaskIds: waveResult.policyApplied === "skip-dependents" ? [...waveResult.blockedTaskIds] : undefined, + batchProgress: buildBatchProgressSnapshot(batchState), + displayWave: resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, + totalDisplayWaves: taskLevelWaveCount, + })); } // ── TS-009: Persist state after wave execution ── @@ -3164,10 +3208,12 @@ export async function executeOrchBatch( }).length; if (mergeableLaneCount > 0) { + const { displayWave: mergeDisplayWave } = resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount); batchState.phase = "merging"; + openMergePanel(String(mergeDisplayWave), mergeableLaneCount); // ── TS-009: Persist state on executing→merging transition ── persistRuntimeState("merge-start", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); - onNotify(ORCH_MESSAGES.orchMergeStart(resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, mergeableLaneCount), "info"); + onNotify(ORCH_MESSAGES.orchMergeStart(mergeDisplayWave, mergeableLaneCount), "info"); // TP-040: Emit merge_start event emitEvent(stateRoot, { ...buildEngineEventBase("merge_start", batchState.batchId, waveIdx, batchState.phase), @@ -3223,12 +3269,16 @@ export async function executeOrchBatch( // TP-032 R006-3: Check lr.error first — verification_new_failure lanes // have error set even though lr.result.status may be SUCCESS/CONFLICT_RESOLVED. if (lr.error) { + pushMergePanelEvent("error", ORCH_MESSAGES.orchMergeLaneFailed(lr.laneNumber, lr.error)); onNotify(ORCH_MESSAGES.orchMergeLaneFailed(lr.laneNumber, lr.error), "error"); } else if (lr.result?.status === "SUCCESS") { + pushMergePanelEvent("success", ORCH_MESSAGES.orchMergeLaneSuccess(lr.laneNumber, lr.result.merge_commit, durationSec)); onNotify(ORCH_MESSAGES.orchMergeLaneSuccess(lr.laneNumber, lr.result.merge_commit, durationSec), "info"); } else if (lr.result?.status === "CONFLICT_RESOLVED") { + pushMergePanelEvent("success", ORCH_MESSAGES.orchMergeLaneConflictResolved(lr.laneNumber, lr.result.conflicts.length, durationSec)); onNotify(ORCH_MESSAGES.orchMergeLaneConflictResolved(lr.laneNumber, lr.result.conflicts.length, durationSec), "info"); } else if (lr.result?.status === "CONFLICT_UNRESOLVED" || lr.result?.status === "BUILD_FAILURE") { + pushMergePanelEvent("error", ORCH_MESSAGES.orchMergeLaneFailed(lr.laneNumber, lr.result.status)); onNotify(ORCH_MESSAGES.orchMergeLaneFailed(lr.laneNumber, lr.result.status), "error"); } } @@ -3262,7 +3312,7 @@ export async function executeOrchBatch( const mergeTotalSec = Math.round(mergeResult.totalDurationMs / 1000); if (mergeResult.status === "succeeded") { - const { displayWave: mergeDisplayWave } = resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount); + pushMergePanelEvent("success", ORCH_MESSAGES.orchMergeComplete(mergeDisplayWave, mergedCount, mergeTotalSec)); onNotify(ORCH_MESSAGES.orchMergeComplete(mergeDisplayWave, mergedCount, mergeTotalSec), "info"); // TP-040: Emit merge_success event @@ -3273,11 +3323,21 @@ export async function executeOrchBatch( totalWaves: taskLevelWaveCount, }, onEngineEvent); } else { + pushMergePanelEvent( + "error", + ORCH_MESSAGES.orchMergeFailed(mergeDisplayWave, mergeResult.failedLane ?? 0, mergeResult.failureReason || "unknown"), + ); onNotify( - ORCH_MESSAGES.orchMergeFailed(resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, mergeResult.failedLane ?? 0, mergeResult.failureReason || "unknown"), + ORCH_MESSAGES.orchMergeFailed(mergeDisplayWave, mergeResult.failedLane ?? 0, mergeResult.failureReason || "unknown"), "error", ); + const atomicRepoSummary = formatRepoAtomicFailureSummary(mergeResult); + if (atomicRepoSummary) { + pushMergePanelEvent("warning", atomicRepoSummary); + onNotify(atomicRepoSummary, "warning"); + } + // TP-040: Emit merge_failed event emitEvent(stateRoot, { ...buildEngineEventBase("merge_failed", batchState.batchId, waveIdx, batchState.phase), @@ -3289,6 +3349,7 @@ export async function executeOrchBatch( if (mergeResult.status === "partial") { const repoSummary = formatRepoMergeSummary(mergeResult); if (repoSummary) { + pushMergePanelEvent("warning", repoSummary); onNotify(repoSummary, "warning"); } } @@ -3296,8 +3357,10 @@ export async function executeOrchBatch( // Restore phase to executing (may be overridden below by failure handling) batchState.phase = "executing"; + closeMergePanel(String(mergeDisplayWave)); // ── TS-009: Persist state after merge (merging→executing) ── persistRuntimeState("merge-complete", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); + closeMergePanel(String(mergeDisplayWave), true); } else if (mixedOutcomeLanes.length > 0) { const mixedIds = mixedOutcomeLanes.map(l => `lane-${l.laneNumber}`).join(", "); mergeResult = { @@ -3338,7 +3401,7 @@ export async function executeOrchBatch( // When a verification rollback failed, force paused regardless of // on_merge_failure policy. The merge worktree and temp branch are // preserved for manual recovery using commands in the transaction record. - if (mergeResult?.rollbackFailed) { + if (mergeResult?.rollbackFailed || (mergeResult && mergeRequiresRollbackSafeStop(mergeResult))) { // TP-033 R004-2: Include persistence error warning when transaction // record files may be missing, so operator knows to inspect manually const hasPersistErrors = mergeResult.persistenceErrors && mergeResult.persistenceErrors.length > 0; @@ -3353,6 +3416,7 @@ export async function executeOrchBatch( }); batchState.phase = "paused"; + closeMergePanel(String(waveIdx + 1)); batchState.errors.push( `Safe-stop at wave ${waveIdx + 1}: verification rollback failed. ` + `Merge worktree and temp branch preserved for recovery. ` + @@ -3414,6 +3478,7 @@ export async function executeOrchBatch( batchState.resilience.retryCountByScope, { performMerge: async () => { + openMergePanel(String(resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave), mergeableLaneCount); batchState.phase = "merging"; return await mergeWaveByRepo( waveResult.allocatedLanes, @@ -3458,7 +3523,9 @@ export async function executeOrchBatch( if (retryOutcome.kind === "retry_succeeded") { mergeResult = retryOutcome.mergeResult; batchState.phase = "executing"; + closeMergePanel(String(waveIdx + 1)); persistRuntimeState("merge-retry-succeeded", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); + closeMergePanel(String(waveIdx + 1), true); // Emit merge retry success event emitTier0Event(stateRoot, { @@ -3474,6 +3541,7 @@ export async function executeOrchBatch( } else if (retryOutcome.kind === "safe_stop") { mergeResult = retryOutcome.mergeResult; batchState.phase = "paused"; + closeMergePanel(String(waveIdx + 1)); batchState.errors.push(retryOutcome.errorMessage); persistRuntimeState("merge-rollback-safe-stop", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); onNotify(retryOutcome.notifyMessage, "error"); @@ -3550,6 +3618,7 @@ export async function executeOrchBatch( ); batchState.phase = "paused"; + closeMergePanel(String(waveIdx + 1)); batchState.errors.push(exhaustionMsg); persistRuntimeState("merge-retry-exhausted", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); onNotify(retryOutcome.notifyMessage, "error"); @@ -3589,6 +3658,7 @@ export async function executeOrchBatch( execLog("batch", batchState.batchId, `merge failure — applying ${policyResult.policy} policy${classNote}`, policyResult.logDetails); batchState.phase = policyResult.targetPhase; + closeMergePanel(String(waveIdx + 1)); batchState.errors.push(policyResult.errorMessage + classNote); persistRuntimeState(policyResult.persistTrigger, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot); onNotify(policyResult.notifyMessage + classNote, policyResult.notifyLevel); diff --git a/extensions/taskplane/execution.ts b/extensions/taskplane/execution.ts index ab2cb2cb..f8e82929 100644 --- a/extensions/taskplane/execution.ts +++ b/extensions/taskplane/execution.ts @@ -8,12 +8,12 @@ import { join, dirname, basename, resolve, relative, delimiter as pathDelimiter import { userInfo } from "os"; import { DONE_GRACE_MS, EXECUTION_POLL_INTERVAL_MS, ExecutionError, SESSION_SPAWN_RETRY_MAX } from "./types.ts"; -import type { AllocatedLane, AllocatedTask, DependencyGraph, LaneExecutionResult, LaneMonitorSnapshot, LaneTaskOutcome, LaneTaskStatus, MonitorState, MtimeTracker, OrchestratorConfig, ParsedTask, TaskMonitorSnapshot, WaveExecutionResult, WorkspaceConfig, ExecutionUnit, PacketPaths, RuntimeAgentId, RuntimeAgentRole, SupervisorAlertCallback } from "./types.ts"; -import { resolvePacketPaths, buildRuntimeAgentId } from "./types.ts"; -import { readRegistrySnapshot, readLaneSnapshot, isTerminalStatus, isProcessAlive, detectOrphans, markOrphansCrashed, buildRegistrySnapshot, writeRegistrySnapshot } from "./process-registry.ts"; +import type { AllocatedLane, AllocatedTask, DependencyGraph, LaneExecutionResult, LaneMonitorSnapshot, LaneTaskOutcome, LaneTaskStatus, MonitorState, MtimeTracker, OrchestratorConfig, ParsedTask, TaskMonitorSnapshot, WaveExecutionResult, WorkspaceConfig, ExecutionUnit, PacketPaths, RuntimeAgentId, RuntimeAgentRole, SupervisorAlertCallback, RuntimeLaneSnapshot, RuntimeLaneSubmoduleDiagnostics, RuntimeUnsafeSubmoduleFinding, RuntimeSubmoduleSnapshot } from "./types.ts"; +import { resolvePacketPaths, buildRuntimeAgentId, runtimeLaneSnapshotPath } from "./types.ts"; +import { readRegistrySnapshot, readLaneSnapshot, isTerminalStatus, isProcessAlive, detectOrphans, markOrphansCrashed, buildRegistrySnapshot, writeRegistrySnapshot, writeLaneSnapshot } from "./process-registry.ts"; import { allocateLanes } from "./waves.ts"; import { resolveOperatorId } from "./naming.ts"; -import { runGit, runGitWithEnv } from "./git.ts"; +import { captureSubmoduleStatusSnapshot, detectUnsafeSubmoduleStates, runGit, runGitWithEnv } from "./git.ts"; import { resolveTaskplanePackageFile, resolveTaskplaneAgentTemplate } from "./path-resolver.ts"; import { resolvePointer, loadWorkspaceConfig } from "./workspace.ts"; @@ -208,6 +208,73 @@ function laneSessionIdOf(lane: Pick): string { return lane.laneSessionId; } +function appendRepoIdCandidate( + orderedRepoIds: string[], + seenRepoIds: Set, + repoId: string | undefined, +): void { + if (!repoId || seenRepoIds.has(repoId)) return; + seenRepoIds.add(repoId); + orderedRepoIds.push(repoId); +} + +export function buildExecutionRepoPaths( + task: ParsedTask, + executionRepoId: string, + packetHomeRepoId: string, + worktreePath: string, + repoRoot: string, + workspaceConfig?: WorkspaceConfig | null, + repoWorktrees?: Record, +): Record { + const orderedRepoIds: string[] = []; + const seenRepoIds = new Set(); + + for (const repoId of task.participatingRepoIds ?? []) { + appendRepoIdCandidate(orderedRepoIds, seenRepoIds, repoId); + } + for (const repoId of task.promptRepoIds ?? []) { + appendRepoIdCandidate(orderedRepoIds, seenRepoIds, repoId); + } + for (const repoId of task.resolvedRepoIds ?? []) { + appendRepoIdCandidate(orderedRepoIds, seenRepoIds, repoId); + } + for (const repoId of task.explicitSegmentDag?.repoIds ?? []) { + appendRepoIdCandidate(orderedRepoIds, seenRepoIds, repoId); + } + appendRepoIdCandidate(orderedRepoIds, seenRepoIds, task.promptRepoId); + appendRepoIdCandidate(orderedRepoIds, seenRepoIds, task.resolvedRepoId); + appendRepoIdCandidate(orderedRepoIds, seenRepoIds, packetHomeRepoId); + appendRepoIdCandidate(orderedRepoIds, seenRepoIds, executionRepoId); + + const repoPaths: Record = {}; + for (const repoId of orderedRepoIds) { + if (repoId === executionRepoId) { + repoPaths[repoId] = repoWorktrees?.[repoId]?.path ?? worktreePath; + continue; + } + const laneRepoWorktreePath = repoWorktrees?.[repoId]?.path; + if (laneRepoWorktreePath) { + repoPaths[repoId] = laneRepoWorktreePath; + continue; + } + const repoPath = workspaceConfig?.repos.get(repoId)?.path; + if (repoPath) { + repoPaths[repoId] = repoPath; + continue; + } + if (!workspaceConfig && repoId === packetHomeRepoId) { + repoPaths[repoId] = repoRoot; + } + } + + if (!repoPaths[executionRepoId]) { + repoPaths[executionRepoId] = repoWorktrees?.[executionRepoId]?.path ?? worktreePath; + } + + return repoPaths; +} + /** * Resolve the lane session log path for a task execution. * @@ -331,6 +398,14 @@ export interface ResolvedTaskPaths { statusPath: string; } +function normalizePortablePath(pathValue: string): string { + const normalized = pathValue.replace(/\\/g, "/"); + if (/^[A-Za-z]:\//.test(normalized)) { + return normalized; + } + return resolve(normalized).replace(/\\/g, "/"); +} + /** * Canonical task-folder path resolver. * @@ -362,8 +437,9 @@ export function resolveCanonicalTaskPaths( repoRoot: string, isWorkspaceMode?: boolean, ): ResolvedTaskPaths { - const repoRootNorm = resolve(repoRoot).replace(/\\/g, "/"); - const folderNorm = resolve(taskFolder).replace(/\\/g, "/"); + const repoRootNorm = normalizePortablePath(repoRoot); + const folderNorm = normalizePortablePath(taskFolder); + const worktreeRootNorm = normalizePortablePath(worktreePath); let resolvedFolder: string; @@ -373,21 +449,21 @@ export function resolveCanonicalTaskPaths( // the worktree, so the engine must look there too. if (folderNorm.startsWith(repoRootNorm + "/")) { const relPath = folderNorm.slice(repoRootNorm.length + 1); - resolvedFolder = join(worktreePath, relPath); + resolvedFolder = join(worktreeRootNorm, relPath); } else { // Cross-repo: task files were copied into the worktree under // .taskplane-tasks// by buildLaneEnvVars - const taskDirName = basename(resolve(taskFolder)); - resolvedFolder = join(worktreePath, ".taskplane-tasks", taskDirName); + const taskDirName = basename(folderNorm); + resolvedFolder = join(worktreeRootNorm, ".taskplane-tasks", taskDirName); } } else if (folderNorm.startsWith(repoRootNorm + "/")) { // Repo mode: task folder is inside the repo root. // Translate to equivalent path in the worktree. const relativePath = folderNorm.slice(repoRootNorm.length + 1); - resolvedFolder = join(worktreePath, relativePath); + resolvedFolder = join(worktreeRootNorm, relativePath); } else { // Fallback: use absolute path directly. - resolvedFolder = resolve(taskFolder); + resolvedFolder = folderNorm; } // Check primary location @@ -403,7 +479,7 @@ export function resolveCanonicalTaskPaths( // Archive fallback: worker may have archived the task folder during the // "Documentation & Delivery" step, moving it under `.../archive/TASK-ID/`. - const resolvedNorm = resolve(resolvedFolder).replace(/\\/g, "/"); + const resolvedNorm = normalizePortablePath(resolvedFolder); const parts = resolvedNorm.split("/"); const taskDirName = parts[parts.length - 1]; const parentDir = parts.slice(0, -1).join("/"); @@ -488,16 +564,84 @@ function commitTaskArtifacts( lane: AllocatedLane, task: AllocatedTask, laneId: string, + v2Context?: { stateRoot: string; batchId: string; laneNumber: number; repoId: string }, ): void { const worktreePath = lane.worktreePath; // Check if there are any uncommitted changes in the worktree const statusResult = runGit(["status", "--porcelain"], worktreePath); if (!statusResult.ok || !statusResult.stdout.trim()) { + // Worker may have already committed everything. Check for untracked .DONE file. + // If .DONE exists but is untracked, we must stage+commit it so it survives + // worktree reset and appears in the main repo after merge. + const donePath = resolveTaskDonePath(task.task.taskFolder, worktreePath, lane.worktreePath); + if (existsSync(donePath)) { + const untrackedResult = runGit(["status", "--porcelain", "--untracked-files=all"], worktreePath); + const hasUntracked = untrackedResult.ok && untrackedResult.stdout.includes("??"); + if (hasUntracked) { + // Stage .DONE specifically + const addResult = runGit(["add", donePath], worktreePath); + if (!addResult.ok) { + execLog(laneId, task.taskId, `post-task stage .DONE failed (non-fatal): ${addResult.stderr.slice(0, 200)}`); + } + // Commit with task ID for traceability + const commitResult = runGit( + ["commit", "-m", `checkpoint: ${task.taskId} task artifacts (.DONE, STATUS.md)`], + worktreePath, + ); + if (!commitResult.ok && !commitResult.stderr.includes("nothing to commit")) { + execLog(laneId, task.taskId, `post-task commit .DONE failed (non-fatal): ${commitResult.stderr.slice(0, 200)}`); + } + if (commitResult.ok) { + execLog(laneId, task.taskId, `committed untracked .DONE to lane branch`, { + commit: commitResult.stdout.trim().split("\n")[0], + }); + } + return; + } + } // Nothing to commit (worker already committed everything, or git error) return; } + const unsafeSubmodules = detectUnsafeSubmoduleStates(worktreePath); + if (unsafeSubmodules.length > 0) { + const statusSnapshot = captureSubmoduleStatusSnapshot(worktreePath); + const summary = unsafeSubmodules + .slice(0, 3) + .map((finding) => + finding.kind === "dirty-worktree" + ? `${finding.path} has uncommitted submodule changes` + : `${finding.path} points to local commit ${finding.headCommit?.slice(0, 8)} not reachable on ${finding.remoteName ?? "any remote"}`, + ) + .join("; "); + const remainder = unsafeSubmodules.length > 3 + ? ` (+${unsafeSubmodules.length - 3} more)` + : ""; + const findingDetails = buildUnsafeSubmoduleFindingDetails(unsafeSubmodules, statusSnapshot); + recordUnsafeSubmoduleDiagnostics( + v2Context, + task.taskId, + toRuntimeSubmoduleSnapshot(task.taskId, "post-task", statusSnapshot), + summary, + remainder, + findingDetails, + ); + for (const detail of findingDetails) { + const preview = detail.statusLines.length > 0 + ? detail.statusLines.join(" | ") + : detail.error + ? `[status unavailable: ${detail.error}]` + : "[no modified files reported by git status --porcelain]"; + execLog(laneId, task.taskId, `unsafe submodule detail: ${detail.path}: ${detail.summary} :: ${preview}`); + } + const message = + `Unsafe submodule state after task success: ${summary}${remainder}. ` + + `Taskplane refused to checkpoint a superproject gitlink that could lose or orphan submodule work.`; + execLog(laneId, task.taskId, message); + throw new Error(message); + } + // Stage all changes in the worktree const addResult = runGit(["add", "-A"], worktreePath); if (!addResult.ok) { @@ -523,6 +667,94 @@ function commitTaskArtifacts( }); } +function toRuntimeSubmoduleSnapshot( + taskId: string, + phase: "pre-task" | "post-task", + snapshot: ReturnType, +): RuntimeSubmoduleSnapshot { + return { + taskId, + phase, + capturedAt: snapshot.capturedAt, + worktreePath: snapshot.worktreePath, + totalSubmodules: snapshot.totalSubmodules, + dirtySubmodules: snapshot.dirtySubmodules, + entries: snapshot.entries, + }; +} + +function buildUnsafeSubmoduleFindingDetails( + unsafeSubmodules: ReturnType, + snapshot: ReturnType, +): RuntimeUnsafeSubmoduleFinding[] { + const previewByPath = new Map(snapshot.entries.map((entry) => [entry.path, entry])); + return unsafeSubmodules.map((finding) => { + const preview = previewByPath.get(finding.path); + const summary = finding.kind === "dirty-worktree" + ? `${finding.path} has uncommitted submodule changes` + : `${finding.path} points to local commit ${finding.headCommit?.slice(0, 8)} not reachable on ${finding.remoteName ?? "any remote"}`; + return { + path: finding.path, + kind: finding.kind, + summary, + statusLines: preview?.statusLines ?? [], + lineCount: preview?.lineCount ?? 0, + truncated: preview?.truncated ?? false, + ...(preview?.error ? { error: preview.error } : {}), + ...(finding.headCommit ? { headCommit: finding.headCommit } : {}), + ...(finding.indexCommit ? { indexCommit: finding.indexCommit } : {}), + ...(finding.remoteName ? { remoteName: finding.remoteName } : {}), + }; + }); +} + +function recordUnsafeSubmoduleDiagnostics( + v2Context: { stateRoot: string; batchId: string; laneNumber: number; repoId: string } | undefined, + taskId: string, + postTaskSnapshot: RuntimeSubmoduleSnapshot, + summary: string, + remainder: string, + findings: RuntimeUnsafeSubmoduleFinding[], +): void { + if (!v2Context) return; + const snapshotPath = runtimeLaneSnapshotPath(v2Context.stateRoot, v2Context.batchId, v2Context.laneNumber); + + try { + const current = existsSync(snapshotPath) + ? JSON.parse(readFileSync(snapshotPath, "utf-8")) as RuntimeLaneSnapshot + : { + batchId: v2Context.batchId, + laneNumber: v2Context.laneNumber, + laneId: `lane-${v2Context.laneNumber}`, + repoId: v2Context.repoId, + taskId, + segmentId: null, + status: "failed", + worker: null, + reviewer: null, + progress: null, + updatedAt: Date.now(), + } satisfies RuntimeLaneSnapshot; + const nextDiagnostics: RuntimeLaneSubmoduleDiagnostics = { + ...(current.submoduleDiagnostics ?? {}), + postTask: postTaskSnapshot, + unsafeCheckpoint: { + taskId, + capturedAt: Date.now(), + summary: `${summary}${remainder}`, + findings, + }, + }; + writeLaneSnapshot(v2Context.stateRoot, v2Context.batchId, v2Context.laneNumber, { + ...(current as Record), + submoduleDiagnostics: nextDiagnostics, + updatedAt: Date.now(), + }); + } catch { + // Best effort only: lane snapshots are telemetry, not execution critical. + } +} + @@ -547,46 +779,73 @@ export interface ParsedWorktreeStatus { reviewCounter: number; /** Iteration number from STATUS.md */ iteration: number; + /** Raw Current Step header value from STATUS.md */ + currentStepLabel: string | null; + /** Normalized current step name derived from the header when possible */ + currentStepName: string | null; + /** Normalized current step number derived from the header when possible */ + currentStepNumber: number | null; /** File modification time (epoch ms) */ mtime: number; } -/** - * Parse STATUS.md from a task folder inside a worktree. - * - * Reads the STATUS.md file, parses step statuses and checkbox counts - * using the same regex patterns as task-runner's parseStatusMd. - * - * @param taskFolder - Absolute task folder path (from main repo) - * @param worktreePath - Absolute path to the lane worktree - * @param repoRoot - Absolute path to the main repository root - * @returns Parsed status or null with reason if unreadable - */ -export function parseWorktreeStatusMd( - taskFolder: string, - worktreePath: string, - repoRoot: string, - isWorkspaceMode?: boolean, -): { parsed: ParsedWorktreeStatus | null; error: string | null } { - // Use canonical resolver for consistent path translation - const resolved = resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot, isWorkspaceMode); - const statusPath = resolved.statusPath; +function normalizeCurrentStepHeader( + rawLabel: string | null, + steps: ParsedWorktreeStatus["steps"], +): Pick { + const label = rawLabel?.trim() || null; + if (!label) { + return { + currentStepLabel: null, + currentStepName: null, + currentStepNumber: null, + }; + } - if (!existsSync(statusPath)) { - return { parsed: null, error: `STATUS.md not found at ${statusPath}` }; + const numbered = label.match(/^Step\s+(\d+)(?::\s*(.+))?$/i); + if (numbered) { + const currentStepNumber = parseInt(numbered[1], 10); + const namedMatch = numbered[2]?.trim() || null; + const matchingStep = steps.find((step) => step.number === currentStepNumber) || null; + return { + currentStepLabel: label, + currentStepName: namedMatch || matchingStep?.name || null, + currentStepNumber, + }; } - let content: string; - let mtime: number; - try { - content = readFileSync(statusPath, "utf-8"); - mtime = statSync(statusPath).mtimeMs; - } catch (err: unknown) { - return { parsed: null, error: `Cannot read STATUS.md: ${err instanceof Error ? err.message : String(err)}` }; + const normalized = label.toLowerCase(); + if ( + normalized.includes("all steps complete") + || normalized === "complete" + || normalized === "done" + ) { + const last = steps[steps.length - 1] || null; + return { + currentStepLabel: label, + currentStepName: last?.name || null, + currentStepNumber: last?.number ?? null, + }; + } + + if (normalized === "not started" || normalized === "none") { + return { + currentStepLabel: label, + currentStepName: null, + currentStepNumber: null, + }; } - // Parse using same regex patterns as task-runner's parseStatusMd - const text = content.replace(/\r\n/g, "\n"); + const matchingByName = steps.find((step) => step.name === label) || null; + return { + currentStepLabel: label, + currentStepName: matchingByName?.name || label, + currentStepNumber: matchingByName?.number ?? null, + }; +} + +function parseStatusMdText(text: string, mtime: number): ParsedWorktreeStatus { + const normalizedText = text.replace(/\r\n/g, "\n"); const steps: ParsedWorktreeStatus["steps"] = []; let currentStep: { number: number; @@ -596,14 +855,23 @@ export function parseWorktreeStatusMd( } | null = null; let reviewCounter = 0; let iteration = 0; + let currentStepLabel: string | null = null; + let inCodeFence = false; - for (const line of text.split("\n")) { + for (const line of normalizedText.split("\n")) { + if (/^```/.test(line.trim())) { + inCodeFence = !inCodeFence; + continue; + } const rcMatch = line.match(/\*\*Review Counter:\*\*\s*(\d+)/); - if (rcMatch) reviewCounter = parseInt(rcMatch[1]); + if (rcMatch) reviewCounter = parseInt(rcMatch[1], 10); const itMatch = line.match(/\*\*Iteration:\*\*\s*(\d+)/); - if (itMatch) iteration = parseInt(itMatch[1]); + if (itMatch) iteration = parseInt(itMatch[1], 10); + const currentStepMatch = line.match(/\*\*Current Step:\*\*\s*(.+)/); + if (currentStepMatch) currentStepLabel = currentStepMatch[1].trim(); + if (inCodeFence) continue; - const stepMatch = line.match(/^###\s+Step\s+(\d+):\s*(.+)/); + const stepMatch = line.match(/^#{2,6}\s+Step\s+(\d+)(?::\s*|\s+)(.+)$/); if (stepMatch) { if (currentStep) { const totalChecked = currentStep.checkboxes.filter(c => c).length; @@ -616,20 +884,32 @@ export function parseWorktreeStatusMd( }); } currentStep = { - number: parseInt(stepMatch[1]), + number: parseInt(stepMatch[1], 10), name: stepMatch[2].trim(), status: "not-started", checkboxes: [], }; continue; } + if (currentStep && /^#{1,6}\s+/.test(line) && !/^####\s+Segment:\s*/.test(line)) { + const totalChecked = currentStep.checkboxes.filter(c => c).length; + steps.push({ + number: currentStep.number, + name: currentStep.name, + status: currentStep.status, + totalChecked, + totalItems: currentStep.checkboxes.length, + }); + currentStep = null; + continue; + } if (currentStep) { const ss = line.match(/\*\*Status:\*\*\s*(.*)/); if (ss) { - const s = ss[1]; - if (s.includes("✅") || s.toLowerCase().includes("complete")) { + const statusText = ss[1]; + if (statusText.includes("✅") || statusText.toLowerCase().includes("complete")) { currentStep.status = "complete"; - } else if (s.includes("🟨") || s.includes("🟡") || s.toLowerCase().includes("progress")) { + } else if (statusText.includes("🟨") || statusText.includes("🟡") || statusText.toLowerCase().includes("progress")) { currentStep.status = "in-progress"; } } @@ -651,7 +931,50 @@ export function parseWorktreeStatusMd( } return { - parsed: { steps, reviewCounter, iteration, mtime }, + steps, + reviewCounter, + iteration, + ...normalizeCurrentStepHeader(currentStepLabel, steps), + mtime, + }; +} + +/** + * Parse STATUS.md from a task folder inside a worktree. + * + * Reads the STATUS.md file, parses step statuses and checkbox counts + * using the same regex patterns as task-runner's parseStatusMd. + * + * @param taskFolder - Absolute task folder path (from main repo) + * @param worktreePath - Absolute path to the lane worktree + * @param repoRoot - Absolute path to the main repository root + * @returns Parsed status or null with reason if unreadable + */ +export function parseWorktreeStatusMd( + taskFolder: string, + worktreePath: string, + repoRoot: string, + isWorkspaceMode?: boolean, +): { parsed: ParsedWorktreeStatus | null; error: string | null } { + // Use canonical resolver for consistent path translation + const resolved = resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot, isWorkspaceMode); + const statusPath = resolved.statusPath; + + if (!existsSync(statusPath)) { + return { parsed: null, error: `STATUS.md not found at ${statusPath}` }; + } + + let content: string; + let mtime: number; + try { + content = readFileSync(statusPath, "utf-8"); + mtime = statSync(statusPath).mtimeMs; + } catch (err: unknown) { + return { parsed: null, error: `Cannot read STATUS.md: ${err instanceof Error ? err.message : String(err)}` }; + } + + return { + parsed: parseStatusMdText(content, mtime), error: null, }; } @@ -710,73 +1033,8 @@ async function parseStatusMdContent( return { parsed: null, error: `Cannot read STATUS.md: ${err instanceof Error ? err.message : String(err)}` }; } - // Parse logic is identical to the sync version - const text = content.replace(/\r\n/g, "\n"); - const steps: ParsedWorktreeStatus["steps"] = []; - let currentStep: { - number: number; - name: string; - status: "not-started" | "in-progress" | "complete"; - checkboxes: boolean[]; - } | null = null; - let reviewCounter = 0; - let iteration = 0; - - for (const line of text.split("\n")) { - const rcMatch = line.match(/\*\*Review Counter:\*\*\s*(\d+)/); - if (rcMatch) reviewCounter = parseInt(rcMatch[1]); - const itMatch = line.match(/\*\*Iteration:\*\*\s*(\d+)/); - if (itMatch) iteration = parseInt(itMatch[1]); - - const stepMatch = line.match(/^###\s+Step\s+(\d+):\s*(.+)/); - if (stepMatch) { - if (currentStep) { - const totalChecked = currentStep.checkboxes.filter(c => c).length; - steps.push({ - number: currentStep.number, - name: currentStep.name, - status: currentStep.status, - totalChecked, - totalItems: currentStep.checkboxes.length, - }); - } - currentStep = { - number: parseInt(stepMatch[1]), - name: stepMatch[2].trim(), - status: "not-started", - checkboxes: [], - }; - continue; - } - if (currentStep) { - const ss = line.match(/\*\*Status:\*\*\s*(.*)/); - if (ss) { - const s = ss[1]; - if (s.includes("✅") || s.toLowerCase().includes("complete")) { - currentStep.status = "complete"; - } else if (s.includes("🟨") || s.includes("🟡") || s.toLowerCase().includes("progress")) { - currentStep.status = "in-progress"; - } - } - const cb = line.match(/^\s*-\s*\[([ xX])\]\s*(.*)/); - if (cb) { - currentStep.checkboxes.push(cb[1].toLowerCase() === "x"); - } - } - } - if (currentStep) { - const totalChecked = currentStep.checkboxes.filter(c => c).length; - steps.push({ - number: currentStep.number, - name: currentStep.name, - status: currentStep.status, - totalChecked, - totalItems: currentStep.checkboxes.length, - }); - } - return { - parsed: { steps, reviewCounter, iteration, mtime }, + parsed: parseStatusMdText(content, mtime), error: null, }; } @@ -903,22 +1161,24 @@ export async function resolveTaskMonitorState( totalItems += step.totalItems; } - // Find the current step (first in-progress, or first not-started after last complete) - const inProgress = steps.find(s => s.status === "in-progress"); - if (inProgress) { - currentStepName = inProgress.name; - currentStepNumber = inProgress.number; - } else { - // Find first not-started step - const notStarted = steps.find(s => s.status === "not-started"); - if (notStarted) { - currentStepName = notStarted.name; - currentStepNumber = notStarted.number; - } else if (steps.length > 0) { - // All complete - const last = steps[steps.length - 1]; - currentStepName = last.name; - currentStepNumber = last.number; + currentStepName = statusResult.parsed.currentStepName; + currentStepNumber = statusResult.parsed.currentStepNumber; + if (!currentStepName) { + // Fall back to per-step status inference for older STATUS.md variants + const inProgress = steps.find(s => s.status === "in-progress"); + if (inProgress) { + currentStepName = inProgress.name; + currentStepNumber = inProgress.number; + } else { + const notStarted = steps.find(s => s.status === "not-started"); + if (notStarted) { + currentStepName = notStarted.name; + currentStepNumber = notStarted.number; + } else if (steps.length > 0) { + const last = steps[steps.length - 1]; + currentStepName = last.name; + currentStepNumber = last.number; + } } } @@ -1211,7 +1471,7 @@ export async function monitorLanes( currentTaskId = task.taskId; const tracker = getOrCreateTracker(task.taskId, now); - const unit = buildExecutionUnit(lane, task, repoRoot, isWorkspaceMode); + const unit = buildExecutionUnit(lane, task, repoRoot, isWorkspaceMode); const donePath = unit.packet.donePath; const statusPath = unit.packet.statusPath; const statusResult = await parseStatusMdAtPath(statusPath); @@ -2195,6 +2455,7 @@ export function buildExecutionUnit( task: AllocatedTask, repoRoot: string, isWorkspaceMode?: boolean, + workspaceConfig?: WorkspaceConfig | null, ): ExecutionUnit { // TP-169: Guard against missing taskFolder. This can happen when // reconstructAllocatedLanes creates task stubs from persisted state @@ -2211,15 +2472,25 @@ export function buildExecutionUnit( task.taskId, ); } + const executionRepoId = lane.repoId ?? "default"; + const executionWorktreePath = lane.repoWorktrees?.[executionRepoId]?.path ?? lane.worktreePath; const resolved = resolveCanonicalTaskPaths( taskFolder, - lane.worktreePath, + executionWorktreePath, repoRoot, isWorkspaceMode, ); - const executionRepoId = lane.repoId ?? "default"; const packetHomeRepoId = task.task.packetRepoId ?? executionRepoId; + const repoPaths = buildExecutionRepoPaths( + task.task, + executionRepoId, + packetHomeRepoId, + executionWorktreePath, + repoRoot, + workspaceConfig, + lane.repoWorktrees, + ); // Build a segment-style ID if this is a segment execution, // otherwise use the plain task ID. @@ -2249,7 +2520,8 @@ export function buildExecutionUnit( segmentId, executionRepoId, packetHomeRepoId, - worktreePath: lane.worktreePath, + repoPaths, + worktreePath: executionWorktreePath, packet, task: task.task, }; @@ -2562,6 +2834,9 @@ export async function executeLaneV2( let shouldSkipRemaining = false; const stateRoot = resolveRuntimeStateRoot(repoRoot, workspaceRoot); + const workspaceConfig = (isWorkspaceMode && workspaceRoot) + ? loadWorkspaceConfig(workspaceRoot) + : null; const batchId = config.orchestrator?.batchId || extraEnvVars?.ORCH_BATCH_ID || String(Date.now()); // Build agent ID prefix — must match the wave planner's naming (TP-115). @@ -2615,7 +2890,7 @@ export async function executeLaneV2( } // Build execution unit - const unit = buildExecutionUnit(lane, task, repoRoot, isWorkspaceMode); + const unit = buildExecutionUnit(lane, task, repoRoot, isWorkspaceMode, workspaceConfig); const rawAutonomy = String(extraEnvVars?.TASKPLANE_SUPERVISOR_AUTONOMY ?? "autonomous").toLowerCase(); const supervisorAutonomy: LaneRunnerConfig["supervisorAutonomy"] = @@ -2630,6 +2905,7 @@ export async function executeLaneV2( worktreePath: lane.worktreePath, branch: lane.branch, repoId: lane.repoId ?? "default", + repoPaths: unit.repoPaths, stateRoot, workerModel: "", workerTools: "read,write,edit,bash,grep,find,ls", @@ -2654,22 +2930,30 @@ export async function executeLaneV2( try { const result = await executeTaskV2(unit, laneRunnerConfig, pauseSignal); - outcomes.push({ + const outcome: LaneTaskOutcome = { ...result.outcome, laneNumber: result.outcome.laneNumber ?? lane.laneNumber, - }); + }; // Commit artifacts after success (same as legacy path) - if (result.outcome.status === "succeeded") { - commitTaskArtifacts(lane, task, laneId); - // Reset worktree for next task - if (lane.tasks.indexOf(task) < lane.tasks.length - 1) { - runGit(["checkout", "--", "."], lane.worktreePath); - runGit(["clean", "-fd"], lane.worktreePath); + if (outcome.status === "succeeded") { + try { + commitTaskArtifacts(lane, task, laneId, { stateRoot, batchId, laneNumber: lane.laneNumber, repoId: lane.repoId ?? "default" }); + // Reset worktree for next task + if (lane.tasks.indexOf(task) < lane.tasks.length - 1) { + runGit(["checkout", "--", "."], lane.worktreePath); + runGit(["clean", "-fd"], lane.worktreePath); + } + } catch (err: unknown) { + const errMsg = err instanceof Error ? err.message : String(err); + outcome.status = "failed"; + outcome.exitReason = errMsg; } } - if (result.outcome.status === "failed" || result.outcome.status === "stalled") { + outcomes.push(outcome); + + if (outcome.status === "failed" || outcome.status === "stalled") { shouldSkipRemaining = true; } } catch (err: unknown) { diff --git a/extensions/taskplane/extension.ts b/extensions/taskplane/extension.ts index a1d4e9ee..44431e49 100644 --- a/extensions/taskplane/extension.ts +++ b/extensions/taskplane/extension.ts @@ -1,5 +1,6 @@ -import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { BorderedLoader, type ExtensionAPI, type ExtensionContext } from "@mariozechner/pi-coding-agent"; import { Type } from "@mariozechner/pi-ai"; +import { Box } from "@mariozechner/pi-tui"; import { execSync, execFileSync } from "child_process"; import { writeFileSync, unlinkSync, mkdirSync, existsSync, readdirSync, readFileSync, statSync, createWriteStream, renameSync } from "fs"; @@ -10,13 +11,20 @@ import { fork, type ChildProcess } from "child_process"; // Direct imports — avoid barrel (index.ts) to prevent loading the entire module graph. // Each import targets the specific module where the symbol is defined. import { DEFAULT_ORCHESTRATOR_CONFIG, DEFAULT_TASK_RUNNER_CONFIG, FATAL_DISCOVERY_CODES, StateFileError, WorkspaceConfigError, freshOrchBatchState } from "./types.ts"; -import type { AbortMode, ExecutionContext, MonitorState, OrchestratorConfig, PersistedBatchState, TaskRunnerConfig } from "./types.ts"; -import { ORCH_MESSAGES, computeIntegrateCleanupResult } from "./messages.ts"; +import type { AbortMode, ExecutionContext, MonitorState, OrchestratorConfig, PersistedBatchState, PreflightCheck, PreflightResult, TaskRunnerConfig, WorkspaceSyncApplyResult } from "./types.ts"; +import { + ORCH_MESSAGES, + computeIntegrateCleanupResult, + formatWorkspaceSyncPresentation, + getBlockingWorkspaceSyncFindings, + hasBlockingWorkspaceSyncFindings, +} from "./messages.ts"; import type { IntegrateCleanupRepoFindings } from "./messages.ts"; import { computeWaveAssignments } from "./waves.ts"; import { createOrchWidget, formatDependencyGraph, formatWavePlan } from "./formatting.ts"; +import { CollapsibleRibbonWidget, type CollapsibleRibbonWidgetState } from "./widgets/collapsible-ribbon.ts"; import { deleteBatchState, loadBatchState, saveBatchState, detectOrphanSessions, updateBatchHistoryIntegration } from "./persistence.ts"; -import { deleteStaleBranches, listWorktrees, resolveWorktreeBasePath, formatPreflightResults, runPreflight } from "./worktree.ts"; +import { deleteStaleBranches, listWorktrees, resolveWorktreeBasePath, runPreflight } from "./worktree.ts"; import { computeTransitiveDependents, resolveCanonicalTaskPaths } from "./execution.ts"; import { executeOrchBatch } from "./engine.ts"; import { formatDiscoveryResults, runDiscovery } from "./discovery.ts"; @@ -25,7 +33,7 @@ import { getCurrentBranch, runGit } from "./git.ts"; import { hasConfigFiles, resolveConfigRoot, loadOrchestratorConfig, loadSupervisorConfig, loadTaskRunnerConfig } from "./config.ts"; import { resolveOperatorId } from "./naming.ts"; import { reconstructAllocatedLanes, resumeOrchBatch } from "./resume.ts"; -import { buildExecutionContext } from "./workspace.ts"; +import { applyWorkspaceSync, buildExecutionContext, collectWorkspaceSyncSummary } from "./workspace.ts"; import { openSettingsTui } from "./settings-tui.ts"; import { loadProjectConfig } from "./config-loader.ts"; import { runMigrations } from "./migrations.ts"; @@ -68,6 +76,32 @@ import type { SupervisorConfig, SupervisorRoutingContext, IntegrationExecutor, C // ── Integrate Args Parsing ──────────────────────────────────────────── +const ORCH_PLAN_MESSAGE_TYPE = "taskplane-orch-plan"; +const ORCH_PLAN_WIDGET_KEY = "task-orch-plan"; + +function isMergeStatusNotification(message: string): boolean { + const normalized = message.trimStart(); + return normalized.startsWith("🔀 [Wave ") + || /^✅ Lane \d+ merged/.test(normalized) + || /^⚡ Lane \d+ merged/.test(normalized) + || /^❌ Lane \d+ merge failed:/.test(normalized) + || (normalized.startsWith("❌ [Wave ") && normalized.includes("Merge")) + || (normalized.startsWith("⚠️ [Wave ") && normalized.includes("Merge")) + || normalized.startsWith("📝 [Wave "); +} + +function dispatchOrchNotify( + ctx: ExtensionContext, + message: string, + level: "info" | "warning" | "error", + updateWidget: () => void, +): void { + if (!isMergeStatusNotification(message)) { + ctx.ui.notify(message, level); + } + updateWidget(); +} + export type IntegrateMode = "ff" | "merge" | "pr"; export interface IntegrateArgs { @@ -1045,7 +1079,7 @@ export function startBatchInWorker( wkData.runnerConfig, wkData.cwd, batchState, - (msg: string, lvl: "info" | "warning" | "error") => { ctx.ui.notify(msg, lvl); updateWidget(); }, + (msg: string, lvl: "info" | "warning" | "error") => { dispatchOrchNotify(ctx, msg, lvl, updateWidget); }, (monState: import("./types.ts").MonitorState) => { onMonitorUpdate?.(monState); }, wsConfig, wkData.workspaceRoot, @@ -1060,7 +1094,7 @@ export function startBatchInWorker( wkData.runnerConfig, wkData.cwd, batchState, - (msg: string, lvl: "info" | "warning" | "error") => { ctx.ui.notify(msg, lvl); updateWidget(); }, + (msg: string, lvl: "info" | "warning" | "error") => { dispatchOrchNotify(ctx, msg, lvl, updateWidget); }, (monState: import("./types.ts").MonitorState) => { onMonitorUpdate?.(monState); }, wsConfig, wkData.workspaceRoot, @@ -1154,8 +1188,7 @@ export function startBatchInWorker( child.on("message", (msg: WorkerToMainMessage) => { switch (msg.type) { case "notify": - ctx.ui.notify(msg.msg, msg.level); - updateWidget(); + dispatchOrchNotify(ctx, msg.msg, msg.level, updateWidget); break; case "monitor-update": @@ -1645,6 +1678,17 @@ export function detectOrchState(deps: OrchStateDetectionDeps): OrchStateDetectio // ── Extension ──────────────────────────────────────────────────────── export default function (pi: ExtensionAPI) { + pi.registerMessageRenderer(ORCH_PLAN_MESSAGE_TYPE, (message, { expanded }, theme) => { + const state = message.details as CollapsibleRibbonWidgetState | undefined; + if (!state) return undefined; + const ribbon = new CollapsibleRibbonWidget(state); + const component = (expanded ? ribbon.open() : ribbon.close()).factory()?.(undefined, theme); + if (!component) return undefined; + const box = new Box(0, 0, (text) => theme.bg("customMessageBg", text)); + box.addChild(component); + return box; + }); + let orchBatchState = freshOrchBatchState(); let orchConfig: OrchestratorConfig = { ...DEFAULT_ORCHESTRATOR_CONFIG }; let runnerConfig: TaskRunnerConfig = { ...DEFAULT_TASK_RUNNER_CONFIG }; @@ -1689,6 +1733,12 @@ export default function (pi: ExtensionAPI) { ); } + function resetRootWidget(ctx?: ExtensionContext, key = ORCH_PLAN_WIDGET_KEY) { + if (ctx) { + ctx.ui.setWidget(key, undefined); + } + } + // ── Command Guard ──────────────────────────────────────────────── function getExecCtxInitErrorMessage(): string { @@ -1706,6 +1756,159 @@ export default function (pi: ExtensionAPI) { return false; } + function resolveRuntimeSubmodulePolicy() { + return { + failureMode: orchConfig.failure.submodule_failure_mode ?? "permissive", + onSubmoduleDrift: orchConfig.failure.on_submodule_drift ?? "manual", + repoIdStrategy: orchConfig.orchestrator.submodule_repo_id_strategy ?? "path-basename", + }; + } + + function collectCurrentWorkspaceSyncSummary(targetLabel: string) { + if (!execCtx) return null; + return collectWorkspaceSyncSummary( + execCtx.repoRoot, + execCtx.workspaceConfig, + resolveRuntimeSubmodulePolicy(), + targetLabel, + ); + } + + function executeWorkspaceSync(targetLabel: string) { + try { + const syncResult = applyWorkspaceSync( + execCtx!.workspaceRoot, + execCtx!.repoRoot, + execCtx!.workspaceConfig, + resolveRuntimeSubmodulePolicy(), + collectCurrentWorkspaceSyncSummary(targetLabel)!, + ); + return { + syncResult, + refreshedSummary: collectCurrentWorkspaceSyncSummary(targetLabel), + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + syncResult: { + importedRepoIds: [], + initializedPaths: [], + updatedPaths: [], + warnings: [`Workspace sync crashed: ${message}`], + changed: false, + }, + refreshedSummary: collectCurrentWorkspaceSyncSummary(targetLabel), + }; + } + } + + async function runWorkspaceSyncWithUi(targetLabel: string, ctx: ExtensionContext) { + if (!ctx.hasUI) return executeWorkspaceSync(targetLabel); + return ctx.ui.custom>((tui, theme, _keybindings, done) => { + const loader = new BorderedLoader(tui, theme, "Running workspace sync before planning...", { + cancellable: false, + }); + setImmediate(() => { + done(executeWorkspaceSync(targetLabel)); + }); + return loader; + }); + } + + function formatWorkspaceSyncBlocker( + targetLabel: string, + summary: ReturnType, + afterSync = false, + ): string { + const syncCmd = `/orch-plan ${targetLabel} --sync`; + const headline = afterSync + ? "⚠️ Workspace sync is still incomplete." + : "⚠️ Workspace sync is required before continuing."; + const findings = getBlockingWorkspaceSyncFindings(summary) + .slice(0, 5) + .map((finding) => ` • ${finding.message}`); + return [ + headline, + `Run ${syncCmd} and resolve the reported findings before retrying.`, + findings.length > 0 ? "" : undefined, + ...findings, + ].filter(Boolean).join("\n"); + } + + function formatDetectedSubmodulesSection( + summary: ReturnType, + ): string { + if (!summary || summary.detectedSubmodules.length === 0) { + return "🧩 Detected Submodules (0):\n • none"; + } + return [ + `🧩 Detected Submodules (${summary.trackedSubmodules}):`, + ...summary.detectedSubmodules.map((submodule) => { + const stateLabel = submodule.state === "clean" + ? "clean" + : submodule.state === "uninitialized" + ? "uninitialized" + : submodule.state === "drifted" + ? "drifted" + : "conflict"; + const marker = submodule.state === "clean" ? "•" : "!"; + return ` ${marker} ${submodule.repoLabel}:${submodule.submodulePath} [${stateLabel}]`; + }), + ].join("\n"); + } + + function isWorkspaceSyncCheck(check: PreflightCheck): boolean { + return check.name === "submodules" || check.name.startsWith("submodule-"); + } + + function formatOrchPlanPreflightSection( + result: PreflightResult, + summary: ReturnType, + options?: { showWorkspaceSyncStatus?: boolean; workspaceSyncResult?: WorkspaceSyncApplyResult | null }, + ): string { + const visibleChecks = result.checks.filter((check) => !isWorkspaceSyncCheck(check)); + const lines: string[] = ["Preflight Check:"]; + + for (const check of visibleChecks) { + const icon = + check.status === "pass" ? "✅" : + check.status === "warn" ? "⚠️ " : + "❌"; + const nameCol = check.name.padEnd(18); + lines.push(` ${icon} ${nameCol} ${check.message}`); + if (check.hint && check.status !== "pass") { + for (const hintLine of check.hint.split("\n")) { + lines.push(` ${" ".repeat(18)} ${hintLine}`); + } + } + } + + if (options?.showWorkspaceSyncStatus ?? true) { + const workspaceSyncResult = options?.workspaceSyncResult; + const workspaceWarnings = summary?.detectedSubmodules.filter((submodule) => submodule.state !== "clean") ?? []; + const syncNameCol = "sync".padEnd(18); + if (workspaceWarnings.length > 0 || (workspaceSyncResult?.warnings.length ?? 0) > 0) { + lines.push(` ⚠️ ${syncNameCol} Workspace sync delivered warnings`); + } else { + lines.push(` ✅ ${syncNameCol} Workspace synced successfully`); + } + lines.push(""); + } + const visiblePassed = visibleChecks.every((check) => check.status !== "fail"); + if (visiblePassed) { + lines.push("All required checks passed."); + } else { + const failedNames = visibleChecks + .filter((check) => check.status === "fail") + .map((check) => check.name) + .join(", "); + lines.push(`❌ Preflight FAILED: ${failedNames}`); + lines.push("Fix the issues above before running the orchestrator."); + } + + return lines.join("\n"); + } + // ── Commands ───────────────────────────────────────────────────── pi.registerCommand("orch", { @@ -1800,115 +2003,224 @@ export default function (pi: ExtensionAPI) { }); pi.registerCommand("orch-plan", { - description: "Preview execution plan: /orch-plan [--refresh]", + description: "Preview execution plan: /orch-plan [--refresh] [--sync]", handler: async (args, ctx) => { - if (!args?.trim()) { - ctx.ui.notify( - "Usage: /orch-plan [--refresh]\n\n" + - "Shows the execution plan (tasks, waves, lane assignments)\n" + - "without actually executing anything.\n\n" + - "Options:\n" + - " --refresh Force re-scan of areas (bypass dependency cache)\n\n" + - "Examples:\n" + - " /orch-plan all\n" + - " /orch-plan time-off notifications\n" + - " /orch-plan docs/task-management/domains/time-off/tasks\n" + - " /orch-plan all --refresh", - "info", + resetRootWidget(ctx); + const commandTitle = args?.trim() ? `/orch-plan ${args.trim()}` : "/orch-plan"; + const orchPlanWidget = new CollapsibleRibbonWidget({ + title: commandTitle, + status: "running", + phase: "Preparing plan", + sections: [], + viewState: "running", + padding: 1, + }); + const getOrchPlanState = () => orchPlanWidget.state; + const renderOrchPlan = () => { + ctx.ui.setWidget(ORCH_PLAN_WIDGET_KEY, orchPlanWidget.factory()); + }; + const updateOrchPlan = (patch: Partial) => { + orchPlanWidget.update(patch); + renderOrchPlan(); + }; + const setOrchPlanPhase = (phase: string) => { + const currentStatus = getOrchPlanState().status; + updateOrchPlan({ + status: currentStatus === "error" ? "error" : "running", + phase, + collapsed: false, + viewState: "running", + }); + }; + const finalizeOrchPlan = (status: CollapsibleRibbonWidgetState["status"], phase: string) => { + const collapsedMessage = orchPlanWidget.message({ + status, + phase, + collapsed: false, + viewState: "opened", + }); + pi.sendMessage( + { + customType: ORCH_PLAN_MESSAGE_TYPE, + content: [{ + type: "text", + text: collapsedMessage.text, + }], + display: true, + details: collapsedMessage.details, + }, + { triggerTurn: false }, ); - return; - } + resetRootWidget(ctx); + }; + const publishOrchPlanSection = ( + message: string, + level: "info" | "warning" | "error", + persist = true, + ) => { + if (persist && message.trim().length > 0) { + const currentState = getOrchPlanState(); + const nextStatus = level === "error" + ? "error" + : level === "warning" && currentState.status === "running" + ? "warning" + : currentState.status; + updateOrchPlan({ + status: nextStatus, + sections: [...currentState.sections, message], + }); + return; + } + ctx.ui.notify(message, level); + }; - if (!requireExecCtx(ctx)) return; + renderOrchPlan(); - // Parse --refresh flag - const hasRefresh = /--refresh/.test(args); - const cleanArgs = args.replace(/--refresh/g, "").trim(); - if (!cleanArgs) { - ctx.ui.notify( - "Usage: /orch-plan [--refresh]\n" + - "Error: target argument required (e.g., 'all', area name, or path)", - "error", - ); - return; - } - if (hasRefresh) { - ctx.ui.notify("🔄 Refresh mode: re-scanning all areas (cache bypassed)", "info"); - } + try { + if (!args?.trim()) { + publishOrchPlanSection( + "Usage: /orch-plan [--refresh] [--sync]\n\n" + + "Shows the execution plan (tasks, waves, lane assignments)\n" + + "without actually executing anything.\n\n" + + "Options:\n" + + " --refresh Force re-scan of areas (bypass dependency cache)\n" + + " --sync Reconcile workspace repo imports and submodule state before planning\n\n" + + "Examples:\n" + + " /orch-plan all\n" + + " /orch-plan all --sync\n" + + " /orch-plan time-off notifications\n" + + " /orch-plan docs/task-management/domains/time-off/tasks\n" + + " /orch-plan all --refresh", + "info", + ); + finalizeOrchPlan("error", "Usage error"); + return; + } - // ── Section 1: Preflight ───────────────────────────────── - ctx.ui.notify("ℹ️ Runtime V2 is the default backend (subprocess-only).", "info"); - const preflight = runPreflight(orchConfig, execCtx!.repoRoot); - ctx.ui.notify(formatPreflightResults(preflight), preflight.passed ? "info" : "error"); - if (!preflight.passed) return; + if (!requireExecCtx(ctx)) { + finalizeOrchPlan("error", "Initialization failed"); + return; + } - // ── Section 2: Discovery ───────────────────────────────── - // Discovery resolves task area paths relative to workspaceRoot (not repoRoot), - // because task_areas in task-runner.yaml are workspace-relative paths. - const discovery = runDiscovery(cleanArgs, runnerConfig.task_areas, execCtx!.workspaceRoot, { - refreshDependencies: hasRefresh, - dependencySource: orchConfig.dependencies.source, - useDependencyCache: orchConfig.dependencies.cache, - workspaceConfig: execCtx!.workspaceConfig, - }); - ctx.ui.notify(formatDiscoveryResults(discovery), discovery.errors.length > 0 ? "warning" : "info"); - - // Check for fatal errors - const fatalCodes = new Set(FATAL_DISCOVERY_CODES); - const fatalErrors = discovery.errors.filter((e) => fatalCodes.has(e.code)); - if (fatalErrors.length > 0) { - ctx.ui.notify("❌ Cannot compute plan due to discovery errors above.", "error"); - const hasRoutingErrors = fatalErrors.some( - (e) => e.code === "TASK_REPO_UNRESOLVED" || e.code === "TASK_REPO_UNKNOWN", - ); - if (hasRoutingErrors) { - ctx.ui.notify( - "💡 Check PROMPT Repo: fields, area repo_id config, and routing.default_repo in workspace config.", - "info", - ); - } - const hasStrictErrors = fatalErrors.some( - (e) => e.code === "TASK_ROUTING_STRICT", - ); - if (hasStrictErrors) { - ctx.ui.notify( - "💡 Strict routing is enabled (routing.strict: true). Every task must declare an explicit execution target.\n" + - " Add a `## Execution Target` section with `Repo: ` to each task's PROMPT.md.\n" + - " To disable strict routing, set `routing.strict: false` in workspace config.", - "info", + const hasRefresh = /(^|\s)--refresh(?=\s|$)/.test(args); + const hasSync = /(^|\s)--sync(?=\s|$)/.test(args); + const cleanArgs = args.replace(/(^|\s)--refresh(?=\s|$)/g, " ").replace(/(^|\s)--sync(?=\s|$)/g, " ").trim(); + if (!cleanArgs) { + publishOrchPlanSection( + "Usage: /orch-plan [--refresh] [--sync]\n" + + "Error: target argument required (e.g., 'all', area name, or path)", + "error", + ); + finalizeOrchPlan("error", "Target required"); + return; + } + if (hasRefresh) { + ctx.ui.notify("🔄 Refresh mode: re-scanning all areas (cache bypassed)", "info"); + } + + ctx.ui.notify("ℹ️ Runtime V2 is the default backend (subprocess-only).", "info"); + let workspaceSyncSummary = collectCurrentWorkspaceSyncSummary(cleanArgs); + let orchPlanWorkspaceSyncResult: WorkspaceSyncApplyResult | null = null; + if (hasSync && workspaceSyncSummary) { + setOrchPlanPhase("Syncing workspace"); + const syncOutcome = await runWorkspaceSyncWithUi(cleanArgs, ctx); + orchPlanWorkspaceSyncResult = syncOutcome.syncResult; + workspaceSyncSummary = syncOutcome.refreshedSummary; + } + setOrchPlanPhase("Running preflight"); + const preflight = runPreflight(orchConfig, execCtx!.repoRoot, { + workspaceRoot: execCtx!.workspaceRoot, + pointerConfigRoot: execCtx!.pointer?.configRoot, + workspaceConfig: execCtx!.workspaceConfig, + }); + publishOrchPlanSection( + formatOrchPlanPreflightSection(preflight, workspaceSyncSummary, { + showWorkspaceSyncStatus: true, + workspaceSyncResult: orchPlanWorkspaceSyncResult, + }), + preflight.passed ? "info" : "error", ); - } - return; - } + publishOrchPlanSection(formatDetectedSubmodulesSection(workspaceSyncSummary), "info"); + if (hasBlockingWorkspaceSyncFindings(workspaceSyncSummary)) { + finalizeOrchPlan("error", "Workspace sync required"); + return; + } + if (!preflight.passed) { + finalizeOrchPlan("error", "Preflight failed"); + return; + } - if (discovery.pending.size === 0) { - ctx.ui.notify("No pending tasks found. Nothing to plan.", "info"); - return; - } + setOrchPlanPhase("Discovering tasks"); + const discovery = runDiscovery(cleanArgs, runnerConfig.task_areas, execCtx!.workspaceRoot, { + refreshDependencies: hasRefresh, + dependencySource: orchConfig.dependencies.source, + useDependencyCache: orchConfig.dependencies.cache, + workspaceConfig: execCtx!.workspaceConfig, + }); + publishOrchPlanSection(formatDiscoveryResults(discovery), discovery.errors.length > 0 ? "warning" : "info"); + + const fatalCodes = new Set(FATAL_DISCOVERY_CODES); + const fatalErrors = discovery.errors.filter((e) => fatalCodes.has(e.code)); + if (fatalErrors.length > 0) { + publishOrchPlanSection("❌ Cannot compute plan due to discovery errors above.", "error"); + const hasRoutingErrors = fatalErrors.some( + (e) => e.code === "TASK_REPO_UNRESOLVED" || e.code === "TASK_REPO_UNKNOWN" || e.code === "TASK_REPO_SCOPE_MISMATCH", + ); + if (hasRoutingErrors) { + publishOrchPlanSection( + "💡 Check PROMPT Repo:/Repos: fields, repo-prefixed file scope entries, area repo_id config, and routing.default_repo in workspace config.", + "info", + ); + } + const hasStrictErrors = fatalErrors.some( + (e) => e.code === "TASK_ROUTING_STRICT", + ); + if (hasStrictErrors) { + publishOrchPlanSection( + "💡 Strict routing is enabled (routing.strict: true). Every task must declare an explicit execution target.\n" + + " Add a `## Execution Target` section with `Repo: ` or `Repos: , ` to each task's PROMPT.md.\n" + + " To disable strict routing, set `routing.strict: false` in workspace config.", + "info", + ); + } + finalizeOrchPlan("error", "Discovery failed"); + return; + } - // ── Section 3: Dependency Graph ────────────────────────── - ctx.ui.notify( - formatDependencyGraph(discovery.pending, discovery.completed), - "info", - ); + if (discovery.pending.size === 0) { + publishOrchPlanSection("No pending tasks found. Nothing to plan.", "info"); + finalizeOrchPlan("success", "No pending tasks"); + return; + } - // ── Section 4: Waves + Estimate ────────────────────────── - // Uses computeWaveAssignments pipeline only — NO re-parsing - const waveResult = computeWaveAssignments( - discovery.pending, - discovery.completed, - orchConfig, - { - workspaceRepoIds: execCtx!.workspaceConfig - ? execCtx!.workspaceConfig.repos.keys() - : undefined, - }, - ); + setOrchPlanPhase("Rendering dependency graph"); + publishOrchPlanSection( + formatDependencyGraph(discovery.pending, discovery.completed), + "info", + ); - ctx.ui.notify( - formatWavePlan(waveResult, orchConfig.assignment.size_weights), - waveResult.errors.length > 0 ? "error" : "info", - ); + setOrchPlanPhase("Computing waves"); + const waveResult = computeWaveAssignments( + discovery.pending, + discovery.completed, + orchConfig, + { + workspaceRepoIds: execCtx!.workspaceConfig + ? execCtx!.workspaceConfig.repos.keys() + : undefined, + }, + ); + + publishOrchPlanSection( + formatWavePlan(waveResult, orchConfig.assignment.size_weights), + waveResult.errors.length > 0 ? "error" : "info", + ); + finalizeOrchPlan(waveResult.errors.length > 0 ? "error" : "success", waveResult.errors.length > 0 ? "Plan failed" : "Plan ready"); + } catch (err: unknown) { + const errMsg = err instanceof Error ? err.message : String(err); + publishOrchPlanSection(`❌ Orchestrator plan crashed: ${errMsg}`, "error"); + finalizeOrchPlan("error", "Plan failed"); + } }, }); @@ -2071,6 +2383,14 @@ export default function (pi: ExtensionAPI) { }; } + const workspaceSyncSummary = collectCurrentWorkspaceSyncSummary(trimmedTarget); + if (hasBlockingWorkspaceSyncFindings(workspaceSyncSummary)) { + return { + message: formatWorkspaceSyncBlocker(trimmedTarget, workspaceSyncSummary), + error: true, + }; + } + // Pre-discovery: count pending tasks for the ACK response. // This is a lightweight synchronous check before launching the async engine. let pendingTaskCount = 0; @@ -5049,6 +5369,7 @@ export default function (pi: ExtensionAPI) { } catch { // Best effort only — session is already ending. } + resetRootWidget(); }); } diff --git a/extensions/taskplane/formatting.ts b/extensions/taskplane/formatting.ts index 3a4e58b4..972c0055 100644 --- a/extensions/taskplane/formatting.ts +++ b/extensions/taskplane/formatting.ts @@ -3,12 +3,73 @@ * @module orch/formatting */ import { join } from "path"; -import { truncateToWidth } from "@mariozechner/pi-tui"; +import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui"; import { parseDependencyReference } from "./discovery.ts"; import type { LaneAssignment, MonitorState, OrchBatchRuntimeState, OrchDashboardViewModel, OrchLaneCardData, OrchSummaryCounts, ParsedTask, WaveComputationResult } from "./types.ts"; import { getTaskDurationMinutes, SIZE_DURATION_MINUTES } from "./types.ts"; +function renderMergePanel(panel: NonNullable, width: number, theme: any): string[] { + const availableWidth = Math.max(8, width - 2); + const innerWidth = Math.max(1, availableWidth - 2); + const indent = " "; + const tone = panel.status === "success" + ? "success" + : panel.status === "error" + ? "error" + : panel.status === "warning" + ? "warning" + : "accent"; + const headerLabel = typeof theme.bold === "function" ? theme.bold("Merge Status") : "Merge Status"; + const statusText = panel.status === "success" + ? "Complete" + : panel.status === "error" + ? "Failed" + : panel.status === "warning" + ? "Warnings" + : "Running"; + const statusLine = `${panel.waveLabel || "Merge"} · ${statusText}`; + const contentLines = [ + `${typeof theme.fg === "function" ? theme.fg(tone, "🔀") : "🔀"} ${headerLabel}`, + statusLine, + ...(panel.events.length > 0 ? [""] : []), + ...panel.events.map((event) => { + const marker = event.level === "success" + ? "✓" + : event.level === "error" + ? "✗" + : event.level === "warning" + ? "!" + : "•"; + const eventTone = event.level === "success" + ? "success" + : event.level === "error" + ? "error" + : event.level === "warning" + ? "warning" + : "muted"; + const prefix = typeof theme.fg === "function" ? theme.fg(eventTone, marker) : marker; + return `${prefix} ${event.message}`; + }), + ]; + const lines = [truncateToWidth(`${indent}┌${"─".repeat(innerWidth)}┐`, width)]; + for (const contentLine of contentLines) { + if (contentLine.length === 0) { + lines.push(truncateToWidth(`${indent}│${" ".repeat(innerWidth)}│`, width)); + continue; + } + for (const wrappedLine of wrapTextWithAnsi(contentLine, innerWidth)) { + const visible = visibleWidth(wrappedLine); + lines.push(truncateToWidth( + `${indent}│${wrappedLine}${" ".repeat(Math.max(0, innerWidth - visible))}│`, + width, + )); + } + } + lines.push(truncateToWidth(`${indent}└${"─".repeat(innerWidth)}┘`, width)); + return lines; +} + // ── Wave Output Formatting ─────────────────────────────────────────── // ── Dependency Graph Formatting ────────────────────────────────────── @@ -747,6 +808,10 @@ export function createOrchWidget( theme.fg("accent", ` 🔀 Merging lane branches into ${vm.orchBranch || "orch branch"}...`), width, )); + if (batchState.mergePanel) { + lines.push(""); + lines.push(...renderMergePanel(batchState.mergePanel, width, theme)); + } } else if (vm.phase === "paused") { lines.push(""); lines.push(truncateToWidth( @@ -771,3 +836,4 @@ export function createOrchWidget( }; } + diff --git a/extensions/taskplane/git.ts b/extensions/taskplane/git.ts index 93022f60..a757ced6 100644 --- a/extensions/taskplane/git.ts +++ b/extensions/taskplane/git.ts @@ -3,6 +3,46 @@ * @module orch/git */ import { execFileSync } from "child_process"; +import { existsSync } from "fs"; +import { join } from "path"; + +export interface GitSubmoduleStatus { + path: string; + state: "ok" | "uninitialized" | "drifted" | "conflict"; + commit: string; + description?: string; +} + +export interface UnsafeSubmoduleState { + path: string; + kind: "dirty-worktree" | "unpublished-commit"; + headCommit?: string; + indexCommit?: string; + remoteName?: string; +} + +export interface SubmoduleStatusPreview { + path: string; + statusLines: string[]; + lineCount: number; + truncated: boolean; + dirty: boolean; + error?: string; +} + +export interface SubmoduleStatusSnapshot { + capturedAt: number; + worktreePath: string; + totalSubmodules: number; + dirtySubmodules: number; + entries: SubmoduleStatusPreview[]; +} + +export interface UnreachableGitlinkState { + path: string; + gitlinkCommit: string; + remoteName?: string; +} // ── Branch Helpers ─────────────────────────────────────────────────── @@ -88,3 +128,527 @@ export function runGitWithEnv( } } +function runGitWithDir( + gitDir: string, + args: string[], +): { ok: boolean; stdout: string; stderr: string } { + try { + const stdout = execFileSync("git", ["--git-dir", gitDir, ...args], { + encoding: "utf-8", + timeout: 30_000, + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + return { ok: true, stdout, stderr: "" }; + } catch (err: unknown) { + const e = err as { stdout?: string; stderr?: string; message?: string }; + return { + ok: false, + stdout: (e.stdout ?? "").toString().trim(), + stderr: (e.stderr ?? e.message ?? "unknown error").toString().trim(), + }; + } +} + +function uniqueSorted(values: Iterable): string[] { + return [...new Set(values)].sort((left, right) => left.localeCompare(right)); +} + +/** List submodule paths declared in .gitmodules. */ +export function listConfiguredSubmodulePaths(cwd: string): string[] { + const result = runGit(["config", "-f", ".gitmodules", "--get-regexp", "^submodule\\..*\\.path$"], cwd); + if (!result.ok || !result.stdout.trim()) return []; + + const paths: string[] = []; + for (const line of result.stdout.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + const value = trimmed.replace(/^submodule\.[^.]+\.path\s+/, "").trim(); + if (value) paths.push(value); + } + + return uniqueSorted(paths); +} + +/** List gitlink entries tracked by the current repository. */ +export function listGitlinkPaths(cwd: string): string[] { + const result = runGit(["ls-files", "--stage"], cwd); + if (!result.ok || !result.stdout.trim()) return []; + + const paths: string[] = []; + for (const line of result.stdout.split(/\r?\n/)) { + const match = line.match(/^160000\s+[0-9a-f]+\s+\d+\t(.+)$/i); + if (match?.[1]) { + paths.push(match[1]); + } + } + + return uniqueSorted(paths); +} + +function parseSubmoduleStatusLine(line: string): GitSubmoduleStatus | undefined { + if (!line) return undefined; + const prefix = line[0]; + const trimmed = line.slice(1).trim(); + if (!trimmed) return undefined; + + const firstSpace = trimmed.indexOf(" "); + if (firstSpace <= 0) return undefined; + + const commit = trimmed.slice(0, firstSpace).trim(); + let pathAndDescription = trimmed.slice(firstSpace + 1).trim(); + let description: string | undefined; + + const descriptionMatch = pathAndDescription.match(/^(.*)\s+\((.*)\)$/); + if (descriptionMatch) { + pathAndDescription = descriptionMatch[1].trim(); + description = descriptionMatch[2].trim(); + } + + if (!pathAndDescription) return undefined; + + const state = + prefix === "-" ? "uninitialized" : + prefix === "+" ? "drifted" : + prefix === "U" ? "conflict" : + "ok"; + + return { + path: pathAndDescription, + state, + commit, + ...(description ? { description } : {}), + }; +} + +/** List recursive submodule status entries for the repository. */ +export function listSubmoduleStatus(cwd: string): GitSubmoduleStatus[] { + const result = runGit(["submodule", "status", "--recursive"], cwd); + if (!result.ok || !result.stdout.trim()) return []; + + const statuses = result.stdout + .split(/\r?\n/) + .map(parseSubmoduleStatusLine) + .filter((entry): entry is GitSubmoduleStatus => !!entry); + + return statuses.sort((left, right) => left.path.localeCompare(right.path)); +} + +function readGitlinkCommit(cwd: string, submodulePath: string): string | null { + const result = runGit(["ls-files", "--stage", "--", submodulePath], cwd); + if (!result.ok || !result.stdout.trim()) return null; + const line = result.stdout.split(/\r?\n/).find(Boolean)?.trim(); + const match = line?.match(/^160000\s+([0-9a-f]+)\s+\d+\t/i); + return match?.[1] ?? null; +} + +function resolveSubmoduleGitDir(cwd: string, submodulePath: string): string | null { + const commonDirResult = runGit(["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd); + if (!commonDirResult.ok || !commonDirResult.stdout.trim()) return null; + const gitDir = join(commonDirResult.stdout.trim(), "modules", ...submodulePath.split("/")); + return existsSync(gitDir) ? gitDir : null; +} + +function ensureSubmoduleCheckout(cwd: string, submodulePath: string): void { + const absolutePath = join(cwd, submodulePath); + const repoCheck = existsSync(absolutePath) + ? runGit(["rev-parse", "--is-inside-work-tree"], absolutePath) + : { ok: false, stdout: "", stderr: "" }; + if (repoCheck.ok && repoCheck.stdout.trim() === "true") return; + runGit(["-c", "protocol.file.allow=always", "submodule", "update", "--init", "--", submodulePath], cwd); +} + +export function captureSubmoduleStatusSnapshot( + cwd: string, + maxLinesPerSubmodule = 12, +): SubmoduleStatusSnapshot { + const submodulePaths = uniqueSorted([ + ...listGitlinkPaths(cwd), + ...listConfiguredSubmodulePaths(cwd), + ]); + const entries: SubmoduleStatusPreview[] = []; + let dirtySubmodules = 0; + + for (const submodulePath of submodulePaths) { + const absolutePath = join(cwd, submodulePath); + if (!existsSync(absolutePath)) { + entries.push({ + path: submodulePath, + statusLines: [], + lineCount: 0, + truncated: false, + dirty: false, + error: "submodule path does not exist on disk", + }); + continue; + } + + const statusResult = runGit(["status", "--porcelain"], absolutePath); + if (!statusResult.ok) { + entries.push({ + path: submodulePath, + statusLines: [], + lineCount: 0, + truncated: false, + dirty: false, + error: statusResult.stderr || statusResult.stdout || "git status failed", + }); + continue; + } + + // Build a set of known submodule paths for gitlink-only dirty detection. + // When the parent repo stages a gitlink change, every shared-worktree + // submodule reports "M " as dirty — these are + // transient index artifacts from checkpointing, not real code changes. + const knownSubmodulePaths = new Set(submodulePaths); + // Filter out task-plane artifact paths (and gitlink-only state) before counting as dirty + const filteredStdout = filterArtifactStatusLines(statusResult.stdout, knownSubmodulePaths); + const lines = filteredStdout + .split(/\r?\n/) + .map((line) => line.trimEnd()) + .filter(Boolean); + const dirty = lines.length > 0; + if (dirty) dirtySubmodules += 1; + + entries.push({ + path: submodulePath, + statusLines: lines.slice(0, maxLinesPerSubmodule), + lineCount: lines.length, + truncated: lines.length > maxLinesPerSubmodule, + dirty, + }); + } + + return { + capturedAt: Date.now(), + worktreePath: cwd, + totalSubmodules: submodulePaths.length, + dirtySubmodules, + entries, + }; +} + +function resolvePreferredRemote(cwd: string): string | null { + const result = runGit(["remote"], cwd); + if (!result.ok || !result.stdout.trim()) return null; + const remotes = result.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + if (remotes.includes("origin")) return "origin"; + return remotes[0] ?? null; +} + +function resolvePreferredRemoteFromGitDir(gitDir: string): string | null { + const result = runGitWithDir(gitDir, ["remote"]); + if (!result.ok || !result.stdout.trim()) return null; + const remotes = result.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + if (remotes.includes("origin")) return "origin"; + return remotes[0] ?? null; +} + +/** + * Check whether a specific commit is reachable from a submodule's remote. + * + * Strategy: + * 1. Direct match against origin/HEAD via ls-remote (always points to latest tip). + * 2. Named branch/tag tips via ls-remote + merge-base ancestry check. + * 3. If fetch was done before calling, also try local tracking refs as fallback. + * + * This handles forked submodules where commits exist on origin but may not be + * reachable from the branch names that ls-remote returns in a stale worktree. + */ +function checkSubmoduleCommitReachable(cwd: string, remoteName: string, commit: string): boolean { + // Fast path: direct match against origin/HEAD — always available after fetch. + const headResult = runGit(["ls-remote", remoteName, "HEAD"], cwd); + if (headResult.ok && headResult.stdout.trim()) { + const headSha = headResult.stdout + .split(/\r?\n/) + .map((line) => line.trim().split(/[\t]+/)[0] ?? "") + [0]; + if (headSha === commit) return true; + } + + // Check all named branch and tag tips. + const refsResult = runGit(["ls-remote", remoteName, "refs/heads/*", "refs/tags/*"], cwd); + if (!refsResult.ok || !refsResult.stdout.trim()) { + // Fallback: if we have a local tracking ref for origin/HEAD, use merge-base. + const mbResult = runGit(["merge-base", "--is-ancestor", commit, `${remoteName}/HEAD`], cwd); + if (mbResult.ok) return true; + return false; + } + + const remoteTips = uniqueSorted( + refsResult.stdout + .split(/\r?\n/) + .map((line) => line.trim().split(/[\s]+/)[0] ?? "") + .filter((sha) => /^[0-9a-f]{40}$/i.test(sha)), + ); + + for (const tip of remoteTips) { + if (tip === commit) return true; + const ancestorResult = runGit(["merge-base", "--is-ancestor", commit, tip], cwd); + if (ancestorResult.ok) return true; + } + + // Final fallback: check against origin/HEAD as a local ref. + if (runGit(["merge-base", "--is-ancestor", commit, `${remoteName}/HEAD`], cwd).ok) return true; + + return false; +} + +function isCommitReachableOnRemoteFromGitDir(gitDir: string, remoteName: string, commit: string): boolean { + const refsResult = runGitWithDir(gitDir, ["ls-remote", remoteName, "refs/heads/*", "refs/tags/*"]); + if (!refsResult.ok || !refsResult.stdout.trim()) return false; + + const remoteTips = uniqueSorted( + refsResult.stdout + .split(/\r?\n/) + .map((line) => line.trim().split(/\s+/)[0] ?? "") + .filter((sha) => /^[0-9a-f]{40}$/i.test(sha)), + ); + + for (const tip of remoteTips) { + if (tip === commit) return true; + const ancestorResult = runGitWithDir(gitDir, ["merge-base", "--is-ancestor", commit, tip]); + if (ancestorResult.ok) return true; + } + + return false; +} + +/** + * Check if a git status porcelain line refers to a task-plane artifact path + * that should be excluded from unsafe-submodule detection. These paths are + * expected to change during task execution and don't represent lost submodule work. + * + * Matches patterns like: + * .pi/tasks/.../STATUS.md + * .pi/tasks/.../.DONE + * .pi/orch-logs/... + * .reviewer-state.json (task review artifacts) + */ +function isArtifactStatusLine(line: string, submodulePaths: Set): boolean { + // Extract the file path from porcelain format (e.g., "M .pi/tasks/foo/STATUS.md") + const parts = line.trim().split(/[\t ]+/); + if (parts.length < 2) return false; + const filePath = parts[1]; // path starts after status codes + // Check if the file is a known task-plane artifact location + return ( + filePath.startsWith(".pi/tasks/") || + filePath.startsWith(".pi/orch-logs/") || + filePath === ".reviewer-state.json" || + filePath.endsWith("/.DONE") || + filePath.endsWith("/STATUS.md") || + filePath.endsWith("/CONTEXT.md") || + // Gitlink-only dirty state: line points to another known submodule. + // During checkpointing, the parent repo's index update bleeds into every + // shared-worktree submodule as "M " — this is an + // expected transient artifact, not real code changes inside that submodule. + submodulePaths.has(filePath) || + // Transient Python build artifacts — these are created by Python tooling + // during task execution and should not block checkpointing. + filePath.includes("__pycache__") || + filePath.includes(".pytest_cache") || + filePath.includes(".mypy_cache") || + filePath.includes("node_modules") || + filePath.includes("build/") || + filePath.includes("dist/") || + filePath.endsWith(".pyc") || + filePath.endsWith(".egg-info") + ); +} + +/** + * Filter git status porcelain output to exclude task-plane artifact paths + * and gitlink-only dirty states. + * + * Gitlink-only dirty state: when the parent repo stages a gitlink change, + * every shared-worktree submodule reports "M " as dirty. + * These are transient index-level artifacts, not actual code changes inside + * the submodule. Filter them out to avoid false positives during checkpointing. + */ +function filterArtifactStatusLines(rawOutput: string, submodulePaths: Set): string { + return rawOutput + .split(/\r?\n/) + .map((line) => line.trimEnd()) + .filter((line) => line && !isArtifactStatusLine(line, submodulePaths)) + .join("\n"); +} + +/** + * Detect submodule states that cannot be safely checkpointed in the superproject. + * + * Blocking cases: + * - submodule worktree still has uncommitted code changes (excluding task artifacts) + * - submodule HEAD differs from the recorded gitlink commit, but that HEAD is + * not reachable from the submodule's preferred remote + */ +/** + * Resolve the superproject's root .gitignore path from a submodule's cwd. + * Walks up the directory tree from cwd to find the repository root, then + * returns the path to .gitignore at that level. + * + * @param submoduleCwd - The current working directory of the submodule + * @returns Path to the superproject's root .gitignore, or null if not found + */ +function resolveSuperprojectGitignore(submoduleCwd: string): string | null { + // Use git rev-parse to find the repository root from the submodule's cwd + const result = runGit(["rev-parse", "--show-toplevel"], submoduleCwd); + if (!result.ok || !result.stdout.trim()) return null; + + const repoRoot = result.stdout.trim(); + const gitignorePath = join(repoRoot, ".gitignore"); + return existsSync(gitignorePath) ? gitignorePath : null; +} + +/** + * Check if a path matches any pattern in the given .gitignore file. + * Uses git check-ignore with the -f flag to force reading from the specified file. + * + * @param filePath - The file path to check (relative to cwd) + * @param gitignorePath - Path to the .gitignore file + * @param cwd - The current working directory + * @returns true if the path is ignored, false otherwise + */ +function checkIgnoreInFile(filePath: string, gitignorePath: string, cwd: string): boolean { + // Use git check-ignore with -f to force reading from the specified file + const result = runGit(["check-ignore", "-f", gitignorePath, "--no-index", filePath], cwd); + return result.ok && result.stdout.trim() !== ""; +} + +/** + * Filter git status porcelain output to remove paths that are ignored by .gitignore. + * Uses `git check-ignore --no-index` — respects ALL .gitignore rules at every level, + * avoiding the need to hardcode artifact patterns like __pycache__ or node_modules. + * + * Additionally checks the superproject's root .gitignore when called from a submodule, + * ensuring that patterns like __pycache__/ defined in the root are properly applied + * to paths within submodules. + */ +function filterGitIgnoredStatusLines(statusOutput: string, cwd: string): string { + // Resolve the superproject's root .gitignore once (cached for all checks) + const superprojectGitignore = resolveSuperprojectGitignore(cwd); + + return statusOutput + .split(/\r?\n/) + .filter((line) => { + if (!line.trim()) return false; + const parts = line.trimStart().split(/[\t ]+/); + if (parts.length < 2) return true; // Keep lines we can't parse + const filePath = parts[1]; + + // Skip gitignored paths — these are transient artifacts the submodule maintainers don't want tracked. + try { + const checkResult = runGit(["check-ignore", "--no-index", filePath], cwd); + if (checkResult.ok && checkResult.stdout.trim()) { + return false; // Path is ignored → skip it + } + } catch { /* fall through — keep line if check fails */ } + + // Additional check: if we have a superproject .gitignore, also check against it. + // This handles cases where patterns like __pycache__/ are defined in the root + // but not in the submodule's local .gitignore. + if (superprojectGitignore) { + if (checkIgnoreInFile(filePath, superprojectGitignore, cwd)) { + return false; // Path is ignored by superproject .gitignore → skip it + } + } + + return true; + }) + .join("\n"); +} + +export function detectUnsafeSubmoduleStates(cwd: string): UnsafeSubmoduleState[] { + const submodulePaths = uniqueSorted([ + ...listGitlinkPaths(cwd), + ...listConfiguredSubmodulePaths(cwd), + ]); + const findings: UnsafeSubmoduleState[] = []; + + for (const submodulePath of submodulePaths) { + const absolutePath = join(cwd, submodulePath); + if (!existsSync(absolutePath)) continue; + + const dirtyStatus = runGit(["status", "--porcelain"], absolutePath); + if (dirtyStatus.ok && dirtyStatus.stdout.trim()) { + // Build a set of known submodule paths for gitlink-only dirty detection. + // During checkpointing, parent repo index updates bleed into every + // shared-worktree submodule as "M " — this is an + // expected transient artifact, not actual code changes inside that submodule. + const knownSubmodulePaths = new Set(submodulePaths); + // Apply gitignore-aware filtering FIRST (respects submodule .gitignore at every level), + // then apply artifact pattern filtering as a safety net for paths not caught by gitignore. + const ignoredFiltered = filterGitIgnoredStatusLines(dirtyStatus.stdout, absolutePath); + const filteredStatus = filterArtifactStatusLines(ignoredFiltered, knownSubmodulePaths); + if (filteredStatus.trim()) { + findings.push({ + path: submodulePath, + kind: "dirty-worktree", + }); + } + continue; + } + + const headResult = runGit(["rev-parse", "HEAD"], absolutePath); + if (!headResult.ok || !headResult.stdout.trim()) continue; + const headCommit = headResult.stdout.trim(); + const indexCommit = readGitlinkCommit(cwd, submodulePath); + if (!indexCommit || indexCommit === headCommit) continue; + + const remoteName = resolvePreferredRemote(absolutePath); + if (!remoteName || !checkSubmoduleCommitReachable(absolutePath, remoteName, headCommit)) { + findings.push({ + path: submodulePath, + kind: "unpublished-commit", + headCommit, + indexCommit, + ...(remoteName ? { remoteName } : {}), + }); + } + } + + return findings.sort((left, right) => left.path.localeCompare(right.path)); +} + +/** + * Detect gitlinks in the current superproject index whose target commit is not + * reachable from the submodule's preferred remote. + * + * Used as a merge-time backstop: even if an unsafe submodule gitlink reaches the + * merge worktree via a legacy or manual path, Taskplane can still refuse to + * advance the branch to a commit that downstream clones cannot fetch. + */ +export function detectUnreachableGitlinks(cwd: string): UnreachableGitlinkState[] { + const findings: UnreachableGitlinkState[] = []; + for (const submodulePath of listGitlinkPaths(cwd)) { + const gitlinkCommit = readGitlinkCommit(cwd, submodulePath); + if (!gitlinkCommit) continue; + ensureSubmoduleCheckout(cwd, submodulePath); + const absolutePath = join(cwd, submodulePath); + + // Determine the remote to check against. + const remoteName = existsSync(absolutePath) ? resolvePreferredRemote(absolutePath) : null; + const submoduleGitDir = resolveSubmoduleGitDir(cwd, submodulePath); + const gitDirRemoteName = submoduleGitDir ? resolvePreferredRemoteFromGitDir(submoduleGitDir) : null; + const resolvedRemoteName = remoteName ?? gitDirRemoteName; + + // In merge worktrees the submodule may have stale local refs. Fetch fresh + // state from origin before checking reachability so that ls-remote results + // and merge-base object lookups can succeed. + runGit(["fetch", resolvedRemoteName, "--quiet"], absolutePath); + + const reachable = checkSubmoduleCommitReachable( + absolutePath, + resolvedRemoteName ?? "origin", + gitlinkCommit, + ); + + if (!reachable) { + findings.push({ + path: submodulePath, + gitlinkCommit, + ...(resolvedRemoteName ? { remoteName: resolvedRemoteName } : {}), + }); + } + } + return findings.sort((left, right) => left.path.localeCompare(right.path)); +} + diff --git a/extensions/taskplane/lane-runner.ts b/extensions/taskplane/lane-runner.ts index aad50553..58ca75bb 100644 --- a/extensions/taskplane/lane-runner.ts +++ b/extensions/taskplane/lane-runner.ts @@ -56,9 +56,11 @@ import { type ExecutionUnit, type RuntimeAgentId, type RuntimeLaneSnapshot, + type RuntimeLaneSubmoduleDiagnostics, type RuntimeAgentTelemetrySnapshot, type RuntimeTaskProgress, type RuntimeAgentStatus, + type RuntimeSubmoduleSnapshot, type PacketPaths, type LaneTaskOutcome, type LaneTaskStatus, @@ -66,8 +68,166 @@ import { type StepSegmentMapping, } from "./types.ts"; +import { captureSubmoduleStatusSnapshot } from "./git.ts"; +import { classifyExit, type TaskExitDiagnostic } from "./diagnostics.ts"; + const LANE_RUNNER_DIR = dirname(fileURLToPath(import.meta.url)); +export function readGitHead(cwd: string): string | null { + try { + return execSync("git rev-parse HEAD", { + cwd, + timeout: 5000, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim() || null; + } catch { + return null; + } +} + +export function detectSoftProgress( + worktreePath: string, + previousHead: string | null, +): { hasProgress: boolean; reason: string | null } { + const currentHead = readGitHead(worktreePath); + if (previousHead && currentHead && previousHead !== currentHead) { + return { + hasProgress: true, + reason: `HEAD advanced from ${previousHead.slice(0, 8)} to ${currentHead.slice(0, 8)}`, + }; + } + + try { + const diffOutput = execSync("git diff --stat HEAD", { + cwd: worktreePath, + timeout: 5000, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + const changedFiles = diffOutput.split("\n").filter(line => line.includes("|")); + const sourceChanges = changedFiles.filter(line => !line.includes("STATUS.md") && !line.includes(".steering")); + if (sourceChanges.length > 0) { + return { + hasProgress: true, + reason: `uncommitted worktree changes in ${sourceChanges.length} file(s)`, + }; + } + } catch { + // Treat command failures as no soft progress. + } + + return { hasProgress: false, reason: null }; +} + +export function getStatusProgressTotals(statusContent: string): { checked: number; total: number } { + const parsed = parseStatusMd(statusContent); + return { + checked: parsed.steps.reduce((sum, step) => sum + step.totalChecked, 0), + total: parsed.steps.reduce((sum, step) => sum + step.totalItems, 0), + }; +} + +function readLastKnownProgress(statusPath?: string): { lastKnownStep: number | null; lastKnownCheckbox: string | null } { + if (!statusPath || !existsSync(statusPath)) { + return { lastKnownStep: null, lastKnownCheckbox: null }; + } + + try { + const parsed = parseStatusMd(readFileSync(statusPath, "utf-8")); + const lastStep = parsed.steps[parsed.steps.length - 1] ?? null; + const lastCheckbox = lastStep?.checkboxes[lastStep.checkboxes.length - 1] ?? null; + return { + lastKnownStep: lastStep?.number ?? null, + lastKnownCheckbox: lastCheckbox?.text ?? null, + }; + } catch { + return { lastKnownStep: null, lastKnownCheckbox: null }; + } +} + +function detectUnsafeSubmoduleKind(exitReason: string): "dirty-worktree" | "unpublished-commit" | "unreachable-ref" | null { + if (/submodule_unreachable_ref/i.test(exitReason) || /unreachable gitlink refs/i.test(exitReason)) { + return "unreachable-ref"; + } + if (/Unsafe submodule state after task success:/i.test(exitReason)) { + if (/has uncommitted submodule changes/i.test(exitReason)) { + return "dirty-worktree"; + } + if (/points to local commit .* not reachable on /i.test(exitReason)) { + return "unpublished-commit"; + } + } + return null; +} + +export function buildLaneExitDiagnostic( + status: LaneTaskStatus, + exitReason: string, + doneFileFound: boolean, + finalTelemetry?: Partial, + statusPath?: string, + repoId = "default", +): TaskExitDiagnostic | undefined { + if (status === "skipped") return undefined; + + const stallDetected = /No progress after \d+ iterations?/i.test(exitReason); + const timerKilled = /wall-clock timeout/i.test(exitReason); + const contextKilled = /context limit/i.test(exitReason); + const userKilled = /paused by user|aborted by user|killed by user/i.test(exitReason); + const unsafeSubmoduleKind = detectUnsafeSubmoduleKind(exitReason); + const telemetryExitCode = finalTelemetry?.exitCode ?? null; + const syntheticExitCode = stallDetected + ? 0 + : (telemetryExitCode ?? (status === "failed" ? 1 : 0)); + const syntheticSummary = { + exitCode: syntheticExitCode, + exitSignal: finalTelemetry?.signal ?? null, + tokens: { + input: finalTelemetry?.inputTokens ?? 0, + output: finalTelemetry?.outputTokens ?? 0, + cacheRead: finalTelemetry?.cacheReadTokens ?? 0, + cacheWrite: finalTelemetry?.cacheWriteTokens ?? 0, + }, + cost: finalTelemetry?.costUsd ?? 0, + toolCalls: finalTelemetry?.toolCalls ?? 0, + retries: [], + compactions: finalTelemetry?.compactions ?? 0, + durationSec: Math.max(0, Math.round((finalTelemetry?.durationMs ?? 0) / 1000)), + lastToolCall: finalTelemetry?.lastTool ?? null, + error: status === "failed" + ? (finalTelemetry?.error ?? exitReason) + : (finalTelemetry?.error ?? null), + }; + const classification = classifyExit({ + exitSummary: syntheticSummary, + doneFileFound: doneFileFound || status === "succeeded", + timerKilled, + contextKilled, + unsafeSubmoduleKind, + stallDetected, + userKilled, + contextPct: finalTelemetry?.contextUsage?.percent ?? null, + }); + const { lastKnownStep, lastKnownCheckbox } = readLastKnownProgress(statusPath); + + return { + classification, + exitCode: syntheticExitCode, + errorMessage: status === "failed" + ? (finalTelemetry?.error ?? exitReason) + : (finalTelemetry?.error ?? null), + tokensUsed: syntheticSummary.tokens, + contextPct: finalTelemetry?.contextUsage?.percent ?? null, + partialProgressCommits: 0, + partialProgressBranch: null, + durationSec: syntheticSummary.durationSec, + lastKnownStep, + lastKnownCheckbox, + repoId, + }; +} + // ── Segment Scoping Helpers (Phase A, TP-174) ──────────────────────── /** @@ -114,13 +274,13 @@ export function getSegmentCheckboxes( const text = statusContent.replace(/\r\n/g, "\n"); // Find the step section - const stepHeaderPattern = new RegExp(`^###\\s+Step\\s+${stepNumber}:`, "m"); + const stepHeaderPattern = new RegExp(`^#{2,6}\\s+Step\\s+${stepNumber}(?::\\s*|\\s+)`, "m"); const stepMatch = text.match(stepHeaderPattern); if (!stepMatch || stepMatch.index === undefined) return null; // Find the end of this step section (next ### or end of file) const afterStep = text.slice(stepMatch.index + stepMatch[0].length); - const nextStepMatch = afterStep.search(/^###\s+Step\s+\d+:/m); + const nextStepMatch = afterStep.search(/^#{2,6}\s+Step\s+\d+(?::\s*|\s+)/m); const stepContent = nextStepMatch !== -1 ? afterStep.slice(0, nextStepMatch) : afterStep; // Find the segment header within this step @@ -130,7 +290,7 @@ export function getSegmentCheckboxes( // Extract content from segment header to next #### header or ### header or --- const afterSeg = stepContent.slice(segMatch.index + segMatch[0].length); - const nextSectionMatch = afterSeg.search(/^(?:####\s|###\s|---)/m); + const nextSectionMatch = afterSeg.search(/^(?:####\s|###\s|##\s|---)/m); const segContent = nextSectionMatch !== -1 ? afterSeg.slice(0, nextSectionMatch) : afterSeg; // Count checkboxes @@ -191,6 +351,8 @@ export interface LaneRunnerConfig { branch: string; /** Repo ID */ repoId: string; + /** Repo ID -> absolute path map for all repos participating in the task. */ + repoPaths?: Record; /** State root for runtime artifacts (workspace root or repo root) */ stateRoot: string; /** Worker model (empty string = inherit from session) */ @@ -295,6 +457,9 @@ export async function executeTaskV2( const taskId = unit.taskId; const segmentId = unit.segmentId; const workerAgentId = buildRuntimeAgentId(config.agentIdPrefix, config.laneNumber, "worker"); + const submoduleDiagnostics: RuntimeLaneSubmoduleDiagnostics = { + preTask: captureTaskSubmoduleSnapshot(taskId, "pre-task", config.worktreePath), + }; // ── 1. Ensure STATUS.md exists ────────────────────────────────── if (!existsSync(statusPath)) { @@ -340,11 +505,13 @@ export async function executeTaskV2( })() : null; + emitSnapshot(config, taskId, segmentId, "running", {}, statusPath, reviewerStatePath, snapshotSegmentCtx, submoduleDiagnostics); + for (let iter = 0; iter < config.maxIterations; iter++) { if (pauseSignal.paused) { logExecution(statusPath, "Paused", `User paused at iteration ${totalIterations}`); return makeResult(taskId, segmentId, workerAgentId, "skipped", startTime, - "Paused by user", false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, undefined, snapshotSegmentCtx); + "Paused by user", false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, undefined, submoduleDiagnostics, snapshotSegmentCtx); } // Determine remaining steps @@ -403,6 +570,7 @@ export async function executeTaskV2( } else { prevTotalChecked = currentStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0); } + const prevHeadCommit = readGitHead(unit.worktreePath); // ── Build worker prompt ───────────────────────────────────── const wrapUpFile = join(taskFolder, ".task-wrap-up"); @@ -433,6 +601,11 @@ export async function executeTaskV2( ? [`- Active segment ID: ${segmentId}`] : []), ``, + `Task repo map:`, + ...Object.entries(config.repoPaths ?? unit.repoPaths) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([repoId, repoPath]) => `- ${repoId}: ${repoPath}`), + ``, `Packet home context:`, `- Packet home repo ID: ${unit.packetHomeRepoId}`, `- Packet task folder: ${taskFolder}`, @@ -597,6 +770,7 @@ export async function executeTaskV2( TASKPLANE_REVIEWER_STATE_PATH: reviewerStatePath, TASKPLANE_PROJECT_NAME: config.projectName || "project", TASKPLANE_TASK_ID: taskId, + TASKPLANE_REPO_PATHS: JSON.stringify(config.repoPaths ?? unit.repoPaths), // Hard-set segment env vars based on mode. In FULL_TASK mode, // explicitly clear them to prevent env inheritance leaking segment cues. TASKPLANE_ACTIVE_SEGMENT_ID: isSegmentScoped ? (segmentId ?? "") : "", @@ -627,13 +801,16 @@ export async function executeTaskV2( const segCbs = getSegmentCheckboxes(statusContent, firstStep.number, currentRepoId); midTotalChecked = segCbs ? segCbs.checked : 0; } else { - const midStatus = parseStatusMd(statusContent); - midTotalChecked = midStatus.steps.reduce((sum, s) => sum + s.totalChecked, 0); + midTotalChecked = getStatusProgressTotals(statusContent).checked; } if (midTotalChecked > prevTotalChecked) { // Worker checked off checkboxes — let it exit normally return null; } + const softProgress = detectSoftProgress(unit.worktreePath, prevHeadCommit); + if (softProgress.hasProgress) { + return null; + } // Check for blocker entries: extract Blockers section and see if non-empty const blockerMatch = statusContent.match(/## Blockers\s*\n([\s\S]*?)(?:\n---|-$)/i); if (blockerMatch) { @@ -644,6 +821,27 @@ export async function executeTaskV2( return null; } } + // Check if task is fully complete — all checkboxes checked across all steps. + // This handles tasks already completed by prior batch runs where no new + // checkboxes need to be marked, but the worker legitimately finished. + let totalChecked: number; + if (repoStepNumbers && currentRepoId) { + const segCbs = getSegmentCheckboxes(statusContent, firstStep.number, currentRepoId); + totalChecked = segCbs ? segCbs.checked : 0; + } else { + totalChecked = getStatusProgressTotals(statusContent).checked; + } + let totalSteps: number; + if (repoStepNumbers && currentRepoId) { + const segCbs = getSegmentCheckboxes(statusContent, firstStep.number, currentRepoId); + totalSteps = segCbs ? segCbs.total : 0; + } else { + totalSteps = getStatusProgressTotals(statusContent).total; + } + if (totalSteps > 0 && totalChecked >= totalSteps) { + // All task items are checked — worker completed an already-done or just-completed task. + return null; + } } catch { /* If we can't read STATUS.md, proceed with escalation */ } // No visible progress — compose escalation message @@ -782,7 +980,7 @@ export async function executeTaskV2( iterationTelemetry = telemetry; lastTelemetry = telemetry; // Emit lane snapshot - emitSnapshot(config, taskId, segmentId, "running", telemetry, statusPath, reviewerStatePath, snapshotSegmentCtx); + emitSnapshot(config, taskId, segmentId, "running", telemetry, statusPath, reviewerStatePath, snapshotSegmentCtx, submoduleDiagnostics); } catch { /* non-fatal: telemetry callback must never crash the engine */ } }); @@ -792,7 +990,7 @@ export async function executeTaskV2( let reviewerSnapshotFailures = 0; const reviewerRefreshFailureThreshold = 5; const reviewerRefresh = setInterval(() => { - const ok = emitSnapshot(config, taskId, segmentId, "running", iterationTelemetry, statusPath, reviewerStatePath, snapshotSegmentCtx); + const ok = emitSnapshot(config, taskId, segmentId, "running", iterationTelemetry, statusPath, reviewerStatePath, snapshotSegmentCtx, submoduleDiagnostics); if (ok) { reviewerSnapshotFailures = 0; return; @@ -925,29 +1123,13 @@ export async function executeTaskV2( const progressDelta = afterTotalChecked - prevTotalChecked; if (progressDelta <= 0) { - // Check for soft progress: uncommitted changes in the worktree - // indicate the worker is actively editing code even if no checkbox - // was checked yet. This avoids false stall detection on complex - // steps where analysis + editing spans multiple tool calls. - let hasSoftProgress = false; - try { - const diffOutput = execSync("git diff --stat HEAD", { - cwd: unit.worktreePath, - timeout: 5000, - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }).trim(); - // Only count source file changes as soft progress, not just STATUS.md - const changedFiles = diffOutput.split("\n").filter(l => l.includes("|")); - const sourceChanges = changedFiles.filter(l => !l.includes("STATUS.md") && !l.includes(".steering")); - hasSoftProgress = sourceChanges.length > 0; - } catch { /* git not available or timeout — treat as no soft progress */ } - - if (hasSoftProgress) { + const softProgress = detectSoftProgress(unit.worktreePath, prevHeadCommit); + + if (softProgress.hasProgress) { // Worker has uncommitted code changes — don't count toward stall. // Reset the counter since the worker is actively editing. logExecution(statusPath, "Soft progress", - `Iteration ${totalIterations}: 0 new checkboxes but uncommitted source changes detected — not counting as stall`); + `Iteration ${totalIterations}: 0 new checkboxes but ${softProgress.reason ?? "durable progress detected"} — not counting as stall`); noProgressCount = 0; } else { noProgressCount++; @@ -955,8 +1137,9 @@ export async function executeTaskV2( `Iteration ${totalIterations}: 0 new checkboxes (${noProgressCount}/${config.noProgressLimit} stall limit)`); if (noProgressCount >= config.noProgressLimit) { logExecution(statusPath, "Task blocked", `No progress after ${noProgressCount} iterations`); + submoduleDiagnostics.postTask = captureTaskSubmoduleSnapshot(taskId, "post-task", config.worktreePath); return makeResult(taskId, segmentId, workerAgentId, "failed", startTime, - `No progress after ${noProgressCount} iterations`, false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx); + `No progress after ${noProgressCount} iterations`, false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, submoduleDiagnostics, snapshotSegmentCtx); } } } else { @@ -1048,9 +1231,10 @@ export async function executeTaskV2( .join(", "); } logExecution(statusPath, "Task incomplete", `Max iterations reached. Incomplete: ${incomplete}`); + submoduleDiagnostics.postTask = captureTaskSubmoduleSnapshot(taskId, "post-task", config.worktreePath); return makeResult(taskId, segmentId, workerAgentId, "failed", startTime, `Max iterations (${config.maxIterations}) reached with incomplete steps: ${incomplete}`, - false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx); + false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, submoduleDiagnostics, snapshotSegmentCtx); } // TP-145: Determine if this is a non-final segment of a multi-segment task. @@ -1092,8 +1276,9 @@ export async function executeTaskV2( const suppressionReason = isNonFinalSegment ? "non-final" : "pending expansion requests"; + submoduleDiagnostics.postTask = captureTaskSubmoduleSnapshot(taskId, "post-task", config.worktreePath); return makeResult(taskId, segmentId, workerAgentId, "succeeded", startTime, - `Segment completed (${suppressionReason} — .DONE suppressed)`, false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx); + `Segment completed (${suppressionReason} — .DONE suppressed)`, false, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, submoduleDiagnostics, snapshotSegmentCtx); } // Create .DONE if not already present (final segment or single-segment/whole-task execution) @@ -1102,9 +1287,10 @@ export async function executeTaskV2( } updateStatusField(statusPath, "Status", "✅ Complete"); logExecution(statusPath, "Task complete", ".DONE created"); + submoduleDiagnostics.postTask = captureTaskSubmoduleSnapshot(taskId, "post-task", config.worktreePath); return makeResult(taskId, segmentId, workerAgentId, "succeeded", startTime, - ".DONE file created by lane-runner", true, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, snapshotSegmentCtx); + ".DONE file created by lane-runner", true, totalIterations, cumulativeCostUsd, cumulativeTokens, config, statusPath, reviewerStatePath, lastTelemetry, submoduleDiagnostics, snapshotSegmentCtx); } // ── Helpers ────────────────────────────────────────────────────────── @@ -1165,6 +1351,7 @@ function makeResult( statusPath?: string, reviewerStatePath?: string, finalTelemetry?: Partial, + submoduleDiagnostics?: RuntimeLaneSubmoduleDiagnostics, /** TP-174: Segment context for segment-scoped snapshot progress */ segmentCtx?: { stepSegmentMap: StepSegmentMapping[]; repoId: string } | null, ): LaneRunnerTaskResult { @@ -1192,6 +1379,14 @@ function makeResult( doneFileFound, laneNumber: config?.laneNumber, telemetry, + exitDiagnostic: buildLaneExitDiagnostic( + status, + exitReason, + doneFileFound, + finalTelemetry, + statusPath, + config?.repoId ?? "default", + ), }, iterations, costUsd, @@ -1201,7 +1396,7 @@ function makeResult( // TP-115: Emit terminal snapshot with real telemetry from agent-host result if (config && statusPath && reviewerStatePath) { const terminalStatus = mapLaneTaskStatusToTerminalSnapshotStatus(status); - emitSnapshot(config, taskId, segmentId, terminalStatus, finalTelemetry ?? {}, statusPath, reviewerStatePath, segmentCtx); + emitSnapshot(config, taskId, segmentId, terminalStatus, finalTelemetry ?? {}, statusPath, reviewerStatePath, segmentCtx, submoduleDiagnostics); } return result; @@ -1280,6 +1475,7 @@ function emitSnapshot( reviewerStatePath: string, /** TP-174: Optional segment context for segment-scoped progress reporting */ segmentContext?: { stepSegmentMap: StepSegmentMapping[]; repoId: string } | null, + submoduleDiagnostics?: RuntimeLaneSubmoduleDiagnostics, ): boolean { try { // Parse progress from STATUS.md @@ -1346,6 +1542,7 @@ function emitSnapshot( }, reviewer: reviewerSnapshot, progress, + submoduleDiagnostics, updatedAt: Date.now(), }; @@ -1358,3 +1555,20 @@ function emitSnapshot( } } +function captureTaskSubmoduleSnapshot( + taskId: string, + phase: "pre-task" | "post-task", + worktreePath: string, +): RuntimeSubmoduleSnapshot { + const snapshot = captureSubmoduleStatusSnapshot(worktreePath); + return { + taskId, + phase, + capturedAt: snapshot.capturedAt, + worktreePath: snapshot.worktreePath, + totalSubmodules: snapshot.totalSubmodules, + dirtySubmodules: snapshot.dirtySubmodules, + entries: snapshot.entries, + }; +} + diff --git a/extensions/taskplane/merge.ts b/extensions/taskplane/merge.ts index 471c47bd..13bbbcc6 100644 --- a/extensions/taskplane/merge.ts +++ b/extensions/taskplane/merge.ts @@ -5,6 +5,7 @@ import { readFileSync, writeFileSync, existsSync, unlinkSync, copyFileSync, mkdirSync, rmSync, readdirSync } from "fs"; import { readFile as fsReadFile } from "fs/promises"; import { execSync, spawnSync } from "child_process"; +import { randomUUID } from "crypto"; import { join, dirname, resolve, relative } from "path"; import { execLog, isV2AgentAlive, setV2LivenessRegistryCache } from "./execution.ts"; @@ -14,7 +15,7 @@ import type { AllocatedLane, LaneExecutionResult, MergeLaneResult, MergeResult, import { resolveBaseBranch, resolveRepoRoot } from "./waves.ts"; import { readManifest, writeManifest, buildRegistrySnapshot, writeRegistrySnapshot, readRegistrySnapshot, writeMergeSnapshot } from "./process-registry.ts"; import { generateMergeWorktreePath, sleepAsync, sleepSync } from "./worktree.ts"; -import { getCurrentBranch, runGit } from "./git.ts"; +import { detectUnreachableGitlinks, getCurrentBranch, runGit } from "./git.ts"; import { ORCH_MESSAGES } from "./messages.ts"; import { emitEngineEvent } from "./persistence.ts"; import { loadOrchestratorConfig } from "./config.ts"; @@ -1154,7 +1155,7 @@ function persistTransactionRecord(record: TransactionRecord, stateRoot: string): : "default"; const verifyDir = join(stateRoot, ".pi", "verification", record.opId); mkdirSync(verifyDir, { recursive: true }); - const fileName = `txn-b${record.batchId}-repo-${repoSlug}-wave-${record.waveIndex}-lane-${record.laneNumber}.json`; + const fileName = `txn-${record.waveTransactionId}-repo-${repoSlug}-lane-${record.laneNumber}.json`; writeFileSync( join(verifyDir, fileName), JSON.stringify(record, null, 2), @@ -1404,11 +1405,14 @@ export async function mergeWave( healthMonitor?: MergeHealthMonitor | null, forceMixedOutcome?: boolean, runtimeBackend?: RuntimeBackend, + waveTransactionId?: string, + repoAttemptSequence = 0, ): Promise { const startTime = Date.now(); const sessionPrefix = config.orchestrator.sessionPrefix; const opId = resolveOperatorId(config); const targetBranch = baseBranch; + const effectiveWaveTransactionId = waveTransactionId ?? `wave-${batchId}-w${waveIndex}-${randomUUID()}`; const laneResults: MergeLaneResult[] = []; // Build lane outcome lookup for merge eligibility checks. @@ -1462,6 +1466,7 @@ export async function mergeWave( execLog("merge", `W${waveIndex}`, "no mergeable lanes (all failed or empty)"); return { waveIndex, + waveTransactionId: effectiveWaveTransactionId, status: "succeeded", // vacuous success — nothing to merge laneResults: [], failedLane: null, @@ -1500,7 +1505,7 @@ export async function mergeWave( // The merge worktree lives inside the batch container alongside lane worktrees: // {basePath}/{opId}-{batchId}/merge const tempBranch = `_merge-temp-${opId}-${batchId}`; - const mergeWorkDir = generateMergeWorktreePath(repoRoot, opId, batchId, config); + const mergeWorkDir = generateMergeWorktreePath(repoRoot, opId, batchId, config, repoId); // Clean up stale merge worktree/branch from prior failed attempt. // TP-029: Apply forceRemoveMergeWorktree fallback so stale merge worktrees @@ -1858,6 +1863,70 @@ export async function mergeWave( let txnRollbackResult: string | null = null; let txnRecoveryCommands: string[] = []; + // ── Post-merge submodule gitlink reachability validation ───── + // Defense in depth for issue #517: block branch advancement if the merged + // superproject points at submodule commits that are not reachable on the + // configured remote. This protects against legacy/manual paths that bypass + // Runtime V2 checkpoint safeguards. + if ( + failedLane === null && + (mergeResult.status === "SUCCESS" || mergeResult.status === "CONFLICT_RESOLVED") + ) { + const unreachableGitlinks = detectUnreachableGitlinks(mergeWorkDir); + if (unreachableGitlinks.length > 0) { + const summary = unreachableGitlinks + .slice(0, 3) + .map((finding) => `${finding.path}@${finding.gitlinkCommit.slice(0, 8)} on ${finding.remoteName ?? "any remote"}`) + .join(", "); + execLog("merge", sessionName, "post-merge submodule gitlink validation failed", { + count: unreachableGitlinks.length, + summary, + }); + + laneResult.error = `submodule_unreachable_ref: ${summary}`; + + if (preLaneHead) { + txnRollbackAttempted = true; + execLog("merge", sessionName, "rolling back temp branch after submodule gitlink validation failure", { + preLaneHead: preLaneHead.slice(0, 8), + }); + const resetResult = spawnSync("git", ["reset", "--hard", preLaneHead], { cwd: mergeWorkDir }); + if (resetResult.status === 0) { + txnStatus = "rolled_back"; + txnRollbackResult = "success"; + } else { + const resetErr = resetResult.stderr?.toString().trim() || "unknown error"; + laneResult.error = `submodule_unreachable_ref: rollback reset failed (${resetErr}) — temp branch may contain unreachable gitlink refs`; + blockAdvancement = true; + txnStatus = "rollback_failed"; + txnRollbackResult = `reset failed: ${resetErr}`; + txnRecoveryCommands = [ + `# Recovery: manually reset merge worktree to pre-lane HEAD`, + `cd "${mergeWorkDir}"`, + `git reset --hard ${preLaneHead}`, + `# Then inspect submodule refs before re-running merge`, + ]; + rollbackFailed = true; + } + } else { + blockAdvancement = true; + txnStatus = "rollback_failed"; + txnRollbackAttempted = false; + txnRollbackResult = "no baseHEAD captured — rollback impossible"; + txnRecoveryCommands = [ + `# Recovery: no baseHEAD was captured for rollback`, + `cd "${mergeWorkDir}"`, + `git log --oneline -5`, + `# Determine the correct pre-merge commit and reset manually`, + ]; + rollbackFailed = true; + } + + failedLane = lane.laneNumber; + failureReason = `Post-merge submodule gitlink validation failed in lane ${lane.laneNumber}: ${summary}`; + } + } + // ── Orchestrator-side post-merge verification (TP-032) ────── // After a successful merge (SUCCESS/CONFLICT_RESOLVED), capture // post-merge fingerprints and diff against baseline. New failures @@ -1982,7 +2051,9 @@ export async function mergeWave( const txnRecord: TransactionRecord = { opId, batchId, + waveTransactionId: effectiveWaveTransactionId, waveIndex, + repoAttemptSequence, laneNumber: lane.laneNumber, repoId: repoId ?? null, baseHEAD, @@ -2033,7 +2104,9 @@ export async function mergeWave( const errorTxnRecord: TransactionRecord = { opId, batchId, + waveTransactionId: effectiveWaveTransactionId, waveIndex, + repoAttemptSequence, laneNumber: lane.laneNumber, repoId: repoId ?? null, baseHEAD, @@ -2363,6 +2436,7 @@ export async function mergeWave( const result: MergeWaveResult = { waveIndex, + waveTransactionId: effectiveWaveTransactionId, status, laneResults, failedLane, @@ -2418,6 +2492,123 @@ export function groupLanesByRepo( })); } +function readBranchHead(repoRoot: string, branch: string): string | null { + const headResult = spawnSync("git", ["rev-parse", `refs/heads/${branch}`], { + cwd: repoRoot, + encoding: "utf-8", + }); + if (headResult.status !== 0) { + return null; + } + return headResult.stdout.trim(); +} + +type RepoAtomicRollbackResult = { + ok: boolean; + error: string | null; + recoveryCommands: string[]; +}; + +function rollbackRepoBranchToHead( + repoRoot: string, + targetBranch: string, + restoreHead: string, + waveIndex: number, + repoId: string | undefined, +): RepoAtomicRollbackResult { + const repoLabel = repoId ?? "default"; + const currentHead = readBranchHead(repoRoot, targetBranch); + const checkedOutBranch = getCurrentBranch(repoRoot); + const targetIsCheckedOut = checkedOutBranch === targetBranch; + + if (targetIsCheckedOut) { + const resetResult = spawnSync("git", ["reset", "--hard", restoreHead], { + cwd: repoRoot, + encoding: "utf-8", + }); + if (resetResult.status === 0) { + execLog("merge", `W${waveIndex}`, `cross-repo atomic rollback restored checked-out branch ${targetBranch}`, { + repoId: repoLabel, + restoreHead: restoreHead.slice(0, 8), + }); + return { ok: true, error: null, recoveryCommands: [] }; + } + + const err = resetResult.stderr?.toString().trim() + || resetResult.stdout?.toString().trim() + || "unknown error"; + return { + ok: false, + error: `failed to reset checked-out branch ${targetBranch}: ${err}`, + recoveryCommands: [ + `cd "${repoRoot}"`, + `git checkout ${targetBranch}`, + `git reset --hard ${restoreHead}`, + ], + }; + } + + const updateRefArgs = currentHead + ? ["update-ref", `refs/heads/${targetBranch}`, restoreHead, currentHead] + : ["update-ref", `refs/heads/${targetBranch}`, restoreHead]; + const updateRefResult = spawnSync("git", updateRefArgs, { + cwd: repoRoot, + encoding: "utf-8", + }); + if (updateRefResult.status === 0) { + execLog("merge", `W${waveIndex}`, `cross-repo atomic rollback restored ${targetBranch}`, { + repoId: repoLabel, + restoreHead: restoreHead.slice(0, 8), + }); + return { ok: true, error: null, recoveryCommands: [] }; + } + + const err = updateRefResult.stderr?.toString().trim() + || updateRefResult.stdout?.toString().trim() + || "unknown error"; + return { + ok: false, + error: `failed to restore ${targetBranch} to ${restoreHead.slice(0, 8)}: ${err}`, + recoveryCommands: [ + `cd "${repoRoot}"`, + currentHead + ? `git update-ref refs/heads/${targetBranch} ${restoreHead} ${currentHead}` + : `git update-ref refs/heads/${targetBranch} ${restoreHead}`, + ], + }; +} + +function rewriteCommittedTransactionsAfterAtomicRollback( + transactionRecords: TransactionRecord[], + repoId: string | undefined, + rollbackSucceeded: boolean, + rollbackDetail: string, + recoveryCommands: string[], + stateRoot: string, +): string[] { + const persistErrors: string[] = []; + const recordRepoId = repoId ?? null; + + for (const record of transactionRecords) { + if (record.repoId !== recordRepoId || record.status !== "committed") { + continue; + } + + record.status = rollbackSucceeded ? "rolled_back" : "rollback_failed"; + record.rollbackAttempted = true; + record.rollbackResult = rollbackDetail; + record.recoveryCommands = rollbackSucceeded ? [] : recoveryCommands; + record.completedAt = new Date().toISOString(); + + const persistError = persistTransactionRecord(record, stateRoot); + if (persistError) { + persistErrors.push(persistError); + } + } + + return persistErrors; +} + /** * Merge a wave's lanes partitioned by repository. * @@ -2437,9 +2628,10 @@ export function groupLanesByRepo( * Failure semantics: * - A failure in one repo does NOT stop merging in other repos. * - The aggregate status is "succeeded" only if all repos succeeded. - * - If any repo failed and any succeeded, status is "partial". - * - `repoResults` field carries per-repo attribution for downstream - * reporting (Step 1 will use this for explicit partial-success summaries). + * - In multi-repo waves, any repo failure triggers cross-repo atomic rollback + * for already-advanced repo refs and the aggregate status becomes "failed". + * - `repoResults` field still carries per-repo attribution for downstream + * reporting and recovery guidance. * * @param completedLanes - Lanes that completed execution (from wave result) * @param waveResult - The wave execution result (for lane status filtering) @@ -2468,6 +2660,7 @@ export async function mergeWaveByRepo( runtimeBackend?: RuntimeBackend, ): Promise { const startTime = Date.now(); + const waveTransactionId = `wave-${batchId}-w${waveIndex}-${randomUUID()}`; // Build lane outcome lookup for merge eligibility (same logic as mergeWave). const laneOutcomeByNumber = new Map(); @@ -2509,6 +2702,7 @@ export async function mergeWaveByRepo( execLog("merge", `W${waveIndex}`, "no mergeable lanes (all failed or empty)"); return { waveIndex, + waveTransactionId, status: "succeeded", laneResults: [], failedLane: null, @@ -2544,6 +2738,8 @@ export async function mergeWaveByRepo( healthMonitor, forceMixedOutcome, runtimeBackend, + waveTransactionId, + 0, ); // Attach empty repoResults for consistent shape return { ...result, repoResults: [] }; @@ -2553,6 +2749,14 @@ export async function mergeWaveByRepo( const allLaneResults: MergeLaneResult[] = []; const repoOutcomes: RepoMergeOutcome[] = []; const allTransactionRecords: TransactionRecord[] = []; + type RepoMergeContext = { + repoId: string | undefined; + repoRoot: string; + targetBranch: string; + initialTargetHead: string | null; + outcome: RepoMergeOutcome; + }; + const repoContexts: RepoMergeContext[] = []; // TP-033 R004-2: Accumulate persistence errors across all repo groups const allPersistenceErrors: string[] = []; let firstFailedLane: number | null = null; @@ -2565,17 +2769,19 @@ export async function mergeWaveByRepo( // TP-033: Track rollback failures across all repo groups let anyRollbackFailed = false; - for (const group of repoGroups) { + for (const [groupIndex, group] of repoGroups.entries()) { const groupRepoRoot = resolveRepoRoot(group.repoId, repoRoot, workspaceConfig); // In workspace mode with orch branch, always merge into the orch branch // (passed as baseBranch from engine.ts). Do NOT use resolveBaseBranch() // which returns the repo's current branch (e.g., develop), bypassing // the orch branch model entirely. const groupBaseBranch = baseBranch; + const groupInitialTargetHead = readBranchHead(groupRepoRoot, groupBaseBranch); execLog("merge", `W${waveIndex}`, `merging repo group: ${group.repoId ?? "(default)"}`, { repoRoot: groupRepoRoot, baseBranch: groupBaseBranch, + initialTargetHead: groupInitialTargetHead?.slice(0, 8) ?? "unknown", laneCount: group.lanes.length, lanes: group.lanes.map(l => l.laneNumber).join(","), }); @@ -2609,6 +2815,8 @@ export async function mergeWaveByRepo( healthMonitor, forceMixedOutcome, runtimeBackend, + waveTransactionId, + groupIndex, ); // Accumulate lane results @@ -2635,6 +2843,13 @@ export async function mergeWaveByRepo( failureReason: groupResult.failureReason, }; repoOutcomes.push(repoOutcome); + repoContexts.push({ + repoId: group.repoId, + repoRoot: groupRepoRoot, + targetBranch: groupBaseBranch, + initialTargetHead: groupInitialTargetHead, + outcome: repoOutcome, + }); // Track failures across repos (but continue to merge other repos). // Check groupResult.status (not just failedLane) to catch setup failures @@ -2666,6 +2881,94 @@ export async function mergeWaveByRepo( } } + if (!anyRollbackFailed && anyRepoFailed && repoContexts.length > 1) { + const rollbackContexts = repoContexts.filter(context => { + if (context.outcome.status !== "succeeded" && context.outcome.status !== "partial") { + return false; + } + const currentTargetHead = readBranchHead(context.repoRoot, context.targetBranch); + if (!context.initialTargetHead || !currentTargetHead) { + return true; + } + return currentTargetHead !== context.initialTargetHead; + }); + + if (rollbackContexts.length > 0) { + execLog("merge", `W${waveIndex}`, `cross-repo atomic merge failure detected — rolling back ${rollbackContexts.length} repo group(s)`, { + repos: rollbackContexts.map(context => context.repoId ?? "(default)").join(", "), + }); + } + + const atomicRollbackFailures: string[] = []; + for (const context of rollbackContexts) { + const repoLabel = context.repoId ?? "default"; + const originalReason = context.outcome.failureReason; + + if (!context.initialTargetHead) { + const rollbackError = `no pre-merge target HEAD captured for repo ${repoLabel}`; + const recoveryCommands = [ + `cd "${context.repoRoot}"`, + `git log --oneline refs/heads/${context.targetBranch} -5`, + `# Reset refs/heads/${context.targetBranch} to the correct pre-wave commit`, + ]; + allPersistenceErrors.push(...rewriteCommittedTransactionsAfterAtomicRollback( + allTransactionRecords, + context.repoId, + false, + `cross_repo_atomic_rollback failed: ${rollbackError}`, + recoveryCommands, + stateRoot ?? context.repoRoot, + )); + context.outcome.status = "failed"; + context.outcome.failureReason = `cross_repo_atomic_rollback_failed: ${rollbackError}`; + atomicRollbackFailures.push(`[repo:${repoLabel}] ${rollbackError}`); + anyRollbackFailed = true; + continue; + } + + const rollbackResult = rollbackRepoBranchToHead( + context.repoRoot, + context.targetBranch, + context.initialTargetHead, + waveIndex, + context.repoId, + ); + allPersistenceErrors.push(...rewriteCommittedTransactionsAfterAtomicRollback( + allTransactionRecords, + context.repoId, + rollbackResult.ok, + rollbackResult.ok + ? `cross_repo_atomic_rollback to ${context.initialTargetHead.slice(0, 8)}` + : `cross_repo_atomic_rollback failed: ${rollbackResult.error}`, + rollbackResult.recoveryCommands, + stateRoot ?? context.repoRoot, + )); + + context.outcome.status = "failed"; + if (rollbackResult.ok) { + context.outcome.failureReason = originalReason + ? `cross_repo_atomic_rollback: ${originalReason}` + : "cross_repo_atomic_rollback: rolled back because another repo in the wave failed"; + } else { + context.outcome.failureReason = `cross_repo_atomic_rollback_failed: ${rollbackResult.error}`; + atomicRollbackFailures.push(`[repo:${repoLabel}] ${rollbackResult.error}`); + anyRollbackFailed = true; + } + } + + if (rollbackContexts.length > 0) { + const rollbackSummary = `Cross-repo atomic merge rolled back ${rollbackContexts.length} repo group(s).`; + firstFailureReason = firstFailureReason + ? `${firstFailureReason} ${rollbackSummary}` + : rollbackSummary; + } + if (atomicRollbackFailures.length > 0) { + firstFailureReason = firstFailureReason + ? `${firstFailureReason} Rollback failures: ${atomicRollbackFailures.join("; ")}` + : `Rollback failures: ${atomicRollbackFailures.join("; ")}`; + } + } + // TP-171: Stage artifacts for repos that have only skipped lanes but were // not included in the mergeable repoGroups. const processedRepoIds = new Set(repoGroups.map(g => g.repoId)); @@ -2678,8 +2981,9 @@ export async function mergeWaveByRepo( return outcome.tasks.some(t => t.status === "skipped"); }); // TP-171 R004: Gate artifact staging behind safe-stop — do not advance - // any branch refs when a rollback failure has been detected. - if (skippedOnlyRepoLanes.length > 0 && !anyRollbackFailed) { + // any branch refs when a rollback failure or cross-repo merge failure + // has been detected. + if (skippedOnlyRepoLanes.length > 0 && !anyRollbackFailed && !anyRepoFailed) { const skippedRepoGroups = groupLanesByRepo(skippedOnlyRepoLanes); for (const group of skippedRepoGroups) { const groupRepoRoot = resolveRepoRoot(group.repoId, repoRoot, workspaceConfig); @@ -2696,10 +3000,13 @@ export async function mergeWaveByRepo( const anyLaneSucceeded = allLaneResults.some( r => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"), ); + const strictAtomicCrossRepo = repoContexts.length > 1; let status: MergeWaveResult["status"]; if (!anyRepoFailed) { status = "succeeded"; + } else if (strictAtomicCrossRepo) { + status = "failed"; } else if (anyLaneSucceeded) { status = "partial"; } else { @@ -2717,6 +3024,7 @@ export async function mergeWaveByRepo( const aggregateResult: MergeWaveResult = { waveIndex, + waveTransactionId, status, laneResults: allLaneResults, failedLane: firstFailedLane, diff --git a/extensions/taskplane/messages.ts b/extensions/taskplane/messages.ts index 10b5a870..2b96bd45 100644 --- a/extensions/taskplane/messages.ts +++ b/extensions/taskplane/messages.ts @@ -2,7 +2,22 @@ * User-facing message templates (ORCH_MESSAGES) * @module orch/messages */ -import type { AbortMode, MergeFailureClassification, MergeRetryCallbacks, MergeRetryDecision, MergeRetryLoopOutcome, MergeRetryPolicy, MergeWaveResult, OrchestratorConfig, RepoMergeOutcome } from "./types.ts"; +import type { + AbortMode, + MergeFailureClassification, + MergeRetryCallbacks, + MergeRetryDecision, + MergeRetryLoopOutcome, + MergeRetryPolicy, + MergeWaveResult, + OrchestratorConfig, + RepoMergeOutcome, + SubmodulePolicy, + WorkspaceSyncApplyResult, + WorkspaceSyncFinding, + WorkspaceSyncPresentation, + WorkspaceSyncSummary, +} from "./types.ts"; import { MERGE_RETRY_POLICY_MATRIX } from "./types.ts"; // ── Message Templates ──────────────────────────────────────────────── @@ -143,6 +158,8 @@ export const ORCH_MESSAGES = { // /orch merge — repo-scoped partial summary (TP-005 Step 1) orchMergePartialRepoSummary: (waveNum: number, repoLines: string[]) => `⚠️ [Wave ${waveNum}] Merge partially succeeded — repo outcomes diverged:\n${repoLines.join("\n")}`, + orchMergeAtomicRepoFailureSummary: (waveNum: number, repoLines: string[]) => + `❌ [Wave ${waveNum}] Merge failed — repo changes were rolled back atomically:\n${repoLines.join("\n")}`, // /orch integration — post-batch integration guidance (TP-022 Step 4) orchIntegrationAutoSuccess: (orchBranch: string, baseBranch: string) => @@ -163,6 +180,182 @@ export const ORCH_MESSAGES = { }, } as const; +// ── Workspace Messages ────────────────────────────────────────────── + +export const WORKSPACE_MESSAGES = { + pointerNotFound: (filePath: string) => `Pointer file not found: ${filePath}. Run 'taskplane init' to create it.`, + pointerReadError: (filePath: string, message: string) => `Cannot read pointer file ${filePath}: ${message}`, + pointerInvalidJson: (filePath: string) => `Pointer file ${filePath} contains invalid JSON.`, + pointerInvalidShape: (filePath: string) => `Pointer file ${filePath} must be a JSON object.`, + pointerMissingConfigRepo: (filePath: string) => `Pointer file ${filePath} is missing required field 'config_repo'.`, + pointerMissingConfigPath: (filePath: string) => `Pointer file ${filePath} is missing required field 'config_path'.`, + pointerAbsoluteConfigPath: (filePath: string, configPath: string) => + `Pointer file ${filePath} has invalid config_path '${configPath}' (absolute paths not allowed).`, + pointerTraversalConfigPath: (filePath: string, configPath: string) => + `Pointer file ${filePath} has invalid config_path '${configPath}' (path traversal not allowed).`, + pointerUnknownConfigRepo: (filePath: string, repoId: string, availableRepos: string) => + `Pointer file ${filePath}: config_repo '${repoId}' not found in workspace repos. Available repos: ${availableRepos}`, + pointerEscapedConfigPath: (filePath: string, configPath: string) => + `Pointer file ${filePath} has invalid config_path '${configPath}' (resolved path escapes config repo root).`, + pointerWarningLog: (warning: string) => `[taskplane] pointer warning: ${warning}`, + workspaceConfigReadError: (message: string) => `Cannot read workspace config file: ${message}`, + workspaceConfigParseError: (message: string) => `Invalid YAML in workspace config: ${message}`, + workspaceConfigMustBeMapping: () => "Workspace config must be a YAML mapping (object), not a scalar or sequence.", + workspaceConfigMissingReposMapping: () => "Workspace config must contain a 'repos' mapping.", + workspaceConfigMissingRoutingMapping: () => "Workspace config must contain a 'routing' mapping.", + workspaceConfigMissingRepos: () => "Workspace config must define at least one repo under 'repos'.", + workspaceConfigInvalidRepoEntry: (repoId: string) => `Repo '${repoId}' must be a YAML mapping with at least a 'path' field.`, + workspaceConfigMissingRepoPath: (repoId: string) => `Repo '${repoId}' is missing a 'path' field.`, + workspaceConfigRepoPathNotFound: (repoId: string, absolutePath: string) => `Repo '${repoId}' path does not exist: ${absolutePath}`, + workspaceConfigRepoNotGit: (repoId: string, absolutePath: string) => `Repo '${repoId}' path is not a git repository: ${absolutePath}`, + workspaceConfigRepoNotRoot: (repoId: string, expectedRoot: string, absolutePath: string) => + `Repo '${repoId}' path is a subdirectory of a git repo, not the repo root. Expected root: ${expectedRoot}, got: ${absolutePath}`, + workspaceConfigDuplicateRepoPath: (existingRepoId: string | undefined, repoId: string, absolutePath: string) => + `Repos '${existingRepoId}' and '${repoId}' share the same path: ${absolutePath}`, + workspaceConfigMissingTasksRoot: () => "Workspace config 'routing.tasks_root' is missing or empty.", + workspaceConfigTasksRootNotFound: (tasksRoot: string) => `routing.tasks_root path does not exist: ${tasksRoot}`, + workspaceConfigMissingDefaultRepo: () => "Workspace config 'routing.default_repo' is missing or empty.", + workspaceConfigUnknownDefaultRepo: (defaultRepoId: string, availableRepos: string) => + `routing.default_repo '${defaultRepoId}' does not match any repo ID. Available repos: ${availableRepos}`, + workspaceConfigInvalidTaskPacketRepo: () => "Workspace config 'routing.task_packet_repo' must be a non-empty string when provided.", + workspaceConfigCompatibilityTaskPacketRepo: (configFile: string, defaultRepoId: string) => + `[taskplane] workspace compatibility: 'routing.task_packet_repo' is missing in ${configFile}; defaulting to routing.default_repo ('${defaultRepoId}'). Add 'routing.task_packet_repo' explicitly.`, + workspaceConfigUnknownTaskPacketRepo: (taskPacketRepoId: string, availableRepos: string) => + `routing.task_packet_repo '${taskPacketRepoId}' does not match any repo ID. Available repos: ${availableRepos}`, + workspaceConfigTasksRootOutsidePacketRepo: (tasksRoot: string, taskPacketRepoId: string, packetRepoPath: string) => + `routing.tasks_root '${tasksRoot}' must be inside packet-home repo '${taskPacketRepoId}' (${packetRepoPath}). Update routing.tasks_root or routing.task_packet_repo.`, + workspaceConfigInvalidStrict: (rawStrict: unknown) => + `routing.strict must be a boolean (true/false)${rawStrict === null ? ", got null (use true or false explicitly)" : `, got ${typeof rawStrict}: ${JSON.stringify(rawStrict)}`}`, + workspaceTaskAreaOutsideTasksRoot: (areaName: string, areaPath: string, tasksRoot: string) => + `Task area '${areaName}' path '${areaPath}' must be inside routing.tasks_root '${tasksRoot}'. Move the area under tasks_root or update task_areas.${areaName}.path.`, + workspaceSetupRequired: (configFile: string, cwd: string) => + `No workspace config found at ${configFile}, and current directory is not a git repository: ${cwd}. Run Taskplane from a git repository, or create ${configFile} (taskplane init) to use workspace mode.`, + plannerSyncCommand: (targetLabel = "") => `/orch-plan ${targetLabel} --sync`, + workspaceRepoIdPolicyMessage: (repoId: string) => + `Workspace repo ID '${repoId}' does not match the lowercase letters/digits/hyphen policy.`, + workspaceRepoIdPolicyHint: () => "Rename the repo ID to use lowercase letters, digits, and hyphens before relying on workspace routing.", + workspaceInvalidDerivedRepoIdMessage: (repoLabel: string, submodulePath: string, derivedRepoId: string) => + `${repoLabel}: submodule '${submodulePath}' is not declared in workspace.repos and basename import would derive invalid repo ID '${derivedRepoId}'.`, + workspaceInvalidDerivedRepoIdHint: (targetLabel: string | undefined, submodulePath: string) => + `Rename the submodule path or add an explicit workspace.repos entry with a valid repo ID, then rerun ${WORKSPACE_MESSAGES.plannerSyncCommand(targetLabel)}.`, + workspaceRepoIdCollisionMessage: (repoLabel: string, submodulePath: string, derivedRepoId: string, existingPath: string) => + `${repoLabel}: submodule '${submodulePath}' would reuse repo ID '${derivedRepoId}', which is already assigned to '${existingPath}'.`, + workspaceRepoIdCollisionHint: (targetLabel: string | undefined, submodulePath: string) => + `Add an explicit workspace.repos entry for '${submodulePath}' with a unique repo ID, then rerun ${WORKSPACE_MESSAGES.plannerSyncCommand(targetLabel)}.`, + workspaceMissingRepoMessage: (repoLabel: string, submodulePath: string) => + `${repoLabel}: submodule '${submodulePath}' is not declared in workspace.repos.`, + workspaceMissingRepoHint: (targetLabel: string | undefined, submodulePath: string, derivedRepoId: string) => + `Run ${WORKSPACE_MESSAGES.plannerSyncCommand(targetLabel)} to add a workspace.repos entry for '${submodulePath}' (repo ID '${derivedRepoId}').`, + workspaceUninitializedSubmoduleMessage: (repoLabel: string, submodulePath: string) => + `${repoLabel}: submodule '${submodulePath}' is not initialized.`, + workspaceDriftedSubmoduleMessage: (repoLabel: string, submodulePath: string, isConflict: boolean) => + `${repoLabel}: submodule '${submodulePath}' is ${isConflict ? "in conflict" : "drifted from the recorded gitlink commit"}.`, + workspaceRepoCollisionMessage: (derivedRepoId: string) => + `Multiple undeclared submodules would map to repo ID '${derivedRepoId}'.`, + workspaceRepoCollisionHint: ( + targetLabel: string | undefined, + candidates: Array<{ repoLabel: string; submodulePath: string | undefined }>, + ) => + `Add explicit workspace.repos entries for ${candidates.map((candidate) => `${candidate.repoLabel}:${candidate.submodulePath}`).join(", ")} instead of relying on path-basename imports, then rerun ${WORKSPACE_MESSAGES.plannerSyncCommand(targetLabel)}.`, + workspaceNoSubmoduleIssues: (trackedSubmodules: number) => `No submodule issues detected (${trackedSubmodules} tracked)`, + workspaceNoSubmodules: () => "No submodules detected", + workspaceSyncBadgeNoneLabel: () => "No submodules", + workspaceSyncBadgeNoneDetail: () => "No tracked submodules were detected when the batch started.", + workspaceSyncBadgeCleanLabel: (trackedSubmodules: number) => `${trackedSubmodules} synced`, + workspaceSyncBadgeCleanDetail: () => "Workspace repos and tracked submodules were synchronized before orchestration.", + workspaceSyncInitFailure: (repoRoot: string, detail: string) => `Failed to initialize submodules in '${repoRoot}': ${detail}`, + workspaceSyncRecursiveFailure: (repoRoot: string, detail: string) => `Failed to synchronize submodules in '${repoRoot}': ${detail}`, + workspaceSyncManualModeWarning: () => "On Submodule Drift is manual, so planner sync did not run git submodule update commands.", + workspaceSyncFailedHeadline: () => "❌ Workspace sync failed.", + workspaceSyncIncompleteHeadline: () => "❌ Workspace sync is still incomplete.", + workspaceSyncNoChangesHeadline: () => "ℹ️ Workspace sync made no changes.", + workspaceSyncAppliedHeadline: () => "✅ Workspace sync applied.", + workspaceSyncImportedReposLine: (repoIds: string[]) => ` Imported repos: ${repoIds.join(", ")}`, + workspaceSyncInitializedLine: (paths: string[]) => ` Initialized: ${paths.join(", ")}`, + workspaceSyncRealignedLine: (paths: string[]) => ` Realigned: ${paths.join(", ")}`, + workspaceSyncWarningLine: (warning: string) => ` ⚠ ${warning}`, + uninitializedSubmoduleHint: ( + policy: SubmodulePolicy, + repoPath: string, + submodulePath: string, + targetLabel?: string, + ) => { + const planner = WORKSPACE_MESSAGES.plannerSyncCommand(targetLabel); + const initCmd = `git -C "${repoPath}" submodule update --init -- "${submodulePath}"`; + const recursiveCmd = `git -C "${repoPath}" submodule update --init --recursive -- "${submodulePath}"`; + if (policy.onSubmoduleDrift === "manual") { + return `Run ${planner} after setting On Submodule Drift to init-only or recursive-on-drift, or run ${initCmd}.`; + } + if (policy.onSubmoduleDrift === "init-only") { + return `Run ${planner} to initialize it, or run ${initCmd}.`; + } + return `Run ${planner} to initialize it recursively, or run ${recursiveCmd}.`; + }, + driftedSubmoduleHint: ( + policy: SubmodulePolicy, + repoPath: string, + submodulePath: string, + targetLabel?: string, + ) => { + const planner = WORKSPACE_MESSAGES.plannerSyncCommand(targetLabel); + const updateCmd = `git -C "${repoPath}" submodule update --init --recursive -- "${submodulePath}"`; + if (policy.onSubmoduleDrift === "manual") { + return `Run ${planner} after setting On Submodule Drift to recursive-on-drift, or run ${updateCmd}.`; + } + if (policy.onSubmoduleDrift === "init-only") { + return `Configured On Submodule Drift is init-only, which does not repair drift. Switch to recursive-on-drift and rerun ${planner}, or run ${updateCmd}.`; + } + return `Run ${planner} to realign the checkout, or run ${updateCmd}.`; + }, +} as const; + +export function getBlockingWorkspaceSyncFindings(summary: WorkspaceSyncSummary | null | undefined): WorkspaceSyncFinding[] { + return (summary?.findings ?? []).filter((finding) => finding.status === "fail"); +} + +export function hasBlockingWorkspaceSyncFindings(summary: WorkspaceSyncSummary | null | undefined): boolean { + return getBlockingWorkspaceSyncFindings(summary).length > 0; +} + +export function formatWorkspaceSyncPresentation( + result: WorkspaceSyncApplyResult, + summary: WorkspaceSyncSummary | null | undefined, +): WorkspaceSyncPresentation { + const lines: string[] = []; + const executionFailed = result.warnings.length > 0; + const blockingFindingsRemain = hasBlockingWorkspaceSyncFindings(summary); + const madeChanges = result.importedRepoIds.length > 0 || result.initializedPaths.length > 0 || result.updatedPaths.length > 0; + + if (executionFailed) { + lines.push(WORKSPACE_MESSAGES.workspaceSyncFailedHeadline()); + } else if (blockingFindingsRemain) { + lines.push(WORKSPACE_MESSAGES.workspaceSyncIncompleteHeadline()); + } else if (!madeChanges) { + lines.push(WORKSPACE_MESSAGES.workspaceSyncNoChangesHeadline()); + } else { + lines.push(WORKSPACE_MESSAGES.workspaceSyncAppliedHeadline()); + } + + if (result.importedRepoIds.length > 0) { + lines.push(WORKSPACE_MESSAGES.workspaceSyncImportedReposLine(result.importedRepoIds)); + } + if (result.initializedPaths.length > 0) { + lines.push(WORKSPACE_MESSAGES.workspaceSyncInitializedLine(result.initializedPaths)); + } + if (result.updatedPaths.length > 0) { + lines.push(WORKSPACE_MESSAGES.workspaceSyncRealignedLine(result.updatedPaths)); + } + for (const warning of result.warnings) { + lines.push(WORKSPACE_MESSAGES.workspaceSyncWarningLine(warning)); + } + + return { + status: executionFailed || blockingFindingsRemain ? "failure" : "success", + notificationLevel: executionFailed || blockingFindingsRemain ? "error" : "info", + message: lines.join("\n"), + }; +} + // ── Repo-Scoped Merge Summary (TP-005) ────────────────────────────── @@ -235,6 +428,86 @@ export function formatRepoMergeSummary(mergeResult: MergeWaveResult): string | n return ORCH_MESSAGES.orchMergePartialRepoSummary(mergeResult.waveIndex, repoLines); } +function isAtomicRollbackFailureReason(reason: string | null): boolean { + return !!reason && reason.startsWith("cross_repo_atomic_rollback"); +} + +function hasRollbackFailureLaneError(mergeResult: MergeWaveResult): boolean { + return mergeResult.laneResults.some((laneResult) => { + const error = laneResult.error?.toLowerCase() ?? ""; + return error.includes("rollback reset failed") || + error.includes("no pre-lane head available for rollback") || + (error.includes("rollback") && error.includes("advancement blocked")); + }); +} + +/** + * Detect rollback safe-stop even when older persisted state omitted the + * dedicated `rollbackFailed` flag. + */ +export function mergeRequiresRollbackSafeStop(mergeResult: MergeWaveResult): boolean { + if (mergeResult.rollbackFailed) { + return true; + } + + if (mergeResult.transactionRecords?.some((record) => record.status === "rollback_failed")) { + return true; + } + + if (hasRollbackFailureLaneError(mergeResult)) { + return true; + } + + if (mergeResult.repoResults?.some((result) => result.failureReason?.startsWith("cross_repo_atomic_rollback_failed"))) { + return true; + } + + const failureReason = (mergeResult.failureReason ?? "").toLowerCase(); + return failureReason.includes("cross_repo_atomic_rollback_failed") || + failureReason.includes("rollback failures:"); +} + +/** + * Format a repo-scoped failure summary for strict atomic multi-repo merges. + * + * Returns null unless all of the following are true: + * - mergeResult.status is "failed" + * - repoResults exists with at least 2 repos + * - at least one repo failureReason indicates atomic rollback + * - the wave was not a rollback safe-stop (that has dedicated messaging) + */ +export function formatRepoAtomicFailureSummary(mergeResult: MergeWaveResult): string | null { + const repoResults = mergeResult.repoResults; + if (mergeResult.status !== "failed" || mergeRequiresRollbackSafeStop(mergeResult)) { + return null; + } + if (!repoResults || repoResults.length < 2) { + return null; + } + if (!repoResults.some(result => isAtomicRollbackFailureReason(result.failureReason))) { + return null; + } + + const repoLines = repoResults.map(result => { + const repoLabel = result.repoId ?? "(default)"; + const rolledBack = isAtomicRollbackFailureReason(result.failureReason); + const icon = rolledBack ? "↩" : "❌"; + const mergedCount = result.laneResults.filter( + lane => !lane.error && (lane.result?.status === "SUCCESS" || lane.result?.status === "CONFLICT_RESOLVED"), + ).length; + const totalCount = result.laneResults.length; + let detail = rolledBack + ? `${mergedCount}/${totalCount} lane(s) rolled back` + : `${mergedCount}/${totalCount} lane(s) attempted`; + if (result.failureReason) { + detail += ` — ${result.failureReason.slice(0, 150)}`; + } + return ` ${icon} ${repoLabel}: ${detail}`; + }); + + return ORCH_MESSAGES.orchMergeAtomicRepoFailureSummary(mergeResult.waveIndex, repoLines); +} + // ── Merge Failure Policy Application (TP-005 Step 2) ───────────────── @@ -790,7 +1063,7 @@ export async function applyMergeRetryLoop( }; } - if (currentResult.rollbackFailed) { + if (currentResult.rollbackFailed || mergeRequiresRollbackSafeStop(currentResult)) { // Safe-stop takes priority const hasPersistErrors = currentResult.persistenceErrors && currentResult.persistenceErrors.length > 0; const persistWarning = hasPersistErrors diff --git a/extensions/taskplane/persistence.ts b/extensions/taskplane/persistence.ts index 80a73b20..28d0a3eb 100644 --- a/extensions/taskplane/persistence.ts +++ b/extensions/taskplane/persistence.ts @@ -319,6 +319,12 @@ export function persistRuntimeState( if (taskRecord.resolvedRepoId === undefined && parsedTask.resolvedRepoId !== undefined) { taskRecord.resolvedRepoId = parsedTask.resolvedRepoId; } + if ((taskRecord as any).resolvedRepoIds === undefined && parsedTask.resolvedRepoIds !== undefined) { + (taskRecord as any).resolvedRepoIds = parsedTask.resolvedRepoIds; + } + if ((taskRecord as any).participatingRepoIds === undefined && parsedTask.participatingRepoIds !== undefined) { + (taskRecord as any).participatingRepoIds = parsedTask.participatingRepoIds; + } if ((taskRecord as any).packetRepoId === undefined && parsedTask.packetRepoId !== undefined) { (taskRecord as any).packetRepoId = parsedTask.packetRepoId; } @@ -331,6 +337,12 @@ export function persistRuntimeState( if ((taskRecord as any).activeSegmentId === undefined && parsedTask.activeSegmentId !== undefined) { (taskRecord as any).activeSegmentId = parsedTask.activeSegmentId; } + if ((taskRecord as any).explicitSegmentDag === undefined && parsedTask.explicitSegmentDag !== undefined) { + (taskRecord as any).explicitSegmentDag = parsedTask.explicitSegmentDag; + } + if ((taskRecord as any).stepSegmentMap === undefined && parsedTask.stepSegmentMap !== undefined) { + (taskRecord as any).stepSegmentMap = parsedTask.stepSegmentMap; + } } } const enrichedJson = JSON.stringify(parsed, null, 2); @@ -429,7 +441,7 @@ export function upconvertV2toV3(obj: Record): void { * Added fields: * - `segments`: empty array (no segment records exist in pre-v4 state) * - * Task-level segment fields (`packetRepoId`, `packetTaskPath`, + * Task-level segment fields (`participatingRepoIds`, `packetRepoId`, `packetTaskPath`, * `segmentIds`, `activeSegmentId`) are optional and default to * `undefined` (omitted from JSON). They are NOT backfilled here * because their values depend on runtime discovery, not on @@ -661,6 +673,22 @@ export function validatePersistedState(data: unknown): PersistedBatchState { `tasks[${i}].resolvedRepoId is not a string (got ${typeof t.resolvedRepoId})`, ); } + if ((t as any).resolvedRepoIds !== undefined) { + if (!Array.isArray((t as any).resolvedRepoIds)) { + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `tasks[${i}].resolvedRepoIds is not an array (got ${typeof (t as any).resolvedRepoIds})`, + ); + } + for (let j = 0; j < ((t as any).resolvedRepoIds as unknown[]).length; j++) { + if (typeof ((t as any).resolvedRepoIds as unknown[])[j] !== "string") { + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `tasks[${i}].resolvedRepoIds[${j}] is not a string`, + ); + } + } + } // TP-028 optional fields: partialProgressCommits (number | undefined), partialProgressBranch (string | undefined) if (t.partialProgressCommits !== undefined && typeof t.partialProgressCommits !== "number") { throw new StateFileError( @@ -745,6 +773,43 @@ export function validatePersistedState(data: unknown): PersistedBatchState { `lanes[${i}].laneNumber is missing or not a number`, ); } + if (l.repoWorktrees !== undefined) { + if (!l.repoWorktrees || typeof l.repoWorktrees !== "object" || Array.isArray(l.repoWorktrees)) { + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `lanes[${i}].repoWorktrees is not an object`, + ); + } + for (const [repoId, worktree] of Object.entries(l.repoWorktrees as Record)) { + if (!worktree || typeof worktree !== "object" || Array.isArray(worktree)) { + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `lanes[${i}].repoWorktrees[${repoId}] is not an object`, + ); + } + const wt = worktree as Record; + for (const field of ["path", "branch"] as const) { + if (typeof wt[field] !== "string") { + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `lanes[${i}].repoWorktrees[${repoId}].${field} is missing or not a string`, + ); + } + } + if (typeof wt.laneNumber !== "number") { + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `lanes[${i}].repoWorktrees[${repoId}].laneNumber is missing or not a number`, + ); + } + if (wt.repoId !== undefined && typeof wt.repoId !== "string") { + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `lanes[${i}].repoWorktrees[${repoId}].repoId is not a string`, + ); + } + } + } if (!Array.isArray(l.taskIds)) { throw new StateFileError( "STATE_SCHEMA_INVALID", @@ -783,12 +848,40 @@ export function validatePersistedState(data: unknown): PersistedBatchState { `mergeResults[${i}].waveIndex is missing or not a number`, ); } + if (m.waveTransactionId !== undefined && typeof m.waveTransactionId !== "string") { + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `mergeResults[${i}].waveTransactionId is not a string (got ${typeof m.waveTransactionId})`, + ); + } if (typeof m.status !== "string" || !VALID_PERSISTED_MERGE_STATUSES.has(m.status)) { throw new StateFileError( "STATE_SCHEMA_INVALID", `mergeResults[${i}].status is invalid: "${m.status}" (expected one of: ${[...VALID_PERSISTED_MERGE_STATUSES].join(", ")})`, ); } + if (m.rollbackFailed !== undefined && typeof m.rollbackFailed !== "boolean") { + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `mergeResults[${i}].rollbackFailed is not a boolean (got ${typeof m.rollbackFailed})`, + ); + } + if (m.persistenceErrors !== undefined) { + if (!Array.isArray(m.persistenceErrors)) { + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `mergeResults[${i}].persistenceErrors is not an array (got ${typeof m.persistenceErrors})`, + ); + } + for (let j = 0; j < (m.persistenceErrors as unknown[]).length; j++) { + if (typeof (m.persistenceErrors as unknown[])[j] !== "string") { + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `mergeResults[${i}].persistenceErrors[${j}] is not a string`, + ); + } + } + } // v2 optional field: repoResults (array | undefined) if (m.repoResults !== undefined) { if (!Array.isArray(m.repoResults)) { @@ -1046,6 +1139,23 @@ export function validatePersistedState(data: unknown): PersistedBatchState { `tasks[${i}].packetTaskPath is not a string (got ${typeof t.packetTaskPath})`, ); } + // v4 optional field: participatingRepoIds (string[] | undefined) + if (t.participatingRepoIds !== undefined) { + if (!Array.isArray(t.participatingRepoIds)) { + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `tasks[${i}].participatingRepoIds is not an array (got ${typeof t.participatingRepoIds})`, + ); + } + for (let j = 0; j < (t.participatingRepoIds as unknown[]).length; j++) { + if (typeof (t.participatingRepoIds as unknown[])[j] !== "string") { + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `tasks[${i}].participatingRepoIds[${j}] is not a string`, + ); + } + } + } // v4 optional field: segmentIds (string[] | undefined) if (t.segmentIds !== undefined) { if (!Array.isArray(t.segmentIds)) { @@ -1070,6 +1180,68 @@ export function validatePersistedState(data: unknown): PersistedBatchState { `tasks[${i}].activeSegmentId is not a string or null (got ${typeof t.activeSegmentId})`, ); } + // v4 optional field: explicitSegmentDag ({ repoIds: string[], edges: {fromRepoId,toRepoId}[] } | undefined) + if (t.explicitSegmentDag !== undefined) { + const dag = t.explicitSegmentDag as Record; + if (!dag || typeof dag !== "object" || !Array.isArray(dag.repoIds) || !Array.isArray(dag.edges)) { + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `tasks[${i}].explicitSegmentDag is not a valid segment DAG object`, + ); + } + for (let j = 0; j < (dag.repoIds as unknown[]).length; j++) { + if (typeof (dag.repoIds as unknown[])[j] !== "string") { + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `tasks[${i}].explicitSegmentDag.repoIds[${j}] is not a string`, + ); + } + } + for (let j = 0; j < (dag.edges as unknown[]).length; j++) { + const edge = (dag.edges as unknown[])[j] as Record; + if (!edge || typeof edge !== "object" || typeof edge.fromRepoId !== "string" || typeof edge.toRepoId !== "string") { + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `tasks[${i}].explicitSegmentDag.edges[${j}] is not a valid edge`, + ); + } + } + } + // v4 optional field: stepSegmentMap (StepSegmentMapping[] | undefined) + if (t.stepSegmentMap !== undefined) { + if (!Array.isArray(t.stepSegmentMap)) { + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `tasks[${i}].stepSegmentMap is not an array (got ${typeof t.stepSegmentMap})`, + ); + } + for (let j = 0; j < (t.stepSegmentMap as unknown[]).length; j++) { + const step = (t.stepSegmentMap as unknown[])[j] as Record; + if (!step || typeof step !== "object" || typeof step.stepNumber !== "number" || typeof step.stepName !== "string" || !Array.isArray(step.segments)) { + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `tasks[${i}].stepSegmentMap[${j}] is not a valid step-segment mapping`, + ); + } + for (let k = 0; k < (step.segments as unknown[]).length; k++) { + const seg = (step.segments as unknown[])[k] as Record; + if (!seg || typeof seg !== "object" || typeof seg.repoId !== "string" || !Array.isArray(seg.checkboxes)) { + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `tasks[${i}].stepSegmentMap[${j}].segments[${k}] is not a valid checkbox group`, + ); + } + for (let m = 0; m < (seg.checkboxes as unknown[]).length; m++) { + if (typeof (seg.checkboxes as unknown[])[m] !== "string") { + throw new StateFileError( + "STATE_SCHEMA_INVALID", + `tasks[${i}].stepSegmentMap[${j}].segments[${k}].checkboxes[${m}] is not a string`, + ); + } + } + } + } + } } // ── Validate v4 segments array ─────────────────────────────── @@ -1274,6 +1446,12 @@ export function serializeBatchState( if (allocated?.allocatedTask.task?.resolvedRepoId !== undefined) { record.resolvedRepoId = allocated.allocatedTask.task.resolvedRepoId; } + if ((allocated?.allocatedTask.task as any)?.resolvedRepoIds !== undefined) { + (record as any).resolvedRepoIds = (allocated!.allocatedTask.task as any).resolvedRepoIds; + } + if (allocated?.allocatedTask.task?.participatingRepoIds !== undefined) { + (record as any).participatingRepoIds = allocated.allocatedTask.task.participatingRepoIds; + } // TP-028: Serialize partial progress fields from task outcome if (outcome?.partialProgressCommits !== undefined) { @@ -1301,6 +1479,12 @@ export function serializeBatchState( if (allocated?.allocatedTask.task?.activeSegmentId !== undefined) { (record as any).activeSegmentId = allocated.allocatedTask.task.activeSegmentId; } + if (allocated?.allocatedTask.task?.explicitSegmentDag !== undefined) { + (record as any).explicitSegmentDag = allocated.allocatedTask.task.explicitSegmentDag; + } + if (allocated?.allocatedTask.task?.stepSegmentMap !== undefined) { + (record as any).stepSegmentMap = allocated.allocatedTask.task.stepSegmentMap; + } return record; }); @@ -1315,6 +1499,9 @@ export function serializeBatchState( branch: lane.branch, taskIds: lane.tasks.map((t) => t.taskId), }; + if (lane.repoWorktrees !== undefined) { + record.repoWorktrees = lane.repoWorktrees; + } if (lane.repoId !== undefined) { record.repoId = lane.repoId; } @@ -1334,6 +1521,15 @@ export function serializeBatchState( failedLane: mr.failedLane, failureReason: mr.failureReason, }; + if (typeof mr.waveTransactionId === "string" && mr.waveTransactionId.length > 0) { + record.waveTransactionId = mr.waveTransactionId; + } + if (mr.rollbackFailed) { + record.rollbackFailed = true; + } + if (mr.persistenceErrors && mr.persistenceErrors.length > 0) { + record.persistenceErrors = [...mr.persistenceErrors]; + } // v2 (TP-009): Serialize per-repo merge outcomes when available (workspace mode). if (mr.repoResults && mr.repoResults.length > 0) { record.repoResults = mr.repoResults.map((rr) => ({ @@ -1379,13 +1575,14 @@ export function serializeBatchState( resilience: state.resilience ?? defaultResilienceState(), diagnostics: state.diagnostics ?? defaultBatchDiagnostics(), segments: state.segments ?? [], + ...(state.workspaceSyncStatus ? { workspaceSyncStatus: state.workspaceSyncStatus } : {}), }; // Merge unknown fields from loaded state to preserve roundtrip fidelity. // Extra fields are placed at the end of the object (after known schema fields) // and will not overwrite any known field. if (state._extraFields) { - const output = persisted as Record; + const output = persisted as unknown as Record; for (const [key, value] of Object.entries(state._extraFields)) { if (!(key in output)) { output[key] = value; diff --git a/extensions/taskplane/resume.ts b/extensions/taskplane/resume.ts index 7cc6a714..99db9b7e 100644 --- a/extensions/taskplane/resume.ts +++ b/extensions/taskplane/resume.ts @@ -34,11 +34,11 @@ function terminateAliveV2Agents(stateRoot: string, batchId: string, sessionName: } import { getCurrentBranch, runGit } from "./git.ts"; import { mergeWaveByRepo } from "./merge.ts"; -import { applyMergeRetryLoop, computeCleanupGatePolicy, computeMergeFailurePolicy, extractFailedRepoId, formatRepoMergeSummary, ORCH_MESSAGES } from "./messages.ts"; +import { applyMergeRetryLoop, computeCleanupGatePolicy, computeMergeFailurePolicy, extractFailedRepoId, formatRepoAtomicFailureSummary, formatRepoMergeSummary, mergeRequiresRollbackSafeStop, ORCH_MESSAGES } from "./messages.ts"; import type { CleanupGateRepoFailure } from "./messages.ts"; import { resolveOperatorId } from "./naming.ts"; import { applyPartialProgressToOutcomes, deleteBatchState, hasTaskDoneMarker, loadBatchState, persistRuntimeState, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts"; -import { buildBatchProgressSnapshot, buildSupervisorSegmentFrontierSnapshot, defaultResilienceState, StateFileError } from "./types.ts"; +import { buildBatchProgressSnapshot, buildSupervisorTaskFailureAlert, defaultResilienceState, StateFileError } from "./types.ts"; import type { AllocatedLane, AllocatedTask, LaneExecutionResult, LaneTaskOutcome, LaneTaskStatus, MergeWaveResult, OrchBatchPhase, OrchBatchRuntimeState, OrchestratorConfig, ParsedTask, PersistedBatchState, PersistedLaneRecord, PersistedSegmentRecord, ReconciledTaskState, ResumeEligibility, ResumePoint, TaskRunnerConfig, WaveExecutionResult, WorkspaceConfig } from "./types.ts"; import { buildDependencyGraph, resolveBaseBranch, resolveRepoRoot } from "./waves.ts"; import { deleteBranchBestEffort, forceCleanupWorktree, listWorktrees, preserveFailedLaneProgress, removeAllWorktrees, removeWorktree, safeResetWorktree, sleepSync } from "./worktree.ts"; @@ -67,10 +67,23 @@ export function collectRepoRoots( workspaceConfig?: WorkspaceConfig | null, ): string[] { const roots = new Set(); + const collectLaneRepoIds = (lane: { + repoId?: string; + repoWorktrees?: Record; + }): Set => { + const repoIds = new Set(); + repoIds.add(lane.repoId); + for (const [repoKey, worktree] of Object.entries(lane.repoWorktrees ?? {})) { + repoIds.add(worktree.repoId ?? repoKey); + } + return repoIds; + }; for (const lane of persistedState.lanes) { - const root = resolveRepoRoot(lane.repoId, defaultRepoRoot, workspaceConfig); - roots.add(root); + for (const repoId of collectLaneRepoIds(lane)) { + const root = resolveRepoRoot(repoId, defaultRepoRoot, workspaceConfig); + roots.add(root); + } } // Always include the default repo root (covers repo mode and any @@ -146,6 +159,7 @@ export function reconstructAllocatedLanes( laneSessionId: lr.laneSessionId, worktreePath: lr.worktreePath, branch: lr.branch, + ...(lr.repoWorktrees !== undefined ? { repoWorktrees: lr.repoWorktrees } : {}), tasks: lr.taskIds.map((taskId) => { const persistedTask = taskLookup.get(taskId); // Build a minimal ParsedTask stub that carries repo attribution @@ -158,6 +172,12 @@ export function reconstructAllocatedLanes( if (persistedTask?.resolvedRepoId !== undefined) { taskStub.resolvedRepoId = persistedTask.resolvedRepoId; } + if ((persistedTask as any)?.resolvedRepoIds !== undefined) { + (taskStub as any).resolvedRepoIds = (persistedTask as any).resolvedRepoIds; + } + if ((persistedTask as any)?.participatingRepoIds !== undefined) { + (taskStub as any).participatingRepoIds = (persistedTask as any).participatingRepoIds; + } // TP-169: Always set taskFolder on stub, even if empty string. // Previously, the falsy check `if (persistedTask?.taskFolder)` skipped // empty-string values, leaving taskFolder as `undefined` on the stub. @@ -178,6 +198,12 @@ export function reconstructAllocatedLanes( if ((persistedTask as any)?.activeSegmentId !== undefined) { (taskStub as any).activeSegmentId = (persistedTask as any).activeSegmentId; } + if ((persistedTask as any)?.explicitSegmentDag !== undefined) { + (taskStub as any).explicitSegmentDag = (persistedTask as any).explicitSegmentDag; + } + if ((persistedTask as any)?.stepSegmentMap !== undefined) { + (taskStub as any).stepSegmentMap = (persistedTask as any).stepSegmentMap; + } return { taskId, order: 0, @@ -192,6 +218,71 @@ export function reconstructAllocatedLanes( })); } +function recoverSegmentIdsFromRecords( + taskId: string, + persistedSegments: ReadonlyArray, +): string[] { + const taskSegments = persistedSegments.filter((segment) => segment.taskId === taskId); + if (taskSegments.length === 0) return []; + + const segmentIds = new Set(taskSegments.map((segment) => segment.segmentId)); + const indegree = new Map(); + const outgoing = new Map(); + for (const segment of taskSegments) { + indegree.set(segment.segmentId, 0); + outgoing.set(segment.segmentId, []); + } + + for (const segment of taskSegments) { + for (const dep of segment.dependsOnSegmentIds) { + if (!segmentIds.has(dep)) continue; + indegree.set(segment.segmentId, (indegree.get(segment.segmentId) ?? 0) + 1); + outgoing.set(dep, [...(outgoing.get(dep) ?? []), segment.segmentId]); + } + } + + const compareSegmentIds = (left: string, right: string): number => { + const leftRecord = taskSegments.find((segment) => segment.segmentId === left); + const rightRecord = taskSegments.find((segment) => segment.segmentId === right); + const leftStarted = leftRecord?.startedAt ?? Number.MAX_SAFE_INTEGER; + const rightStarted = rightRecord?.startedAt ?? Number.MAX_SAFE_INTEGER; + if (leftStarted !== rightStarted) return leftStarted - rightStarted; + return left.localeCompare(right); + }; + + const queue = [...taskSegments.map((segment) => segment.segmentId)] + .filter((segmentId) => (indegree.get(segmentId) ?? 0) === 0) + .sort(compareSegmentIds); + const ordered: string[] = []; + + while (queue.length > 0) { + const current = queue.shift()!; + ordered.push(current); + const nextIds = [...(outgoing.get(current) ?? [])].sort(compareSegmentIds); + for (const nextId of nextIds) { + const nextDegree = (indegree.get(nextId) ?? 0) - 1; + indegree.set(nextId, nextDegree); + if (nextDegree === 0) { + queue.push(nextId); + queue.sort(compareSegmentIds); + } + } + } + + if (ordered.length === taskSegments.length) return ordered; + return [...segmentIds].sort(compareSegmentIds); +} + +function resolveTaskSegmentIds( + task: PersistedBatchState["tasks"][number], + persistedSegments: ReadonlyArray, +): string[] { + if (Array.isArray(task.segmentIds) && task.segmentIds.length > 0) { + return task.segmentIds; + } + return recoverSegmentIdsFromRecords(task.taskId, persistedSegments); +} + /** * Collect unique repo roots from a combination of sources. * @@ -206,16 +297,32 @@ export function reconstructAllocatedLanes( * @returns Array of unique absolute repo root paths */ export function collectAllRepoRoots( - laneSources: Array<{ repoId?: string }[]>, + laneSources: Array; + }>>, defaultRepoRoot: string, workspaceConfig?: WorkspaceConfig | null, ): string[] { const roots = new Set(); + const collectLaneRepoIds = (lane: { + repoId?: string; + repoWorktrees?: Record; + }): Set => { + const repoIds = new Set(); + repoIds.add(lane.repoId); + for (const [repoKey, worktree] of Object.entries(lane.repoWorktrees ?? {})) { + repoIds.add(worktree.repoId ?? repoKey); + } + return repoIds; + }; for (const lanes of laneSources) { for (const lane of lanes) { - const root = resolveRepoRoot(lane.repoId, defaultRepoRoot, workspaceConfig); - roots.add(root); + for (const repoId of collectLaneRepoIds(lane)) { + const root = resolveRepoRoot(repoId, defaultRepoRoot, workspaceConfig); + roots.add(root); + } } } @@ -226,6 +333,22 @@ export function collectAllRepoRoots( return [...roots]; } +function collectLaneWorktreePaths(lane: { + worktreePath?: string; + repoWorktrees?: Record; +}): string[] { + const paths = new Set(); + if (lane.worktreePath) { + paths.add(lane.worktreePath); + } + for (const worktree of Object.values(lane.repoWorktrees ?? {})) { + if (worktree.path) { + paths.add(worktree.path); + } + } + return [...paths]; +} + // ── Resume Pure Functions ──────────────────────────────────────────── /** @@ -246,15 +369,18 @@ export function collectDoneTaskIdsForResume( continue; } const laneRec = persistedState.lanes.find(l => l.taskIds.includes(task.taskId)); - if (laneRec?.worktreePath && task.taskFolder) { - const resolved = resolveCanonicalTaskPaths( - task.taskFolder, - laneRec.worktreePath, - repoRoot, - !!workspaceConfig, - ); - if (existsSync(resolved.donePath)) { - doneTaskIds.add(task.taskId); + if (laneRec && task.taskFolder) { + for (const worktreePath of collectLaneWorktreePaths(laneRec)) { + const resolved = resolveCanonicalTaskPaths( + task.taskFolder, + worktreePath, + repoRoot, + !!workspaceConfig, + ); + if (existsSync(resolved.donePath)) { + doneTaskIds.add(task.taskId); + break; + } } } } @@ -418,8 +544,11 @@ export function reconstructSegmentFrontier( } for (const task of persistedState.tasks) { - const segmentIds = task.segmentIds ?? []; + const segmentIds = resolveTaskSegmentIds(task, persistedState.segments ?? []); if (segmentIds.length === 0) continue; + if (!Array.isArray(task.segmentIds) || task.segmentIds.length === 0) { + task.segmentIds = segmentIds; + } const dependencyBySegmentId = new Map(); const completedSegmentIds: string[] = []; @@ -657,8 +786,9 @@ export function buildResumeRuntimeWavePlan(persistedState: PersistedBatchState): const runtimeWavePlan = [...baseWavePlan]; const segmentCountByTaskId = new Map(); for (const task of persistedState.tasks) { - if (Array.isArray(task.segmentIds) && task.segmentIds.length > 0) { - segmentCountByTaskId.set(task.taskId, task.segmentIds.length); + const segmentIds = resolveTaskSegmentIds(task, persistedState.segments ?? []); + if (segmentIds.length > 0) { + segmentCountByTaskId.set(task.taskId, segmentIds.length); } } @@ -762,8 +892,9 @@ export function computeResumePoint( : []; const segmentIdsByTaskId = new Map(); for (const task of persistedTasks) { - if (task.segmentIds && task.segmentIds.length > 0) { - segmentIdsByTaskId.set(task.taskId, task.segmentIds); + const segmentIds = resolveTaskSegmentIds(task, persistedState.segments ?? []); + if (segmentIds.length > 0) { + segmentIdsByTaskId.set(task.taskId, segmentIds); } } const waveSegmentIdByTaskOccurrence = new Map(); @@ -1021,27 +1152,32 @@ export function runPreResumeDiagnostics( // 3. Worktree health — check each persisted lane worktree for (const lane of persistedState.lanes) { - if (!lane.worktreePath) continue; - - const wtExists = existsSync(lane.worktreePath); - if (wtExists) { - // Verify it's a valid git worktree (has .git file/directory) - const gitMarker = join(lane.worktreePath, ".git"); - const isValidWt = existsSync(gitMarker); - checks.push({ - check: `worktree-health:lane-${lane.laneNumber}`, - passed: isValidWt, - detail: isValidWt - ? `Lane ${lane.laneNumber} worktree exists and has valid .git marker` - : `Lane ${lane.laneNumber} worktree exists at ${lane.worktreePath} but lacks .git marker (corrupted)`, - }); - } else { - // Absent worktree is OK — resume will re-create or skip - checks.push({ - check: `worktree-health:lane-${lane.laneNumber}`, - passed: true, - detail: `Lane ${lane.laneNumber} worktree absent (will be re-created on resume)`, - }); + const worktreePaths = collectLaneWorktreePaths(lane); + if (worktreePaths.length === 0) continue; + + for (const [index, worktreePath] of worktreePaths.entries()) { + const wtExists = existsSync(worktreePath); + const checkLabel = worktreePaths.length > 1 + ? `worktree-health:lane-${lane.laneNumber}:${index + 1}` + : `worktree-health:lane-${lane.laneNumber}`; + if (wtExists) { + // Verify it's a valid git worktree (has .git file/directory) + const gitMarker = join(worktreePath, ".git"); + const isValidWt = existsSync(gitMarker); + checks.push({ + check: checkLabel, + passed: isValidWt, + detail: isValidWt + ? `Lane ${lane.laneNumber} worktree exists and has valid .git marker` + : `Lane ${lane.laneNumber} worktree exists at ${worktreePath} but lacks .git marker (corrupted)`, + }); + } else { + checks.push({ + check: checkLabel, + passed: true, + detail: `Lane ${lane.laneNumber} worktree absent (will be re-created on resume)`, + }); + } } } @@ -1226,7 +1362,7 @@ export async function resumeOrchBatch( const existingWorktreeTaskIds = new Set(); for (const task of persistedState.tasks) { const laneRecord = persistedState.lanes.find(l => l.taskIds.includes(task.taskId)); - if (laneRecord && laneRecord.worktreePath && existsSync(laneRecord.worktreePath)) { + if (laneRecord && collectLaneWorktreePaths(laneRecord).some((worktreePath) => existsSync(worktreePath))) { existingWorktreeTaskIds.add(task.taskId); } } @@ -1433,12 +1569,18 @@ export async function resumeOrchBatch( // Rehydrate discovered tasks with persisted segment metadata. // Dynamically expanded segments may reference tasks that have segment-level - // fields (segmentIds, activeSegmentId, packetRepoId, packetTaskPath) set + // fields (participatingRepoIds, segmentIds, activeSegmentId, packetRepoId, packetTaskPath) set // during the prior run. Merge these back into discovered ParsedTask records // so execution can resume with correct segment context. for (const persistedTask of persistedState.tasks) { const parsed = discovery.pending.get(persistedTask.taskId); if (!parsed) continue; + if (persistedTask.participatingRepoIds?.length) { + parsed.participatingRepoIds = persistedTask.participatingRepoIds; + } + if ((persistedTask as any).resolvedRepoIds?.length) { + (parsed as any).resolvedRepoIds = (persistedTask as any).resolvedRepoIds; + } if (persistedTask.segmentIds?.length) { parsed.segmentIds = persistedTask.segmentIds; } @@ -1485,6 +1627,7 @@ export async function resumeOrchBatch( laneSessionId: laneRecord.laneSessionId, worktreePath: laneRecord.worktreePath, branch: laneRecord.branch, + ...(laneRecord.repoWorktrees !== undefined ? { repoWorktrees: laneRecord.repoWorktrees } : {}), tasks: [allocatedTask], strategy: "round-robin", estimatedLoad: 0, @@ -1566,6 +1709,7 @@ export async function resumeOrchBatch( laneSessionId: laneRecord.laneSessionId, worktreePath: laneRecord.worktreePath, branch: laneRecord.branch, + ...(laneRecord.repoWorktrees !== undefined ? { repoWorktrees: laneRecord.repoWorktrees } : {}), tasks: [allocatedTask], strategy: "round-robin", estimatedLoad: 0, @@ -1816,6 +1960,62 @@ export async function resumeOrchBatch( const roundToTaskWave = batchState.roundToTaskWave; const taskLevelWaveCount = batchState.taskLevelWaveCount; + const applyRollbackSafeStop = (waveIdx: number, mergeResult: MergeWaveResult): boolean => { + if (!mergeResult.rollbackFailed && !mergeRequiresRollbackSafeStop(mergeResult)) { + return false; + } + + const hasPersistErrors = mergeResult.persistenceErrors && mergeResult.persistenceErrors.length > 0; + const persistWarning = hasPersistErrors + ? ` WARNING: ${mergeResult.persistenceErrors!.length} transaction record(s) failed to persist — recovery file(s) may be missing.` + : ""; + + execLog("batch", batchState.batchId, "SAFE-STOP: verification rollback failed — forcing paused regardless of policy", { + waveIndex: waveIdx, + configPolicy: orchConfig.failure.on_merge_failure, + ...(hasPersistErrors ? { persistenceErrors: mergeResult.persistenceErrors } : {}), + }); + + batchState.phase = "paused"; + batchState.errors.push( + `Safe-stop at wave ${waveIdx + 1}: verification rollback failed. ` + + `Merge worktree and temp branch preserved for recovery. ` + + `Check transaction records in .pi/verification/ for recovery commands.` + + persistWarning, + ); + persistRuntimeState("merge-rollback-safe-stop", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot); + onNotify( + `🛑 Safe-stop: verification rollback failed at wave ${waveIdx + 1}. ` + + `Batch force-paused. Merge worktree preserved for manual recovery. ` + + `See .pi/verification/ transaction records for recovery commands.` + + persistWarning, + "error", + ); + + const rollbackRepoId = extractFailedRepoId(mergeResult) ?? undefined; + emitAlert({ + category: "merge-failure", + summary: + `⚠️ Merge failed for wave ${waveIdx + 1} — verification rollback failed\n` + + ` Batch force-paused for manual recovery.\n` + + ` Check .pi/verification/ for recovery commands.\n\n` + + `Available actions:\n` + + ` - Check .pi/verification/ transaction records\n` + + ` - orch_status() to inspect current state\n` + + ` - orch_resume(force=true) after manual recovery`, + context: { + waveIndex: waveIdx, + laneNumber: mergeResult.failedLane ?? undefined, + repoId: rollbackRepoId, + mergeError: `Safe-stop: verification rollback failed at wave ${waveIdx + 1}`, + batchProgress: buildBatchProgressSnapshot(batchState), + }, + }); + + preserveWorktreesForResume = true; + return true; + }; + for (let waveIdx = resumePoint.resumeWaveIndex; waveIdx < wavePlan.length; waveIdx++) { // Check pause signal if (batchState.pauseSignal.paused) { @@ -1985,6 +2185,9 @@ export async function resumeOrchBatch( `⚠️ Wave ${resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave} merge retry ${mergeRetryResult.status}: ${mergeRetryResult.failureReason || "unknown"}`, "warning", ); + if (applyRollbackSafeStop(waveIdx, mergeRetryResult)) { + break; + } // Apply merge failure policy (same as normal wave merge failure) const policyResult = computeMergeFailurePolicy(mergeRetryResult, waveIdx, orchConfig); execLog("batch", batchState.batchId, `merge retry failure — applying ${policyResult.policy} policy`, policyResult.logDetails); @@ -2086,57 +2289,26 @@ export async function resumeOrchBatch( for (const taskId of waveResult.failedTaskIds) { const outcome = allTaskOutcomes.find(o => o.taskId === taskId); const laneForTask = latestAllocatedLanes.find(l => l.tasks.some(t => t.taskId === taskId)); - const taskRecord = batchState.tasks.find((task) => task.taskId === taskId); + const taskRecord = persistedState.tasks.find((task) => task.taskId === taskId); const exitReason = outcome?.exitReason || "unknown"; const hasPartialProgress = (outcome?.partialProgressCommits ?? 0) > 0; - const segmentFrontier = buildSupervisorSegmentFrontierSnapshot( + emitAlert(buildSupervisorTaskFailureAlert({ taskId, - taskRecord?.segmentIds, - taskRecord?.activeSegmentId, - batchState.segments, - outcome?.segmentId, - ); - const segmentId = outcome?.segmentId - ?? taskRecord?.activeSegmentId - ?? segmentFrontier?.activeSegmentId - ?? undefined; - const repoId = segmentId - ? (segmentFrontier?.segments.find((segment) => segment.segmentId === segmentId)?.repoId ?? laneForTask?.repoId) - : laneForTask?.repoId; - const segmentSummary = segmentId - ? ` Segment: ${segmentId}${repoId ? ` (repo: ${repoId})` : ""}\n` - : ""; - const frontierSummary = segmentFrontier - ? ` Segment frontier: ${segmentFrontier.terminalSegments}/${segmentFrontier.totalSegments} terminal\n` - : ""; - emitAlert({ - category: "task-failure", - summary: - `⚠️ Task failure: ${taskId}\n` + - ` Exit reason: ${exitReason}\n` + - segmentSummary + - frontierSummary + - ` Lane: ${laneForTask?.laneId ?? "unknown"} (lane ${laneForTask?.laneNumber ?? "?"})\n` + - ` Partial progress preserved: ${hasPartialProgress ? "yes" : "no"}\n` + - ` Batch: wave ${resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave}/${taskLevelWaveCount ?? batchState.totalWaves}, ` + - `${batchState.succeededTasks} succeeded, ${batchState.failedTasks} failed\n\n` + - `Available actions:\n` + - ` - orch_status() to inspect current state\n` + - ` - orch_resume(force=true) to retry\n` + - ` - Read STATUS.md and lane logs for diagnosis`, - context: { - taskId, - segmentId, - repoId, - segmentFrontier, - laneId: laneForTask?.laneId, - laneNumber: laneForTask?.laneNumber, - waveIndex: waveIdx, - exitReason, - partialProgress: hasPartialProgress, - batchProgress: buildBatchProgressSnapshot(batchState), - }, - }); + failurePolicy: waveResult.policyApplied, + exitReason, + partialProgress: hasPartialProgress, + laneId: laneForTask?.laneId, + laneNumber: laneForTask?.laneNumber, + laneRepoId: laneForTask?.repoId, + taskSegmentIds: taskRecord?.segmentIds, + taskActiveSegmentId: taskRecord?.activeSegmentId, + persistedSegments: batchState.segments, + outcomeSegmentId: outcome?.segmentId, + blockedTaskIds: waveResult.policyApplied === "skip-dependents" ? [...waveResult.blockedTaskIds] : undefined, + batchProgress: buildBatchProgressSnapshot(batchState), + displayWave: resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, + totalDisplayWaves: taskLevelWaveCount ?? batchState.totalWaves, + })); } persistRuntimeState("wave-execution-complete", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot); @@ -2261,6 +2433,11 @@ export async function resumeOrchBatch( "error", ); + const atomicRepoSummary = formatRepoAtomicFailureSummary(mergeResult); + if (atomicRepoSummary) { + onNotify(atomicRepoSummary, "warning"); + } + // Emit repo-divergence summary when partial is caused by cross-repo outcome differences if (mergeResult.status === "partial") { const repoSummary = formatRepoMergeSummary(mergeResult); @@ -2302,58 +2479,7 @@ export async function resumeOrchBatch( // When a verification rollback failed, force paused regardless of // on_merge_failure policy. The merge worktree and temp branch are // preserved for manual recovery using commands in the transaction record. - if (mergeResult?.rollbackFailed) { - // TP-033 R004-2: Include persistence error warning when transaction - // record files may be missing, so operator knows to inspect manually - const hasPersistErrors = mergeResult.persistenceErrors && mergeResult.persistenceErrors.length > 0; - const persistWarning = hasPersistErrors - ? ` WARNING: ${mergeResult.persistenceErrors!.length} transaction record(s) failed to persist — recovery file(s) may be missing.` - : ""; - - execLog("batch", batchState.batchId, "SAFE-STOP: verification rollback failed — forcing paused regardless of policy", { - waveIndex: waveIdx, - configPolicy: orchConfig.failure.on_merge_failure, - ...(hasPersistErrors ? { persistenceErrors: mergeResult.persistenceErrors } : {}), - }); - - batchState.phase = "paused"; - batchState.errors.push( - `Safe-stop at wave ${waveIdx + 1}: verification rollback failed. ` + - `Merge worktree and temp branch preserved for recovery. ` + - `Check transaction records in .pi/verification/ for recovery commands.` + - persistWarning - ); - persistRuntimeState("merge-rollback-safe-stop", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot); - onNotify( - `🛑 Safe-stop: verification rollback failed at wave ${waveIdx + 1}. ` + - `Batch force-paused. Merge worktree preserved for manual recovery. ` + - `See .pi/verification/ transaction records for recovery commands.` + - persistWarning, - "error", - ); - - // ── TP-076: Emit supervisor alert for rollback safe-stop ── - const rollbackRepoId = extractFailedRepoId(mergeResult) ?? undefined; - emitAlert({ - category: "merge-failure", - summary: - `⚠️ Merge failed for wave ${waveIdx + 1} — verification rollback failed\n` + - ` Batch force-paused for manual recovery.\n` + - ` Check .pi/verification/ for recovery commands.\n\n` + - `Available actions:\n` + - ` - Check .pi/verification/ transaction records\n` + - ` - orch_status() to inspect current state\n` + - ` - orch_resume(force=true) after manual recovery`, - context: { - waveIndex: waveIdx, - laneNumber: mergeResult.failedLane ?? undefined, - repoId: rollbackRepoId, - mergeError: `Safe-stop: verification rollback failed at wave ${waveIdx + 1}`, - batchProgress: buildBatchProgressSnapshot(batchState), - }, - }); - - preserveWorktreesForResume = true; + if (mergeResult && applyRollbackSafeStop(waveIdx, mergeResult)) { break; } diff --git a/extensions/taskplane/settings-tui.ts b/extensions/taskplane/settings-tui.ts index f013d3a4..88db7428 100644 --- a/extensions/taskplane/settings-tui.ts +++ b/extensions/taskplane/settings-tui.ts @@ -102,6 +102,7 @@ export const SECTIONS: SectionDef[] = [ { configPath: "orchestrator.orchestrator.sessionPrefix", label: "Session Prefix", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "sessionPrefix", description: "Prefix for orchestrator session names" }, { configPath: "orchestrator.orchestrator.operatorId", label: "Operator ID", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "operatorId", description: "Operator identifier (empty = auto-detect)" }, { configPath: "orchestrator.orchestrator.integration", label: "Integration", control: "picker", layer: "L1", fieldType: "enum", values: ["manual", "supervised", "auto"], description: "How completed batches are integrated. manual = user runs /orch-integrate. supervised = supervisor proposes plan, asks confirmation. auto = supervisor executes without asking." }, + { configPath: "orchestrator.orchestrator.submoduleRepoIdStrategy", label: "Submodule Repo IDs", control: "picker", layer: "L1", fieldType: "enum", values: ["path-basename"], description: "How workspace repo IDs are derived when importing undeclared submodules." }, ], }, { @@ -159,6 +160,8 @@ export const SECTIONS: SectionDef[] = [ fields: [ { configPath: "orchestrator.failure.onTaskFailure", label: "On Task Failure", control: "toggle", layer: "L1", fieldType: "enum", values: ["skip-dependents", "stop-wave", "stop-all"], description: "Batch behavior when a task fails" }, { configPath: "orchestrator.failure.onMergeFailure", label: "On Merge Failure", control: "toggle", layer: "L1", fieldType: "enum", values: ["pause", "abort"], description: "Behavior when a merge step fails" }, + { configPath: "orchestrator.failure.submoduleFailureMode", label: "Submodule Failure Mode", control: "toggle", layer: "L1", fieldType: "enum", values: ["permissive", "strict"], description: "Whether submodule findings warn or block orchestrator startup" }, + { configPath: "orchestrator.failure.onSubmoduleDrift", label: "On Submodule Drift", control: "picker", layer: "L1", fieldType: "enum", values: ["manual", "init-only", "recursive-on-drift"], description: "How planner sync should reconcile submodule drift and init gaps" }, { configPath: "orchestrator.failure.stallTimeout", label: "Stall Timeout (min)", control: "input", layer: "L1", fieldType: "number", description: "Stall detection threshold (minutes)" }, { configPath: "orchestrator.failure.maxWorkerMinutes", label: "Max Worker Min", control: "input", layer: "L1", fieldType: "number", description: "Max worker runtime budget per task (minutes)" }, { configPath: "orchestrator.failure.abortGracePeriod", label: "Abort Grace (sec)", control: "input", layer: "L1", fieldType: "number", description: "Graceful abort wait time (seconds)" }, @@ -576,6 +579,13 @@ function getNestedValue(obj: any, path: string): any { return current; } +function getDefaultFieldValue(field: FieldDef): any { + const explicitDefault = getNestedValue(DEFAULT_PROJECT_CONFIG, field.configPath); + if (explicitDefault !== undefined) return explicitDefault; + + return undefined; +} + /** * Determine the source of a field's current value. * @@ -615,8 +625,11 @@ export function getFieldDisplayValue( const val = getNestedValue(mergedConfig, field.configPath); - // Optional fields may be undefined if (val === undefined) { + const defaultVal = getDefaultFieldValue(field); + if (defaultVal !== undefined) { + return String(defaultVal); + } return "(not set)"; } diff --git a/extensions/taskplane/task-executor-core.ts b/extensions/taskplane/task-executor-core.ts index 815cd5f5..a3d9a568 100644 --- a/extensions/taskplane/task-executor-core.ts +++ b/extensions/taskplane/task-executor-core.ts @@ -64,6 +64,29 @@ export interface ParsedStatus { iteration: number; } +function parseStatusStepHeader(line: string): { number: number; name: string } | null { + const match = line.match(/^#{2,6}\s+Step\s+(\d+)(?::\s*|\s+)(.+)$/); + if (!match) return null; + return { + number: parseInt(match[1], 10), + name: match[2].trim(), + }; +} + +function isNonStepSectionHeading(line: string): boolean { + if (!/^#{1,6}\s+/.test(line)) return false; + if (parseStatusStepHeader(line)) return false; + if (/^####\s+Segment:\s*/.test(line)) return false; + return true; +} + +function pushParsedStep(steps: StepInfo[], step: StepInfo | null): void { + if (!step) return; + step.totalChecked = step.checkboxes.filter(c => c.checked).length; + step.totalItems = step.checkboxes.length; + steps.push(step); +} + // ── PROMPT.md Parsing ──────────────────────────────────────────────── /** @@ -146,21 +169,35 @@ export function parseStatusMd(content: string): ParsedStatus { const steps: StepInfo[] = []; let currentStep: StepInfo | null = null; let reviewCounter = 0, iteration = 0; + let inCodeFence = false; for (const line of text.split("\n")) { + if (/^```/.test(line.trim())) { + inCodeFence = !inCodeFence; + continue; + } const rcMatch = line.match(/\*\*Review Counter:\*\*\s*(\d+)/); if (rcMatch) reviewCounter = parseInt(rcMatch[1]); const itMatch = line.match(/\*\*Iteration:\*\*\s*(\d+)/); if (itMatch) iteration = parseInt(itMatch[1]); - - const stepMatch = line.match(/^###\s+Step\s+(\d+):\s*(.+)/); - if (stepMatch) { - if (currentStep) { - currentStep.totalChecked = currentStep.checkboxes.filter(c => c.checked).length; - currentStep.totalItems = currentStep.checkboxes.length; - steps.push(currentStep); - } - currentStep = { number: parseInt(stepMatch[1]), name: stepMatch[2].trim(), status: "not-started", checkboxes: [], totalChecked: 0, totalItems: 0 }; + if (inCodeFence) continue; + + const stepHeader = parseStatusStepHeader(line); + if (stepHeader) { + pushParsedStep(steps, currentStep); + currentStep = { + number: stepHeader.number, + name: stepHeader.name, + status: "not-started", + checkboxes: [], + totalChecked: 0, + totalItems: 0, + }; + continue; + } + if (currentStep && isNonStepSectionHeading(line)) { + pushParsedStep(steps, currentStep); + currentStep = null; continue; } if (currentStep) { @@ -174,11 +211,7 @@ export function parseStatusMd(content: string): ParsedStatus { if (cb) currentStep.checkboxes.push({ text: cb[2].trim(), checked: cb[1].toLowerCase() === "x" }); } } - if (currentStep) { - currentStep.totalChecked = currentStep.checkboxes.filter(c => c.checked).length; - currentStep.totalItems = currentStep.checkboxes.length; - steps.push(currentStep); - } + pushParsedStep(steps, currentStep); return { steps, reviewCounter, iteration }; } @@ -252,8 +285,9 @@ export function updateStepStatus(statusPath: string, stepNum: number, status: "n const lines = content.split("\n"); let inTarget = false; for (let i = 0; i < lines.length; i++) { - const sm = lines[i].match(/^###\s+Step\s+(\d+):/); - if (sm) inTarget = parseInt(sm[1]) === stepNum; + const stepHeader = parseStatusStepHeader(lines[i]); + if (stepHeader) inTarget = stepHeader.number === stepNum; + if (/^```/.test(lines[i].trim())) continue; if (inTarget && lines[i].match(/^\*\*Status:\*\*/)) { lines[i] = `**Status:** ${emoji}`; break; diff --git a/extensions/taskplane/types.ts b/extensions/taskplane/types.ts index 14e5efeb..dabe917a 100644 --- a/extensions/taskplane/types.ts +++ b/extensions/taskplane/types.ts @@ -20,6 +20,7 @@ export interface OrchestratorConfig { operator_id: string; /** How completed batches are integrated. manual = user runs /orch-integrate. supervised = supervisor proposes plan, asks confirmation. auto = supervisor executes without asking. */ integration: "manual" | "supervised" | "auto"; + submodule_repo_id_strategy: "path-basename"; }; dependencies: { source: "prompt" | "agent"; @@ -49,6 +50,8 @@ export interface OrchestratorConfig { failure: { on_task_failure: "skip-dependents" | "stop-wave" | "stop-all"; on_merge_failure: "pause" | "abort"; + submodule_failure_mode: "permissive" | "strict"; + on_submodule_drift: "manual" | "init-only" | "recursive-on-drift"; stall_timeout: number; max_worker_minutes: number; abort_grace_period: number; @@ -103,6 +106,17 @@ export interface ParsedTask { status: "pending" | "complete"; /** Repo ID declared in the PROMPT metadata (e.g., "api", "frontend"). Undefined if not declared. */ promptRepoId?: string; + /** Ordered repo IDs declared via `## Execution Target` `Repos:` metadata. */ + promptRepoIds?: string[]; + /** Ordered repo IDs participating in the current segment frontier for this task. */ + participatingRepoIds?: string[]; + /** + * Ordered repo IDs after applying routing precedence in workspace mode. + * + * Preserves plural execution-target intent while `resolvedRepoId` remains + * the primary repo for existing single-repo consumers. + */ + resolvedRepoIds?: string[]; /** Resolved repo ID after routing precedence (workspace mode only). Undefined in repo mode. */ resolvedRepoId?: string; /** Optional explicit segment DAG metadata from `## Segment DAG`. */ @@ -359,6 +373,7 @@ export const DEFAULT_ORCHESTRATOR_CONFIG: OrchestratorConfig = { sessionPrefix: "orch", operator_id: "", integration: "manual", + submodule_repo_id_strategy: "path-basename", }, dependencies: { source: "prompt", @@ -384,6 +399,8 @@ export const DEFAULT_ORCHESTRATOR_CONFIG: OrchestratorConfig = { failure: { on_task_failure: "skip-dependents", on_merge_failure: "pause", + submodule_failure_mode: "permissive", + on_submodule_drift: "manual", stall_timeout: 30, max_worker_minutes: 30, abort_grace_period: 60, @@ -433,6 +450,8 @@ export interface WorktreeInfo { branch: string; /** Lane number (1-indexed) this worktree is assigned to */ laneNumber: number; + /** Repo ID this worktree targets in workspace mode. Undefined in repo mode. */ + repoId?: string; } /** Options for createWorktree() */ @@ -447,6 +466,8 @@ export interface CreateWorktreeOptions { prefix: string; /** Operator identifier (sanitized, e.g., "henrylach") */ opId: string; + /** Repo ID for repo-scoped batch container naming in workspace mode. */ + repoId?: string; /** Full orchestrator config (optional; used for worktree_location) */ config?: OrchestratorConfig; } @@ -593,6 +614,7 @@ export interface DiscoveryError { | "DEP_SOURCE_FALLBACK" | "TASK_REPO_UNRESOLVED" | "TASK_REPO_UNKNOWN" + | "TASK_REPO_SCOPE_MISMATCH" | "TASK_ROUTING_STRICT" | "SEGMENT_DAG_INVALID" | "SEGMENT_REPO_UNKNOWN" @@ -619,6 +641,7 @@ export const FATAL_DISCOVERY_CODES: ReadonlyArray = [ "PARSE_MISSING_ID", "TASK_REPO_UNRESOLVED", "TASK_REPO_UNKNOWN", + "TASK_REPO_SCOPE_MISMATCH", "TASK_ROUTING_STRICT", "SEGMENT_DAG_INVALID", "SEGMENT_REPO_UNKNOWN", @@ -720,10 +743,12 @@ export interface AllocatedLane { laneId: string; /** Lane session identifier (e.g., "orch-lane-1") — used by Step 2 */ laneSessionId: string; - /** Absolute path to the lane's worktree directory */ + /** Absolute path to the lane's primary worktree directory */ worktreePath: string; /** Git branch name checked out in the worktree */ branch: string; + /** Additional repo-scoped worktree metadata for multi-repo lanes. */ + repoWorktrees?: Record; /** Tasks assigned to this lane, ordered for sequential execution */ tasks: AllocatedTask[]; /** Assignment strategy that was used (for diagnostics) */ @@ -1113,6 +1138,26 @@ export interface WaveExecutionResult { */ export type OrchBatchPhase = "idle" | "launching" | "planning" | "executing" | "merging" | "paused" | "stopped" | "completed" | "failed"; +export interface OrchWorkspaceSyncStatus { + state: "none" | "clean"; + trackedSubmodules: number; + label: string; + detail: string; +} + +export type OrchMergePanelLevel = "info" | "success" | "warning" | "error"; + +export interface OrchMergePanelEvent { + level: OrchMergePanelLevel; + message: string; +} + +export interface OrchMergePanelState { + status: "running" | "success" | "warning" | "error"; + waveLabel: string; + events: OrchMergePanelEvent[]; +} + /** * Runtime state for a batch execution. * @@ -1193,6 +1238,10 @@ export interface OrchBatchRuntimeState { * and repo-mode batches. */ segments?: PersistedSegmentRecord[]; + /** Workspace repo/submodule sync snapshot captured before execution starts. */ + workspaceSyncStatus?: OrchWorkspaceSyncStatus; + /** Merge-phase panel state rendered into the orchestrator widget. */ + mergePanel?: OrchMergePanelState; /** * Unknown top-level fields from loaded persisted state. * Carried forward so they survive serialization roundtrips. @@ -1261,6 +1310,7 @@ export function freshOrchBatchState(): OrchBatchRuntimeState { currentLanes: [], dependencyGraph: null, mergeResults: [], + mergePanel: undefined, }; } @@ -1352,6 +1402,8 @@ export interface MergeLaneResult { /** Overall wave merge outcome. */ export interface MergeWaveResult { waveIndex: number; + /** Stable wave-level merge transaction identifier shared across repo groups. */ + waveTransactionId?: string; status: "succeeded" | "failed" | "partial"; laneResults: MergeLaneResult[]; failedLane: number | null; @@ -1423,8 +1475,12 @@ export interface TransactionRecord { opId: string; /** Batch identifier */ batchId: string; + /** Stable wave-level merge transaction identifier */ + waveTransactionId: string; /** Wave index (0-based) */ waveIndex: number; + /** Repo-group attempt order within the wave merge */ + repoAttemptSequence: number; /** Lane number within the wave */ laneNumber: number; /** Repo ID (undefined/null in repo mode, string in workspace mode) */ @@ -2096,6 +2152,8 @@ export interface SupervisorSegmentFrontierSnapshot { export interface SupervisorAlertContext { /** Task ID (for task-failure alerts) */ taskId?: string; + /** Failure policy active when the alert was emitted */ + failurePolicy?: "skip-dependents" | "stop-wave" | "stop-all"; /** Segment ID (for segment-aware task-failure alerts) */ segmentId?: string; /** Repo ID associated with the failure (task segment or merge target) */ @@ -2118,6 +2176,10 @@ export interface SupervisorAlertContext { expansionRequestId?: string; /** Whether partial progress was preserved (for task-failure alerts) */ partialProgress?: boolean; + /** Task IDs newly blocked by skip-dependents continuation policy */ + blockedTaskIds?: string[]; + /** Whether unrelated ready tasks continue after this failure */ + continueUnaffected?: boolean; /** Batch progress summary */ batchProgress?: { succeededTasks: number; @@ -2251,6 +2313,90 @@ export function buildSupervisorSegmentFrontierSnapshot( }; } +export interface BuildSupervisorTaskFailureAlertInput { + taskId: string; + failurePolicy: "skip-dependents" | "stop-wave" | "stop-all"; + exitReason: string; + partialProgress: boolean; + laneId?: string; + laneNumber?: number; + laneRepoId?: string; + taskSegmentIds?: string[]; + taskActiveSegmentId?: string | null; + persistedSegments?: PersistedSegmentRecord[]; + outcomeSegmentId?: string | null; + blockedTaskIds?: string[]; + batchProgress: NonNullable; + displayWave: number; + totalDisplayWaves: number; +} + +export function buildSupervisorTaskFailureAlert( + input: BuildSupervisorTaskFailureAlertInput, +): SupervisorAlert { + const segmentFrontier = buildSupervisorSegmentFrontierSnapshot( + input.taskId, + input.taskSegmentIds, + input.taskActiveSegmentId, + input.persistedSegments, + input.outcomeSegmentId, + ); + const segmentId = input.outcomeSegmentId + ?? input.taskActiveSegmentId + ?? segmentFrontier?.activeSegmentId + ?? undefined; + const repoId = segmentId + ? (segmentFrontier?.segments.find((segment) => segment.segmentId === segmentId)?.repoId ?? input.laneRepoId) + : input.laneRepoId; + const blockedTaskIds = input.failurePolicy === "skip-dependents" + ? [...(input.blockedTaskIds ?? [])] + : []; + const segmentSummary = segmentId + ? ` Segment: ${segmentId}${repoId ? ` (repo: ${repoId})` : ""}\n` + : ""; + const frontierSummary = segmentFrontier + ? ` Segment frontier: ${segmentFrontier.terminalSegments}/${segmentFrontier.totalSegments} terminal\n` + : ""; + const policySummary = input.failurePolicy === "skip-dependents" + ? ` Failure policy: skip-dependents\n` + + ` Newly blocked dependents: ${blockedTaskIds.length > 0 ? blockedTaskIds.join(", ") : "none"}\n` + + ` Unrelated ready tasks continue under skip-dependents.\n` + : ""; + + return { + category: "task-failure", + summary: + `⚠️ Task failure: ${input.taskId}\n` + + ` Exit reason: ${input.exitReason}\n` + + segmentSummary + + frontierSummary + + ` Lane: ${input.laneId ?? "unknown"} (lane ${input.laneNumber ?? "?"})\n` + + ` Partial progress preserved: ${input.partialProgress ? "yes" : "no"}\n` + + policySummary + + ` Batch: wave ${input.displayWave}/${input.totalDisplayWaves}, ` + + `${input.batchProgress.succeededTasks} succeeded, ${input.batchProgress.failedTasks} failed\n\n` + + `Available actions:\n` + + ` - orch_status() to inspect current state\n` + + ` - orch_resume(force=true) to retry\n` + + ` - Read STATUS.md and lane logs for diagnosis`, + context: { + taskId: input.taskId, + failurePolicy: input.failurePolicy, + segmentId, + repoId, + segmentFrontier, + laneId: input.laneId, + laneNumber: input.laneNumber, + waveIndex: input.displayWave - 1, + exitReason: input.exitReason, + partialProgress: input.partialProgress, + ...(blockedTaskIds.length > 0 ? { blockedTaskIds } : {}), + continueUnaffected: input.failurePolicy === "skip-dependents", + batchProgress: input.batchProgress, + }, + }; +} + /** * Build the base fields for an engine event. * @@ -2692,6 +2838,20 @@ export interface PersistedTaskRecord { * repo target after prompt → area → workspace-default fallback. */ resolvedRepoId?: string; + /** + * Ordered resolved repo IDs after applying routing precedence. + * + * Preserves plural execution-target intent for resume flows before + * dynamic segment expansion has materialized `participatingRepoIds`. + */ + resolvedRepoIds?: string[]; + /** + * Ordered repo IDs participating in this task's current segment frontier. + * + * Used to restore cross-repo worker context on resume, including + * dynamically-expanded segments that are not recoverable from prompt metadata. + */ + participatingRepoIds?: string[]; /** * Number of commits preserved as partial progress for a failed task (TP-028). * Undefined when no partial progress was saved (succeeded tasks, no commits, etc.). @@ -2745,6 +2905,20 @@ export interface PersistedTaskRecord { * Undefined for pre-v4 state files. */ activeSegmentId?: string | null; + /** + * Explicit segment DAG metadata parsed from the task prompt. + * + * Preserved so resume flows can rebuild repo-scoped segment execution + * without rediscovery when a batch is paused mid-frontier. + */ + explicitSegmentDag?: PromptSegmentDagMetadata; + /** + * Repo-scoped step/checkbox mapping parsed from prompt segment markers. + * + * Preserved so resumed segment-scoped workers retain the same filtered + * step visibility they had before the interruption. + */ + stepSegmentMap?: StepSegmentMapping[]; } // ── Segment-Level Persisted State (v4, TP-081) ────────────────────── @@ -2845,6 +3019,8 @@ export interface PersistedLaneRecord { laneSessionId: string; /** Absolute path to the lane's worktree directory */ worktreePath: string; + /** Repo-scoped worktree map for multi-repo lanes. */ + repoWorktrees?: Record; /** Git branch name checked out in the worktree */ branch: string; /** Task IDs assigned to this lane in execution order */ @@ -2864,12 +3040,18 @@ export interface PersistedLaneRecord { export interface PersistedMergeResult { /** Wave index (0-based) */ waveIndex: number; + /** Stable wave-level merge transaction identifier when available. */ + waveTransactionId?: string; /** Merge status */ status: "succeeded" | "failed" | "partial"; /** Which lane failed (null if all succeeded) */ failedLane: number | null; /** Failure reason (null if all succeeded) */ failureReason: string | null; + /** True when merge-safe-stop was triggered by rollback failure. */ + rollbackFailed?: boolean; + /** Persisted warnings from transaction record persistence. */ + persistenceErrors?: string[]; /** * Per-repo merge outcomes (v2, TP-009). * Populated in workspace mode when MergeWaveResult.repoResults is available. @@ -3014,6 +3196,10 @@ export interface PersistedBatchState { * Required in v4. Migration from v1/v2/v3 fills empty array. */ segments: PersistedSegmentRecord[]; + /** Optional workspace repo/submodule sync snapshot for dashboard rendering. */ + workspaceSyncStatus?: OrchWorkspaceSyncStatus; + /** Optional merge-phase panel state for dashboard rendering. */ + mergePanel?: OrchMergePanelState; /** * Unknown top-level fields captured during deserialization. * Preserved on roundtrip to avoid data loss from future schema extensions @@ -3446,6 +3632,81 @@ export interface ExecutionContext { pointer: PointerResolution | null; } +export type SubmoduleFailureMode = "permissive" | "strict"; +export type SubmoduleDriftMode = "manual" | "init-only" | "recursive-on-drift"; +export type SubmoduleRepoIdStrategy = "path-basename"; + +export interface SubmodulePolicy { + failureMode: SubmoduleFailureMode; + onSubmoduleDrift: SubmoduleDriftMode; + repoIdStrategy: SubmoduleRepoIdStrategy; +} + +export interface WorkspaceSyncFinding { + name: string; + kind: + | "workspace-repo-id" + | "missing-workspace-repo" + | "invalid-derived-repo-id" + | "repo-id-collision" + | "uninitialized-submodule" + | "drifted-submodule" + | "conflicted-submodule"; + status: PreflightCheck["status"]; + repoLabel: string; + repoRoot: string; + submodulePath?: string; + absolutePath?: string; + derivedRepoId?: string; + message: string; + hint?: string; +} + +export interface WorkspaceRepoImportCandidate { + repoLabel: string; + repoRoot: string; + submodulePath: string; + absolutePath: string; + derivedRepoId: string; +} + +export interface WorkspaceDetectedSubmodule { + repoLabel: string; + repoRoot: string; + submodulePath: string; + absolutePath: string; + mappedRepoId?: string; + state: "clean" | "uninitialized" | "drifted" | "conflict"; +} + +export interface WorkspaceSyncSummary { + trackedSubmodules: number; + detectedSubmodules: WorkspaceDetectedSubmodule[]; + findings: WorkspaceSyncFinding[]; + importCandidates: WorkspaceRepoImportCandidate[]; +} + +export interface WorkspaceSyncApplyResult { + importedRepoIds: string[]; + initializedPaths: string[]; + updatedPaths: string[]; + warnings: string[]; + changed: boolean; +} + +export interface WorkspaceSyncBadgeStatus { + state: "none" | "clean"; + trackedSubmodules: number; + label: string; + detail: string; +} + +export interface WorkspaceSyncPresentation { + status: "success" | "failure"; + notificationLevel: "info" | "error"; + message: string; +} + // ── Workspace Validation Error Types ───────────────────────────────── @@ -3856,6 +4117,13 @@ export interface ExecutionUnit { executionRepoId: string; /** Repo ID that owns the packet files (may differ in workspace mode) */ packetHomeRepoId: string; + /** + * Repo ID → absolute path map for repos participating in this task. + * + * The active execution repo resolves to the lane worktree so edits land on + * the orch branch. Sibling repos resolve to their workspace repo roots. + */ + repoPaths: Record; /** Absolute path to the execution worktree */ worktreePath: string; /** Authoritative packet file paths */ @@ -3957,6 +4225,8 @@ export interface RuntimeLaneSnapshot { reviewer: RuntimeAgentTelemetrySnapshot | null; /** Task progress derived from STATUS.md */ progress: RuntimeTaskProgress | null; + /** Optional submodule diagnostics for inherited-vs-introduced dirt analysis */ + submoduleDiagnostics?: RuntimeLaneSubmoduleDiagnostics | null; /** Epoch ms when this snapshot was last updated */ updatedAt: number; } @@ -4009,6 +4279,75 @@ export interface RuntimeTaskProgress { reviews: number; } +export interface RuntimeSubmoduleStatusPreview { + /** Top-level submodule path relative to the lane worktree */ + path: string; + /** Captured `git status --porcelain` preview lines */ + statusLines: string[]; + /** Total status line count before truncation */ + lineCount: number; + /** Whether the preview was truncated */ + truncated: boolean; + /** Whether the submodule worktree is dirty */ + dirty: boolean; + /** Optional capture error instead of status output */ + error?: string; +} + +export interface RuntimeSubmoduleSnapshot { + /** Task this snapshot was captured for */ + taskId: string; + /** Capture phase relative to worker execution */ + phase: "pre-task" | "post-task"; + /** Epoch ms when the snapshot was captured */ + capturedAt: number; + /** Absolute lane worktree path used for capture */ + worktreePath: string; + /** Total top-level submodules discovered */ + totalSubmodules: number; + /** Number of dirty submodules in this capture */ + dirtySubmodules: number; + /** Per-submodule status previews */ + entries: RuntimeSubmoduleStatusPreview[]; +} + +export interface RuntimeUnsafeSubmoduleFinding { + /** Top-level submodule path relative to the lane worktree */ + path: string; + /** Unsafe finding classification from checkpoint validation */ + kind: "dirty-worktree" | "unpublished-commit"; + /** Human-readable per-finding summary */ + summary: string; + /** Preview of `git status --porcelain` for this submodule */ + statusLines: string[]; + /** Total status line count before truncation */ + lineCount: number; + /** Whether the preview was truncated */ + truncated: boolean; + /** Optional capture error when status preview failed */ + error?: string; + /** Current submodule HEAD when relevant */ + headCommit?: string; + /** Indexed gitlink commit when relevant */ + indexCommit?: string; + /** Preferred remote name when relevant */ + remoteName?: string; +} + +export interface RuntimeLaneSubmoduleDiagnostics { + /** Snapshot captured before the worker begins task execution */ + preTask?: RuntimeSubmoduleSnapshot | null; + /** Snapshot captured after the worker finishes task execution */ + postTask?: RuntimeSubmoduleSnapshot | null; + /** Unsafe checkpoint findings recorded before commitTaskArtifacts throws */ + unsafeCheckpoint?: { + taskId: string; + capturedAt: number; + summary: string; + findings: RuntimeUnsafeSubmoduleFinding[]; + } | null; +} + /** * Normalized event emitted by an agent host. * diff --git a/extensions/taskplane/waves.ts b/extensions/taskplane/waves.ts index a471c4e4..b5f0b84f 100644 --- a/extensions/taskplane/waves.ts +++ b/extensions/taskplane/waves.ts @@ -7,7 +7,7 @@ import { join } from "path"; import { parseDependencyReference } from "./discovery.ts"; import { resolveOperatorId } from "./naming.ts"; import { AllocationError, buildSegmentId, getTaskDurationMinutes } from "./types.ts"; -import type { AllocatedLane, AllocatedTask, AllocateLanesResult, AllocationErrorCode, DependencyGraph, DiscoveryError, GraphValidationResult, LaneAssignment, OrchestratorConfig, ParsedTask, TaskSegmentPlan, TaskSegmentPlanMap, WaveAssignment, WaveComputationResult, WorkspaceConfig, WorktreeInfo } from "./types.ts"; +import type { AllocatedLane, AllocatedTask, AllocationErrorCode, DependencyGraph, DiscoveryError, GraphValidationResult, LaneAssignment, OrchestratorConfig, ParsedTask, TaskSegmentPlan, TaskSegmentPlanMap, WaveAssignment, WaveComputationResult, WorkspaceConfig, WorktreeInfo } from "./types.ts"; import { getCurrentBranch, runGit } from "./git.ts"; import { ensureLaneWorktrees, removeAllWorktrees, removeWorktree } from "./worktree.ts"; @@ -415,16 +415,59 @@ export function applyFileScopeAffinity( export interface RepoTaskGroup { /** Repo ID (undefined for repo mode / tasks without resolvedRepoId) */ repoId: string | undefined; + /** Ordered repo membership for this lane-planning group. */ + repoIds?: string[]; /** Task IDs in this group (sorted alphabetically) */ taskIds: string[]; } +function normalizeRepoGroupIds(task: ParsedTask | undefined): string[] { + if (!task) return []; + + const uniqueRepoIds = (repoIds: string[] | undefined): string[] => { + const result: string[] = []; + const seen = new Set(); + for (const rawRepoId of repoIds ?? []) { + const repoId = normalizeRepoIdCandidate(rawRepoId); + if (!repoId || seen.has(repoId)) continue; + seen.add(repoId); + result.push(repoId); + } + return result; + }; + + // Once the engine binds an active segment, the current round is repo-singleton + // even if the task retains broader multi-repo membership metadata. + if (task.activeSegmentId) { + const activeRepoId = normalizeRepoIdCandidate(task.resolvedRepoId ?? ""); + return activeRepoId ? [activeRepoId] : []; + } + + const participatingRepoIds = uniqueRepoIds(task.participatingRepoIds); + if (participatingRepoIds.length > 0) { + return participatingRepoIds; + } + + const resolvedRepoIds = uniqueRepoIds(task.resolvedRepoIds); + if (resolvedRepoIds.length > 0) { + return resolvedRepoIds; + } + + const resolvedRepoId = normalizeRepoIdCandidate(task.resolvedRepoId ?? ""); + return resolvedRepoId ? [resolvedRepoId] : []; +} + /** - * Group wave tasks by their resolved repo ID. + * Group wave tasks by their current lane-planning repo contract. * - * In workspace mode, tasks carry `resolvedRepoId` from the discovery/routing - * phase. This function groups them so each repo gets independent lane - * allocation (own affinity groups, own max_lanes budget). + * In workspace mode, repo-singleton tasks group by `resolvedRepoId`, while + * unsplit multi-repo tasks stay on one dedicated lane contract keyed by their + * ordered repo membership. This prevents a multi-repo task from being merged + * into a primary-repo singleton lane before multi-repo worktree maps exist. + * + * When the engine binds an `activeSegmentId`, the task becomes repo-singleton + * again for that execution round, so segment-frontier waves continue to group + * by the active repo only. * * In repo mode, all tasks have `resolvedRepoId === undefined`, so they all * land in a single group keyed by `""` (empty string). This preserves @@ -442,14 +485,18 @@ export function groupTasksByRepo( waveTasks: string[], pending: Map, ): RepoTaskGroup[] { - const groupMap = new Map(); + const groupMap = new Map(); for (const taskId of waveTasks) { const task = pending.get(taskId); - // Use resolvedRepoId or empty string as group key (undefined → "" for Map key) - const key = task?.resolvedRepoId ?? ""; - const existing = groupMap.get(key) || []; - existing.push(taskId); + const repoIds = normalizeRepoGroupIds(task); + const key = repoIds.length > 0 ? repoIds.join("|") : ""; + const existing = groupMap.get(key) || { + repoId: repoIds[0], + repoIds: repoIds.length > 0 ? [...repoIds] : undefined, + taskIds: [], + }; + existing.taskIds.push(taskId); groupMap.set(key, existing); } @@ -457,11 +504,12 @@ export function groupTasksByRepo( const groups: RepoTaskGroup[] = []; const sortedKeys = [...groupMap.keys()].sort(); for (const key of sortedKeys) { - const taskIds = groupMap.get(key)!; - taskIds.sort(); // Deterministic task order within group + const group = groupMap.get(key)!; + group.taskIds.sort(); // Deterministic task order within group groups.push({ - repoId: key || undefined, // Convert "" back to undefined for repo mode - taskIds, + repoId: group.repoId, + repoIds: group.repoIds, + taskIds: group.taskIds, }); } @@ -661,6 +709,10 @@ function collectKnownRepoIds( } for (const task of pending.values()) { + for (const repoIdRaw of task.resolvedRepoIds ?? []) { + const repoId = normalizeRepoIdCandidate(repoIdRaw); + if (repoId) known.add(repoId); + } if (task.resolvedRepoId) { const repoId = normalizeRepoIdCandidate(task.resolvedRepoId); if (repoId) known.add(repoId); @@ -732,7 +784,16 @@ export function inferTaskRepoOrder( for (const depRaw of task.dependencies) { const depId = parseDependencyReference(depRaw).taskId; const depTask = pending.get(depId); - if (depTask?.resolvedRepoId && record(depTask.resolvedRepoId, true) !== null) { + let sawDependencyRepo = false; + for (const repoIdRaw of depTask?.resolvedRepoIds ?? []) { + if (record(repoIdRaw, true) !== null) { + sawDependencyRepo = true; + } + } + if (!sawDependencyRepo && depTask?.resolvedRepoId && record(depTask.resolvedRepoId, true) !== null) { + sawDependencyRepo = true; + } + if (sawDependencyRepo) { hasPrimarySignal = true; } } @@ -805,6 +866,32 @@ export function buildSegmentPlanForTask( }; } + const declaredRepoIds: string[] = []; + const declaredRepoSet = new Set(); + for (const repoIdRaw of task.promptRepoIds ?? []) { + const repoId = normalizeRepoIdCandidate(repoIdRaw); + if (!repoId || declaredRepoSet.has(repoId)) continue; + declaredRepoSet.add(repoId); + declaredRepoIds.push(repoId); + } + if (declaredRepoIds.length > 0) { + const segments = buildSegmentNodes(task.taskId, declaredRepoIds); + const edges = sortSegmentEdges( + segments.slice(0, -1).map((segment, idx) => ({ + fromSegmentId: segment.segmentId, + toSegmentId: segments[idx + 1].segmentId, + provenance: "inferred" as const, + reason: "prompt:execution-target-repos", + })), + ); + return { + taskId: task.taskId, + segments, + edges, + mode: declaredRepoIds.length === 1 ? "repo-singleton" : "inferred-sequential", + }; + } + const inferred = inferTaskRepoOrder(task, pending, knownRepoIds); const segments = buildSegmentNodes(task.taskId, inferred.repoIds); const edges = sortSegmentEdges( @@ -1242,6 +1329,7 @@ export function allocateLanes( globalLane: number; localLane: number; repoId: string | undefined; + repoIds: string[]; assignments: LaneAssignment[]; }> = []; @@ -1280,6 +1368,7 @@ export function allocateLanes( globalLane: localToGlobal.get(localLane)!, localLane, repoId: group.repoId, + repoIds: group.repoIds ?? (group.repoId ? [group.repoId] : []), assignments: byLocalLane.get(localLane) || [], }); } @@ -1320,16 +1409,22 @@ export function allocateLanes( const repoLaneGroups = new Map(); // key → global lane numbers const repoIdForGroup = new Map(); // key → repoId for (const entry of globalLaneEntries) { - const key = entry.repoId ?? ""; - const existing = repoLaneGroups.get(key) || []; - existing.push(entry.globalLane); - repoLaneGroups.set(key, existing); - repoIdForGroup.set(key, entry.repoId); + const desiredRepoIds = entry.repoIds.length > 0 ? entry.repoIds : [entry.repoId]; + for (const desiredRepoId of desiredRepoIds) { + const key = desiredRepoId ?? ""; + const existing = repoLaneGroups.get(key) || []; + existing.push(entry.globalLane); + repoLaneGroups.set(key, existing); + repoIdForGroup.set(key, desiredRepoId); + } + } + for (const [key, laneNumbers] of repoLaneGroups) { + repoLaneGroups.set(key, [...new Set(laneNumbers)].sort((a, b) => a - b)); } const sortedGroupKeys = [...repoLaneGroups.keys()].sort(); // Track all worktrees created across all repo groups for cross-repo rollback - const allWorktrees = new Map(); // global lane → worktree + const allWorktreesByLane = new Map>(); const createdGroupKeys: string[] = []; // groups that succeeded (for rollback tracking) for (const groupKey of sortedGroupKeys) { @@ -1344,6 +1439,7 @@ export function allocateLanes( config, groupRepoRoot, groupBaseBranch, + groupRepoId, ); if (!worktreeResult.success) { @@ -1354,7 +1450,7 @@ export function allocateLanes( const prevRepoRoot = resolveRepoRoot(prevRepoId, repoRoot, workspaceConfig); const prevLanes = repoLaneGroups.get(prevKey)!; for (const lane of prevLanes) { - const wt = allWorktrees.get(lane); + const wt = allWorktreesByLane.get(lane)?.get(prevRepoId ?? ""); if (wt) { try { removeWorktree(wt, prevRepoRoot); @@ -1397,7 +1493,9 @@ export function allocateLanes( // Record successful worktrees for (const wt of worktreeResult.worktrees) { - allWorktrees.set(wt.laneNumber, wt); + const laneWorktrees = allWorktreesByLane.get(wt.laneNumber) || new Map(); + laneWorktrees.set(groupRepoId ?? "", { ...wt, repoId: groupRepoId }); + allWorktreesByLane.set(wt.laneNumber, laneWorktrees); } createdGroupKeys.push(groupKey); } @@ -1411,7 +1509,8 @@ export function allocateLanes( const allocatedLanes: AllocatedLane[] = []; for (const entry of globalLaneEntries) { - const wt = allWorktrees.get(entry.globalLane); + const laneWorktrees = allWorktreesByLane.get(entry.globalLane); + const wt = laneWorktrees?.get(entry.repoId ?? ""); if (!wt) { // This should never happen if ensureLaneWorktrees and assignTasksToLanes // agree on lane numbers, but handle defensively. @@ -1453,12 +1552,21 @@ export function allocateLanes( ); const laneSessionId = generateLaneSessionId(sessionPrefix, entry.localLane, opId, entry.repoId); + const repoWorktrees = laneWorktrees + ? Object.fromEntries( + [...laneWorktrees.entries()] + .filter(([repoKey]) => repoKey !== "") + .map(([repoKey, worktree]) => [repoKey, worktree]), + ) + : undefined; + allocatedLanes.push({ laneNumber: entry.globalLane, laneId: generateLaneId(entry.localLane, entry.repoId), laneSessionId, worktreePath: wt.path, branch: wt.branch, + repoWorktrees: repoWorktrees && Object.keys(repoWorktrees).length > 0 ? repoWorktrees : undefined, tasks: allocatedTasks, strategy, estimatedLoad, diff --git a/extensions/taskplane/widgets/base.ts b/extensions/taskplane/widgets/base.ts new file mode 100644 index 00000000..583c52b1 --- /dev/null +++ b/extensions/taskplane/widgets/base.ts @@ -0,0 +1,198 @@ +import { existsSync, readFileSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; + +export type WidgetStatus = "running" | "success" | "error" | "warning"; +export type WidgetViewState = "opened" | "running" | "closed"; +export type WidgetThemeState = "in-progress" | "success" | "error" | "warning"; + +export interface WidgetState { + title: string; + status: WidgetStatus; + phase?: string; + sections?: Array; + expandHint?: string; + collapsed?: boolean; + viewState?: WidgetViewState; + themeState?: WidgetThemeState; + showScrollbar?: boolean; + maxBodyHeight?: number; + scrollOffset?: number; + padding?: number; +} + +export interface WidgetMessage { + text: string; + details: TState; +} + +export type WidgetComponent = { + render(width: number): string[]; + invalidate(): void; +}; + +export type WidgetFactory = (_tui: any, theme: any) => WidgetComponent; + +// Base class for widgets, providing common state management and rendering utilities +// Subclasses should implement the abstract methods to define specific widget behavior and appearance +export abstract class WidgetBase { + private static readonly defaultExpandHint = WidgetBase.resolveExpandHint(); + + #state: TState; + + constructor(state: TState) { + this.#state = WidgetBase.normalizeState(state); + } + + get state(): TState { + return WidgetBase.clone(this.#state); + } + + update(patch: Partial): this { + this.#state = WidgetBase.normalizeState({ + ...this.#state, + ...patch, + ...(patch.sections ? { sections: [...patch.sections] } : {}), + } as TState); + return this; + } + + open(patch: Partial = {}): this { + return this.update({ + ...patch, + collapsed: false, + viewState: patch.viewState ?? this.#state.viewState ?? "opened", + } as Partial); + } + + close(patch: Partial = {}): this { + const status = patch.status ?? this.#state.status; + return this.update({ + ...patch, + collapsed: true, + viewState: "closed", + themeState: WidgetBase.themeStateFor(status), + } as Partial); + } + + message(patch: Partial = {}): WidgetMessage { + const status = patch.status ?? this.#state.status; + const details = WidgetBase.normalizeState({ + ...this.#state, + ...patch, + ...(patch.sections ? { sections: [...patch.sections] } : {}), + collapsed: true, + viewState: "closed", + themeState: WidgetBase.themeStateFor(status), + } as TState); + return { + text: this.build(details), + details, + }; + } + + factory(): WidgetFactory | undefined { + const state = this.state; + if (!this.shouldRender(state)) return undefined; + return (_tui: any, theme: any) => ({ + render: (width: number) => this.create(state, theme).render(width), + invalidate() {}, + }); + } + + protected abstract shouldRender(state: TState): boolean; + protected abstract render(state: TState, theme: any, width: number): string[]; + protected abstract create(state: TState, theme: any): WidgetComponent; + protected abstract build(state: TState): string; + + static themeStateFor(status: WidgetStatus): WidgetThemeState { + return status === "success" + ? "success" + : status === "error" + ? "error" + : status === "warning" + ? "warning" + : "in-progress"; + } + + static phaseFor(status: WidgetStatus, phase?: string): string { + return phase + || (status === "success" + ? "Plan ready" + : status === "error" + ? "Plan failed" + : status === "warning" + ? "Needs attention" + : "Running"); + } + + static markerFor(status: WidgetStatus): string { + return status === "success" + ? "✓" + : status === "error" + ? "✗" + : status === "warning" + ? "!" + : "●"; + } + + static statusLineFor(state: WidgetState): string { + return `${WidgetBase.markerFor(state.status)} ${WidgetBase.phaseFor(state.status, state.phase)}`; + } + + static toneFor(status: WidgetStatus): "success" | "error" | "warning" { + return status === "success" + ? "success" + : status === "error" + ? "error" + : "warning"; + } + + protected static clone(state: TState): TState { + return { + ...state, + ...(state.sections ? { sections: [...state.sections] } : {}), + }; + } + + private static normalizeState(state: TState): TState { + const nextState = WidgetBase.clone(state); + if (!nextState.expandHint?.trim()) nextState.expandHint = WidgetBase.defaultExpandHint; + return nextState; + } + + private static resolveExpandHint(): string | undefined { + const defaultKeys = ["ctrl+o"]; + const formatSegment = (segment: string) => { + if (segment.length === 1) return segment.toUpperCase(); + if (segment === "ctrl") return "Ctrl"; + if (segment === "alt") return "Alt"; + if (segment === "shift") return "Shift"; + if (segment === "meta" || segment === "cmd") return "Cmd"; + if (segment === "pageup") return "PageUp"; + if (segment === "pagedown") return "PageDown"; + if (segment === "backspace") return "Backspace"; + if (segment === "enter") return "Enter"; + if (segment === "escape") return "Escape"; + return segment.charAt(0).toUpperCase() + segment.slice(1); + }; + const formatHotkeyLabel = (keys: string[]) => { + if (keys.length === 0) return undefined; + return keys.map((key) => key.split("+").map(formatSegment).join("+")).join(" / "); + }; + const keybindingsPath = join(homedir(), ".pi", "agent", "keybindings.json"); + if (!existsSync(keybindingsPath)) return formatHotkeyLabel(defaultKeys); + try { + const parsed = JSON.parse(readFileSync(keybindingsPath, "utf-8")) as { expandTools?: string | string[] }; + const value = parsed.expandTools; + if (typeof value === "string" && value.trim().length > 0) return formatHotkeyLabel([value]); + if (Array.isArray(value)) { + const keys = value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0); + if (keys.length > 0) return formatHotkeyLabel(keys); + } + } catch { + // Fall back to the documented default binding. + } + return formatHotkeyLabel(defaultKeys); + } +} \ No newline at end of file diff --git a/extensions/taskplane/widgets/collapsible-ribbon.ts b/extensions/taskplane/widgets/collapsible-ribbon.ts new file mode 100644 index 00000000..ae6ebb8c --- /dev/null +++ b/extensions/taskplane/widgets/collapsible-ribbon.ts @@ -0,0 +1,113 @@ +import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui"; + +import { WidgetBase, type WidgetComponent, type WidgetFactory, type WidgetState } from "./base.ts"; + +export type CollapsibleRibbonWidgetState = WidgetState & { + sections: Array; +}; +export type CollapsibleRibbonWidgetFactory = WidgetFactory; + +export class CollapsibleRibbonWidget extends WidgetBase { + lines(): string[] { + return this.build(this.state).split("\n"); + } + + protected build(state: CollapsibleRibbonWidgetState): string { + if (state.collapsed) { + const hintLine = CollapsibleRibbonWidget.collapsedHint(state); + return [ + `● ${state.title}`, + CollapsibleRibbonWidget.collapsedSummary(state), + ...(hintLine ? [hintLine] : []), + ].join("\n"); + } + const sectionLines = CollapsibleRibbonWidget.buildSectionLines(state.sections); + return [state.title, WidgetBase.statusLineFor(state), ...(sectionLines.length > 0 ? ["", ...sectionLines] : [])].join("\n"); + } + + protected create(state: CollapsibleRibbonWidgetState, theme: any): WidgetComponent { + return { + render: (width: number) => this.render(state, theme, width), + invalidate() {}, + }; + } + + protected shouldRender(state: CollapsibleRibbonWidgetState): boolean { + return Boolean(state.title || state.phase || state.sections.length > 0); + } + + protected render(state: CollapsibleRibbonWidgetState, theme: any, width: number): string[] { + const safeWidth = Math.max(6, width); + const padding = Math.max(0, state.padding ?? 1); + const innerWidth = Math.max(1, safeWidth - (padding * 2)); + const outerPad = " ".repeat(padding); + const dot = typeof theme.fg === "function" ? theme.fg(WidgetBase.toneFor(state.status), "●") : "●"; + const title = typeof theme.bold === "function" ? theme.bold(state.title) : state.title; + const phase = WidgetBase.phaseFor(state.status, state.phase); + const sectionLines = CollapsibleRibbonWidget.buildSectionLines(state.sections); + const detailSummaryLine = state.viewState === "closed" + ? CollapsibleRibbonWidget.collapsedSummary(state) + : `${dot} ${phase}`; + const collapsedHintLine = CollapsibleRibbonWidget.collapsedHint(state); + const contentLines = state.collapsed + ? [ + `${dot} ${title}`, + CollapsibleRibbonWidget.collapsedSummary(state), + ...(collapsedHintLine + ? [typeof theme.fg === "function" ? theme.fg("dim", collapsedHintLine) : collapsedHintLine] + : []), + ] + : [ + `${dot} ${title}`, + detailSummaryLine, + ...(sectionLines.length > 0 ? ["", ...sectionLines] : []), + ]; + const rendered: string[] = []; + for (let index = 0; index < padding; index += 1) rendered.push(" ".repeat(safeWidth)); + for (const contentLine of contentLines) { + if (contentLine.length === 0) { + rendered.push(" ".repeat(safeWidth)); + continue; + } + for (const wrappedLine of wrapTextWithAnsi(contentLine, innerWidth)) { + const visible = visibleWidth(wrappedLine); + rendered.push(truncateToWidth( + `${outerPad}${wrappedLine}${" ".repeat(Math.max(0, innerWidth - visible))}${outerPad}`, + safeWidth, + "", + )); + } + } + for (let index = 0; index < padding; index += 1) rendered.push(" ".repeat(safeWidth)); + return rendered; + } + + static buildSectionLines(sections: Array): string[] { + const lines: string[] = []; + for (const section of sections) { + const normalized = section?.replace(/\r\n/g, "\n").trimEnd(); + if (!normalized) continue; + if (lines.length > 0) lines.push(""); + lines.push(...normalized.split("\n")); + } + return lines; + } + + static collapsedSummary(state: CollapsibleRibbonWidgetState): string { + const phase = WidgetBase.phaseFor(state.status, state.phase); + const marker = WidgetBase.markerFor(state.status); + const normalizedSections = state.sections + .map((section) => section?.replace(/\r\n/g, "\n").trim()) + .filter((section): section is string => Boolean(section)); + const lastSectionHeadline = normalizedSections.length > 0 + ? normalizedSections[normalizedSections.length - 1].split("\n")[0]?.trim() + : ""; + return lastSectionHeadline && lastSectionHeadline !== phase + ? `${marker} ${phase} · ${lastSectionHeadline}` + : `${marker} ${phase}`; + } + + static collapsedHint(state: CollapsibleRibbonWidgetState): string | undefined { + return state.expandHint ? `Expand: ${state.expandHint}` : undefined; + } +} \ No newline at end of file diff --git a/extensions/taskplane/workspace.ts b/extensions/taskplane/workspace.ts index c82ba3c1..aa061eb5 100644 --- a/extensions/taskplane/workspace.ts +++ b/extensions/taskplane/workspace.ts @@ -39,19 +39,28 @@ * * @module orch/workspace */ -import { readFileSync, existsSync, realpathSync } from "fs"; -import { resolve, relative, isAbsolute } from "path"; -import { parse as yamlParse } from "yaml"; +import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "fs"; +import { basename, dirname, isAbsolute, relative, resolve } from "path"; +import { parse as yamlParse, stringify as yamlStringify } from "yaml"; -import { runGit } from "./git.ts"; +import { listConfiguredSubmodulePaths, listGitlinkPaths, listSubmoduleStatus, runGit } from "./git.ts"; +import { WORKSPACE_MESSAGES } from "./messages.ts"; import { - WorkspaceConfigError, - workspaceConfigPath, - pointerFilePath, + type PreflightCheck, + type PointerResolution, + type SubmodulePolicy, type WorkspaceConfig, + WorkspaceConfigError, type WorkspaceRepoConfig, + type WorkspaceDetectedSubmodule, + type WorkspaceRepoImportCandidate, type WorkspaceRoutingConfig, - type PointerResolution, + type WorkspaceSyncApplyResult, + type WorkspaceSyncBadgeStatus, + type WorkspaceSyncFinding, + type WorkspaceSyncSummary, + pointerFilePath, + workspaceConfigPath, } from "./types.ts"; @@ -108,6 +117,16 @@ function isPathWithinContainer(childPath: string, parentPath: string): boolean { return child === parent || child.startsWith(`${parent}/`); } +function isCrossPlatformAbsolutePath(rawPath: string): boolean { + const trimmed = rawPath.trim(); + const normalized = trimmed.replace(/\\/g, "/"); + return ( + isAbsolute(trimmed) || + isAbsolute(normalized) || + /^[A-Za-z]:\//.test(normalized) + ); +} + // ── Pointer Resolution ─────────────────────────────────────────────── @@ -161,7 +180,7 @@ export function resolvePointer( used: false, configRoot: fallbackConfigRoot, agentRoot: fallbackAgentRoot, - warning: `Pointer file not found: ${filePath}. Run 'taskplane init' to create it.`, + warning: WORKSPACE_MESSAGES.pointerNotFound(filePath), }; } @@ -175,7 +194,7 @@ export function resolvePointer( used: false, configRoot: fallbackConfigRoot, agentRoot: fallbackAgentRoot, - warning: `Cannot read pointer file ${filePath}: ${msg}`, + warning: WORKSPACE_MESSAGES.pointerReadError(filePath, msg), }; } @@ -188,7 +207,7 @@ export function resolvePointer( used: false, configRoot: fallbackConfigRoot, agentRoot: fallbackAgentRoot, - warning: `Pointer file ${filePath} contains invalid JSON.`, + warning: WORKSPACE_MESSAGES.pointerInvalidJson(filePath), }; } @@ -198,7 +217,7 @@ export function resolvePointer( used: false, configRoot: fallbackConfigRoot, agentRoot: fallbackAgentRoot, - warning: `Pointer file ${filePath} must be a JSON object.`, + warning: WORKSPACE_MESSAGES.pointerInvalidShape(filePath), }; } @@ -211,7 +230,7 @@ export function resolvePointer( used: false, configRoot: fallbackConfigRoot, agentRoot: fallbackAgentRoot, - warning: `Pointer file ${filePath} is missing required field 'config_repo'.`, + warning: WORKSPACE_MESSAGES.pointerMissingConfigRepo(filePath), }; } @@ -220,7 +239,7 @@ export function resolvePointer( used: false, configRoot: fallbackConfigRoot, agentRoot: fallbackAgentRoot, - warning: `Pointer file ${filePath} is missing required field 'config_path'.`, + warning: WORKSPACE_MESSAGES.pointerMissingConfigPath(filePath), }; } @@ -228,12 +247,12 @@ export function resolvePointer( const normalizedConfigPath = configPath.trim().replace(/\\/g, "/"); // Reject absolute paths (POSIX `/...` and Windows `C:/...`, `\\...`) - if (isAbsolute(normalizedConfigPath) || isAbsolute(configPath.trim())) { + if (isCrossPlatformAbsolutePath(configPath)) { return { used: false, configRoot: fallbackConfigRoot, agentRoot: fallbackAgentRoot, - warning: `Pointer file ${filePath} has invalid config_path '${configPath}' (absolute paths not allowed).`, + warning: WORKSPACE_MESSAGES.pointerAbsoluteConfigPath(filePath, configPath), }; } @@ -247,7 +266,7 @@ export function resolvePointer( used: false, configRoot: fallbackConfigRoot, agentRoot: fallbackAgentRoot, - warning: `Pointer file ${filePath} has invalid config_path '${configPath}' (path traversal not allowed).`, + warning: WORKSPACE_MESSAGES.pointerTraversalConfigPath(filePath, configPath), }; } @@ -260,7 +279,7 @@ export function resolvePointer( used: false, configRoot: fallbackConfigRoot, agentRoot: fallbackAgentRoot, - warning: `Pointer file ${filePath}: config_repo '${repoId}' not found in workspace repos. Available repos: ${available}`, + warning: WORKSPACE_MESSAGES.pointerUnknownConfigRepo(filePath, repoId, available), }; } @@ -274,7 +293,7 @@ export function resolvePointer( used: false, configRoot: fallbackConfigRoot, agentRoot: fallbackAgentRoot, - warning: `Pointer file ${filePath} has invalid config_path '${configPath}' (resolved path escapes config repo root).`, + warning: WORKSPACE_MESSAGES.pointerEscapedConfigPath(filePath, configPath), }; } @@ -318,7 +337,7 @@ export function loadWorkspaceConfig(workspaceRoot: string): WorkspaceConfig | nu const msg = err instanceof Error ? err.message : String(err); throw new WorkspaceConfigError( "WORKSPACE_FILE_READ_ERROR", - `Cannot read workspace config file: ${msg}`, + WORKSPACE_MESSAGES.workspaceConfigReadError(msg), undefined, configFile, ); @@ -332,7 +351,7 @@ export function loadWorkspaceConfig(workspaceRoot: string): WorkspaceConfig | nu const msg = err instanceof Error ? err.message : String(err); throw new WorkspaceConfigError( "WORKSPACE_FILE_PARSE_ERROR", - `Invalid YAML in workspace config: ${msg}`, + WORKSPACE_MESSAGES.workspaceConfigParseError(msg), undefined, configFile, ); @@ -342,7 +361,7 @@ export function loadWorkspaceConfig(workspaceRoot: string): WorkspaceConfig | nu if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) { throw new WorkspaceConfigError( "WORKSPACE_SCHEMA_INVALID", - "Workspace config must be a YAML mapping (object), not a scalar or sequence.", + WORKSPACE_MESSAGES.workspaceConfigMustBeMapping(), undefined, configFile, ); @@ -352,7 +371,7 @@ export function loadWorkspaceConfig(workspaceRoot: string): WorkspaceConfig | nu if (!doc.repos || typeof doc.repos !== "object" || Array.isArray(doc.repos)) { throw new WorkspaceConfigError( "WORKSPACE_SCHEMA_INVALID", - "Workspace config must contain a 'repos' mapping.", + WORKSPACE_MESSAGES.workspaceConfigMissingReposMapping(), undefined, configFile, ); @@ -360,7 +379,7 @@ export function loadWorkspaceConfig(workspaceRoot: string): WorkspaceConfig | nu if (!doc.routing || typeof doc.routing !== "object" || Array.isArray(doc.routing)) { throw new WorkspaceConfigError( "WORKSPACE_SCHEMA_INVALID", - "Workspace config must contain a 'routing' mapping.", + WORKSPACE_MESSAGES.workspaceConfigMissingRoutingMapping(), undefined, configFile, ); @@ -372,7 +391,7 @@ export function loadWorkspaceConfig(workspaceRoot: string): WorkspaceConfig | nu if (repoKeys.length === 0) { throw new WorkspaceConfigError( "WORKSPACE_MISSING_REPOS", - "Workspace config must define at least one repo under 'repos'.", + WORKSPACE_MESSAGES.workspaceConfigMissingRepos(), undefined, configFile, ); @@ -387,7 +406,7 @@ export function loadWorkspaceConfig(workspaceRoot: string): WorkspaceConfig | nu if (rawRepo == null || typeof rawRepo !== "object" || Array.isArray(rawRepo)) { throw new WorkspaceConfigError( "WORKSPACE_SCHEMA_INVALID", - `Repo '${repoId}' must be a YAML mapping with at least a 'path' field.`, + WORKSPACE_MESSAGES.workspaceConfigInvalidRepoEntry(repoId), repoId, configFile, ); @@ -399,7 +418,7 @@ export function loadWorkspaceConfig(workspaceRoot: string): WorkspaceConfig | nu if (!rawPath || typeof rawPath !== "string" || rawPath.trim() === "") { throw new WorkspaceConfigError( "WORKSPACE_REPO_PATH_MISSING", - `Repo '${repoId}' is missing a 'path' field.`, + WORKSPACE_MESSAGES.workspaceConfigMissingRepoPath(repoId), repoId, configFile, ); @@ -411,7 +430,7 @@ export function loadWorkspaceConfig(workspaceRoot: string): WorkspaceConfig | nu if (!existsSync(absolutePath)) { throw new WorkspaceConfigError( "WORKSPACE_REPO_PATH_NOT_FOUND", - `Repo '${repoId}' path does not exist: ${absolutePath}`, + WORKSPACE_MESSAGES.workspaceConfigRepoPathNotFound(repoId, absolutePath), repoId, absolutePath, ); @@ -422,7 +441,7 @@ export function loadWorkspaceConfig(workspaceRoot: string): WorkspaceConfig | nu if (!gitDirCheck.ok) { throw new WorkspaceConfigError( "WORKSPACE_REPO_NOT_GIT", - `Repo '${repoId}' path is not a git repository: ${absolutePath}`, + WORKSPACE_MESSAGES.workspaceConfigRepoNotGit(repoId, absolutePath), repoId, absolutePath, ); @@ -434,7 +453,7 @@ export function loadWorkspaceConfig(workspaceRoot: string): WorkspaceConfig | nu if (toplevelNormalized !== normalizedPath) { throw new WorkspaceConfigError( "WORKSPACE_REPO_NOT_GIT", - `Repo '${repoId}' path is a subdirectory of a git repo, not the repo root. Expected root: ${toplevelCheck.stdout.trim()}, got: ${absolutePath}`, + WORKSPACE_MESSAGES.workspaceConfigRepoNotRoot(repoId, toplevelCheck.stdout.trim(), absolutePath), repoId, absolutePath, ); @@ -445,7 +464,7 @@ export function loadWorkspaceConfig(workspaceRoot: string): WorkspaceConfig | nu if (normalizedPaths.has(normalizedPath)) { throw new WorkspaceConfigError( "WORKSPACE_DUPLICATE_REPO_PATH", - `Repos '${normalizedPaths.get(normalizedPath)}' and '${repoId}' share the same path: ${absolutePath}`, + WORKSPACE_MESSAGES.workspaceConfigDuplicateRepoPath(normalizedPaths.get(normalizedPath), repoId, absolutePath), repoId, absolutePath, ); @@ -472,7 +491,7 @@ export function loadWorkspaceConfig(workspaceRoot: string): WorkspaceConfig | nu if (!rawTasksRoot || typeof rawTasksRoot !== "string" || rawTasksRoot.trim() === "") { throw new WorkspaceConfigError( "WORKSPACE_MISSING_TASKS_ROOT", - "Workspace config 'routing.tasks_root' is missing or empty.", + WORKSPACE_MESSAGES.workspaceConfigMissingTasksRoot(), undefined, configFile, ); @@ -483,7 +502,7 @@ export function loadWorkspaceConfig(workspaceRoot: string): WorkspaceConfig | nu if (!existsSync(tasksRootAbsolute)) { throw new WorkspaceConfigError( "WORKSPACE_TASKS_ROOT_NOT_FOUND", - `routing.tasks_root path does not exist: ${tasksRootAbsolute}`, + WORKSPACE_MESSAGES.workspaceConfigTasksRootNotFound(tasksRootAbsolute), undefined, tasksRootAbsolute, ); @@ -494,7 +513,7 @@ export function loadWorkspaceConfig(workspaceRoot: string): WorkspaceConfig | nu if (!rawDefaultRepo || typeof rawDefaultRepo !== "string" || rawDefaultRepo.trim() === "") { throw new WorkspaceConfigError( "WORKSPACE_MISSING_DEFAULT_REPO", - "Workspace config 'routing.default_repo' is missing or empty.", + WORKSPACE_MESSAGES.workspaceConfigMissingDefaultRepo(), undefined, configFile, ); @@ -505,7 +524,7 @@ export function loadWorkspaceConfig(workspaceRoot: string): WorkspaceConfig | nu if (!repos.has(defaultRepoId)) { throw new WorkspaceConfigError( "WORKSPACE_DEFAULT_REPO_NOT_FOUND", - `routing.default_repo '${defaultRepoId}' does not match any repo ID. Available repos: ${Array.from(repos.keys()).join(", ")}`, + WORKSPACE_MESSAGES.workspaceConfigUnknownDefaultRepo(defaultRepoId, Array.from(repos.keys()).join(", ")), undefined, configFile, ); @@ -522,22 +541,20 @@ export function loadWorkspaceConfig(workspaceRoot: string): WorkspaceConfig | nu if (typeof rawTaskPacketRepo !== "string" || rawTaskPacketRepo.trim() === "") { throw new WorkspaceConfigError( "WORKSPACE_SCHEMA_INVALID", - "Workspace config 'routing.task_packet_repo' must be a non-empty string when provided.", + WORKSPACE_MESSAGES.workspaceConfigInvalidTaskPacketRepo(), undefined, configFile, ); } taskPacketRepoId = rawTaskPacketRepo.trim(); } else { - console.error( - `[taskplane] workspace compatibility: 'routing.task_packet_repo' is missing in ${configFile}; defaulting to routing.default_repo ('${defaultRepoId}'). Add 'routing.task_packet_repo' explicitly.`, - ); + console.error(WORKSPACE_MESSAGES.workspaceConfigCompatibilityTaskPacketRepo(configFile, defaultRepoId)); } if (!repos.has(taskPacketRepoId)) { throw new WorkspaceConfigError( "WORKSPACE_TASK_PACKET_REPO_NOT_FOUND", - `routing.task_packet_repo '${taskPacketRepoId}' does not match any repo ID. Available repos: ${Array.from(repos.keys()).join(", ")}`, + WORKSPACE_MESSAGES.workspaceConfigUnknownTaskPacketRepo(taskPacketRepoId, Array.from(repos.keys()).join(", ")), undefined, configFile, ); @@ -548,7 +565,7 @@ export function loadWorkspaceConfig(workspaceRoot: string): WorkspaceConfig | nu if (!isPathWithinContainer(tasksRootAbsolute, packetRepoPath)) { throw new WorkspaceConfigError( "WORKSPACE_TASKS_ROOT_OUTSIDE_PACKET_REPO", - `routing.tasks_root '${tasksRootAbsolute}' must be inside packet-home repo '${taskPacketRepoId}' (${packetRepoPath}). Update routing.tasks_root or routing.task_packet_repo.`, + WORKSPACE_MESSAGES.workspaceConfigTasksRootOutsidePacketRepo(tasksRootAbsolute, taskPacketRepoId, packetRepoPath), undefined, tasksRootAbsolute, ); @@ -562,7 +579,7 @@ export function loadWorkspaceConfig(workspaceRoot: string): WorkspaceConfig | nu if (rawStrict === null || typeof rawStrict !== "boolean") { throw new WorkspaceConfigError( "WORKSPACE_SCHEMA_INVALID", - `routing.strict must be a boolean (true/false)${rawStrict === null ? ", got null (use true or false explicitly)" : `, got ${typeof rawStrict}: ${JSON.stringify(rawStrict)}`}`, + WORKSPACE_MESSAGES.workspaceConfigInvalidStrict(rawStrict), undefined, configFile, ); @@ -612,7 +629,7 @@ export function validateTaskAreasWithinTasksRoot( if (!isPathWithinContainer(areaAbsolute, tasksRoot)) { throw new WorkspaceConfigError( "WORKSPACE_TASK_AREA_OUTSIDE_TASKS_ROOT", - `Task area '${areaName}' path '${areaAbsolute}' must be inside routing.tasks_root '${tasksRoot}'. Move the area under tasks_root or update task_areas.${areaName}.path.`, + WORKSPACE_MESSAGES.workspaceTaskAreaOutsideTasksRoot(areaName, areaAbsolute, tasksRoot), undefined, areaAbsolute, ); @@ -621,6 +638,398 @@ export function validateTaskAreasWithinTasksRoot( } +// ── Workspace Sync Helpers ────────────────────────────────────────── + +export const DEFAULT_SUBMODULE_POLICY: SubmodulePolicy = { + failureMode: "permissive", + onSubmoduleDrift: "manual", + repoIdStrategy: "path-basename", +}; + +export const WORKSPACE_REPO_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; + +function normalizeWorkspaceSyncPath(pathValue: string): string { + return resolve(pathValue).replace(/\\/g, "/"); +} + +function plannerSyncCommand(targetLabel = ""): string { + return `/orch-plan ${targetLabel} --sync`; +} + +function workspaceSyncFindingStatus(policy: SubmodulePolicy): PreflightCheck["status"] { + return policy.failureMode === "strict" ? "fail" : "warn"; +} + +function relativeWorkspacePath(workspaceRoot: string, absolutePath: string): string { + const rel = relative(workspaceRoot, absolutePath).replace(/\\/g, "/"); + if (rel && rel !== "." && !rel.startsWith("../") && rel !== "..") { + return rel; + } + return absolutePath.replace(/\\/g, "/"); +} + +function writeYamlFileAtomically(filePath: string, content: string): void { + mkdirSync(dirname(filePath), { recursive: true }); + const tmpPath = `${filePath}.tmp`; + writeFileSync(tmpPath, content, "utf-8"); + renameSync(tmpPath, filePath); +} + +function groupPathsByRepo(findings: WorkspaceSyncFinding[], kinds: WorkspaceSyncFinding["kind"][]): Map { + const grouped = new Map(); + const kindSet = new Set(kinds); + for (const finding of findings) { + if (!finding.submodulePath || !kindSet.has(finding.kind)) continue; + const paths = grouped.get(finding.repoRoot) ?? []; + paths.push(finding.submodulePath); + grouped.set(finding.repoRoot, paths); + } + for (const [repoRoot, paths] of grouped) { + grouped.set(repoRoot, [...new Set(paths)].sort((left, right) => left.localeCompare(right))); + } + return grouped; +} + +function collapseToTrackedSubmoduleRoots(repoRoot: string, paths: string[]): string[] { + const trackedPaths = [...new Set([...listConfiguredSubmodulePaths(repoRoot), ...listGitlinkPaths(repoRoot)])] + .sort((left, right) => right.length - left.length || left.localeCompare(right)); + if (trackedPaths.length === 0) { + return [...new Set(paths)].sort((left, right) => left.localeCompare(right)); + } + + const collapsed = paths.map((pathValue) => { + for (const trackedPath of trackedPaths) { + if (pathValue === trackedPath || pathValue.startsWith(`${trackedPath}/`)) { + return trackedPath; + } + } + return pathValue; + }); + + return [...new Set(collapsed)].sort((left, right) => left.localeCompare(right)); +} + +export function collectWorkspaceSyncSummary( + repoRoot: string | undefined, + workspaceConfig: WorkspaceConfig | null | undefined, + policy: SubmodulePolicy, + targetLabel?: string, +): WorkspaceSyncSummary { + const findings: WorkspaceSyncFinding[] = []; + const repoEntries = new Map(); + const workspaceRepoPaths = new Map(); + const workspaceRepoIds = new Map(); + + if (workspaceConfig) { + for (const [repoId, repoConfig] of workspaceConfig.repos) { + repoEntries.set(normalizeWorkspaceSyncPath(repoConfig.path), { label: repoId, root: repoConfig.path }); + workspaceRepoPaths.set(normalizeWorkspaceSyncPath(repoConfig.path), repoId); + workspaceRepoIds.set(repoId, repoConfig); + if (!WORKSPACE_REPO_ID_PATTERN.test(repoId)) { + findings.push({ + name: `workspace-repo-id:${repoId}`, + kind: "workspace-repo-id", + status: workspaceSyncFindingStatus(policy), + repoLabel: repoId, + repoRoot: repoConfig.path, + message: WORKSPACE_MESSAGES.workspaceRepoIdPolicyMessage(repoId), + hint: WORKSPACE_MESSAGES.workspaceRepoIdPolicyHint(), + }); + } + } + } else if (repoRoot) { + repoEntries.set(normalizeWorkspaceSyncPath(repoRoot), { label: basename(repoRoot), root: repoRoot }); + } + + const collisionCandidates = new Map(); + const detectedSubmodules: WorkspaceDetectedSubmodule[] = []; + let trackedSubmodules = 0; + + for (const { label, root } of [...repoEntries.values()].sort((left, right) => left.label.localeCompare(right.label))) { + const configuredPaths = listConfiguredSubmodulePaths(root); + const gitlinkPaths = listGitlinkPaths(root); + const statuses = listSubmoduleStatus(root); + const statusByPath = new Map(statuses.map((entry) => [entry.path, entry])); + const allPaths = [...new Set([...configuredPaths, ...gitlinkPaths, ...statuses.map((entry) => entry.path)])] + .sort((left, right) => left.localeCompare(right)); + + trackedSubmodules += allPaths.length; + + for (const submodulePath of allPaths) { + const absolutePath = resolve(root, submodulePath); + const mappedRepoId = workspaceRepoPaths.get(normalizeWorkspaceSyncPath(absolutePath)); + const status = statusByPath.get(submodulePath); + detectedSubmodules.push({ + repoLabel: label, + repoRoot: root, + submodulePath, + absolutePath, + mappedRepoId, + state: status?.state === "conflict" + ? "conflict" + : status?.state === "drifted" + ? "drifted" + : status?.state === "uninitialized" + ? "uninitialized" + : "clean", + }); + + if (workspaceConfig && !mappedRepoId && policy.repoIdStrategy === "path-basename") { + const derivedRepoId = basename(submodulePath).trim().toLowerCase(); + if (!WORKSPACE_REPO_ID_PATTERN.test(derivedRepoId)) { + findings.push({ + name: `submodule-import:${label}:${submodulePath}`, + kind: "invalid-derived-repo-id", + status: workspaceSyncFindingStatus(policy), + repoLabel: label, + repoRoot: root, + submodulePath, + absolutePath, + derivedRepoId, + message: WORKSPACE_MESSAGES.workspaceInvalidDerivedRepoIdMessage(label, submodulePath, derivedRepoId), + hint: WORKSPACE_MESSAGES.workspaceInvalidDerivedRepoIdHint(targetLabel, submodulePath), + }); + } else { + const existingRepo = workspaceRepoIds.get(derivedRepoId); + if (existingRepo && normalizeWorkspaceSyncPath(existingRepo.path) !== normalizeWorkspaceSyncPath(absolutePath)) { + findings.push({ + name: `submodule-repo-id:${derivedRepoId}:${label}:${submodulePath}`, + kind: "repo-id-collision", + status: workspaceSyncFindingStatus(policy), + repoLabel: label, + repoRoot: root, + submodulePath, + absolutePath, + derivedRepoId, + message: WORKSPACE_MESSAGES.workspaceRepoIdCollisionMessage(label, submodulePath, derivedRepoId, existingRepo.path), + hint: WORKSPACE_MESSAGES.workspaceRepoIdCollisionHint(targetLabel, submodulePath), + }); + } else { + const candidate: WorkspaceRepoImportCandidate = { + repoLabel: label, + repoRoot: root, + submodulePath, + absolutePath, + derivedRepoId, + }; + const candidates = collisionCandidates.get(derivedRepoId) ?? []; + candidates.push(candidate); + collisionCandidates.set(derivedRepoId, candidates); + findings.push({ + name: `submodule-import:${label}:${submodulePath}`, + kind: "missing-workspace-repo", + status: workspaceSyncFindingStatus(policy), + repoLabel: label, + repoRoot: root, + submodulePath, + absolutePath, + derivedRepoId, + message: WORKSPACE_MESSAGES.workspaceMissingRepoMessage(label, submodulePath), + hint: WORKSPACE_MESSAGES.workspaceMissingRepoHint(targetLabel, submodulePath, derivedRepoId), + }); + } + } + } + + if (status?.state === "uninitialized") { + findings.push({ + name: `submodule-state:${label}:${submodulePath}`, + kind: "uninitialized-submodule", + status: workspaceSyncFindingStatus(policy), + repoLabel: label, + repoRoot: root, + submodulePath, + absolutePath, + message: WORKSPACE_MESSAGES.workspaceUninitializedSubmoduleMessage(label, submodulePath), + hint: WORKSPACE_MESSAGES.uninitializedSubmoduleHint(policy, root, submodulePath, targetLabel), + }); + continue; + } + + if (status?.state === "drifted" || status?.state === "conflict") { + findings.push({ + name: `submodule-state:${label}:${submodulePath}`, + kind: status.state === "conflict" ? "conflicted-submodule" : "drifted-submodule", + status: workspaceSyncFindingStatus(policy), + repoLabel: label, + repoRoot: root, + submodulePath, + absolutePath, + message: WORKSPACE_MESSAGES.workspaceDriftedSubmoduleMessage(label, submodulePath, status.state === "conflict"), + hint: WORKSPACE_MESSAGES.driftedSubmoduleHint(policy, root, submodulePath, targetLabel), + }); + } + } + } + + const importCandidates: WorkspaceRepoImportCandidate[] = []; + for (const [derivedRepoId, candidates] of [...collisionCandidates.entries()].sort((left, right) => left[0].localeCompare(right[0]))) { + if (candidates.length === 1) { + importCandidates.push(candidates[0]); + continue; + } + findings.push({ + name: `submodule-repo-id:${derivedRepoId}`, + kind: "repo-id-collision", + status: workspaceSyncFindingStatus(policy), + repoLabel: derivedRepoId, + repoRoot: candidates[0]?.repoRoot ?? repoRoot ?? process.cwd(), + derivedRepoId, + message: WORKSPACE_MESSAGES.workspaceRepoCollisionMessage(derivedRepoId), + hint: WORKSPACE_MESSAGES.workspaceRepoCollisionHint(targetLabel, candidates), + }); + } + + findings.sort((left, right) => left.name.localeCompare(right.name)); + detectedSubmodules.sort((left, right) => { + const repoComparison = left.repoLabel.localeCompare(right.repoLabel); + return repoComparison !== 0 ? repoComparison : left.submodulePath.localeCompare(right.submodulePath); + }); + importCandidates.sort((left, right) => left.derivedRepoId.localeCompare(right.derivedRepoId)); + + return { + trackedSubmodules, + detectedSubmodules, + findings, + importCandidates, + }; +} + +export function workspaceSyncSummaryToChecks(summary: WorkspaceSyncSummary): PreflightCheck[] { + if (summary.findings.length === 0) { + return [{ + name: "submodules", + status: "pass", + message: summary.trackedSubmodules > 0 + ? WORKSPACE_MESSAGES.workspaceNoSubmoduleIssues(summary.trackedSubmodules) + : WORKSPACE_MESSAGES.workspaceNoSubmodules(), + }]; + } + return summary.findings.map((finding) => ({ + name: finding.name, + status: finding.status, + message: finding.message, + hint: finding.hint, + })); +} + +export function buildWorkspaceSyncBadgeStatus(summary: WorkspaceSyncSummary): WorkspaceSyncBadgeStatus { + if (summary.trackedSubmodules === 0) { + return { + state: "none", + trackedSubmodules: 0, + label: WORKSPACE_MESSAGES.workspaceSyncBadgeNoneLabel(), + detail: WORKSPACE_MESSAGES.workspaceSyncBadgeNoneDetail(), + }; + } + return { + state: "clean", + trackedSubmodules: summary.trackedSubmodules, + label: WORKSPACE_MESSAGES.workspaceSyncBadgeCleanLabel(summary.trackedSubmodules), + detail: WORKSPACE_MESSAGES.workspaceSyncBadgeCleanDetail(), + }; +} + +export function applyWorkspaceSync( + workspaceRoot: string, + _repoRoot: string, + workspaceConfig: WorkspaceConfig | null | undefined, + policy: SubmodulePolicy, + summary: WorkspaceSyncSummary, +): WorkspaceSyncApplyResult { + const result: WorkspaceSyncApplyResult = { + importedRepoIds: [], + initializedPaths: [], + updatedPaths: [], + warnings: [], + changed: false, + }; + + if (summary.importCandidates.length > 0 && workspaceConfig?.configPath) { + const parsed = yamlParse(readFileSync(workspaceConfig.configPath, "utf-8")) as Record | null; + const document = parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? { ...parsed } + : {}; + const existingRepos = document.repos && typeof document.repos === "object" && !Array.isArray(document.repos) + ? { ...(document.repos as Record) } + : {}; + + for (const candidate of summary.importCandidates) { + if (existingRepos[candidate.derivedRepoId] !== undefined) continue; + existingRepos[candidate.derivedRepoId] = { + path: relativeWorkspacePath(workspaceRoot, candidate.absolutePath), + }; + workspaceConfig.repos.set(candidate.derivedRepoId, { + id: candidate.derivedRepoId, + path: candidate.absolutePath, + }); + result.importedRepoIds.push(candidate.derivedRepoId); + result.changed = true; + } + + if (result.importedRepoIds.length > 0) { + const sortedRepos: Record = {}; + for (const [repoId, repoValue] of Object.entries(existingRepos).sort((left, right) => left[0].localeCompare(right[0]))) { + sortedRepos[repoId] = repoValue; + } + document.repos = sortedRepos; + writeYamlFileAtomically(workspaceConfig.configPath, `${yamlStringify(document)}`.trimEnd() + "\n"); + } + } + + const initGroups = groupPathsByRepo(summary.findings, ["uninitialized-submodule"]); + const updateGroups = groupPathsByRepo(summary.findings, ["drifted-submodule", "conflicted-submodule"]); + + if (policy.onSubmoduleDrift === "init-only") { + for (const [root, paths] of initGroups) { + if (paths.length === 0) continue; + const commandPaths = collapseToTrackedSubmoduleRoots(root, paths); + const gitResult = runGit(["submodule", "update", "--init", "--", ...commandPaths], root); + if (!gitResult.ok) { + result.warnings.push(WORKSPACE_MESSAGES.workspaceSyncInitFailure(root, gitResult.stderr || gitResult.stdout || "git submodule update failed")); + continue; + } + result.initializedPaths.push(...paths.map((pathValue) => `${basename(root)}:${pathValue}`)); + result.changed = true; + } + } else if (policy.onSubmoduleDrift === "recursive-on-drift") { + const recursiveGroups = new Map(); + for (const [root, paths] of initGroups) { + recursiveGroups.set(root, [...paths]); + } + for (const [root, paths] of updateGroups) { + const current = recursiveGroups.get(root) ?? []; + recursiveGroups.set(root, [...current, ...paths]); + } + for (const [root, paths] of recursiveGroups) { + const uniquePaths = [...new Set(paths)].sort((left, right) => left.localeCompare(right)); + if (uniquePaths.length === 0) continue; + const commandPaths = collapseToTrackedSubmoduleRoots(root, uniquePaths); + const gitResult = runGit(["submodule", "update", "--init", "--recursive", "--", ...commandPaths], root); + if (!gitResult.ok) { + result.warnings.push(WORKSPACE_MESSAGES.workspaceSyncRecursiveFailure(root, gitResult.stderr || gitResult.stdout || "git submodule update failed")); + continue; + } + const rootLabel = basename(root); + for (const pathValue of uniquePaths) { + const key = `${rootLabel}:${pathValue}`; + if (initGroups.get(root)?.includes(pathValue)) { + result.initializedPaths.push(key); + } + if (updateGroups.get(root)?.includes(pathValue)) { + result.updatedPaths.push(key); + } + } + result.changed = true; + } + } else if (initGroups.size > 0 || updateGroups.size > 0) { + result.warnings.push(WORKSPACE_MESSAGES.workspaceSyncManualModeWarning()); + } + + return result; +} + + // ── Execution Context Builder ──────────────────────────────────────── /** @@ -655,8 +1064,7 @@ export function buildExecutionContext( const wsConfigFile = workspaceConfigPath(cwd); throw new WorkspaceConfigError( "WORKSPACE_SETUP_REQUIRED", - `No workspace config found at ${wsConfigFile}, and current directory is not a git repository: ${cwd}. ` + - `Run Taskplane from a git repository, or create ${wsConfigFile} (taskplane init) to use workspace mode.`, + WORKSPACE_MESSAGES.workspaceSetupRequired(wsConfigFile, cwd), undefined, cwd, ); @@ -682,7 +1090,7 @@ export function buildExecutionContext( // Log pointer warning once at startup (non-fatal). if (pointer && pointer.warning) { - console.error(`[taskplane] pointer warning: ${pointer.warning}`); + console.error(WORKSPACE_MESSAGES.pointerWarningLog(pointer.warning)); } const pointerConfigRoot = pointer?.configRoot; diff --git a/extensions/taskplane/worktree.ts b/extensions/taskplane/worktree.ts index a9de4009..83278318 100644 --- a/extensions/taskplane/worktree.ts +++ b/extensions/taskplane/worktree.ts @@ -6,11 +6,13 @@ import { existsSync, mkdirSync, readdirSync, realpathSync, rmdirSync, rmSync } f import { execSync } from "child_process"; import { join, basename, resolve } from "path"; +import { loadProjectConfig } from "./config-loader.ts"; import { execLog } from "./execution.ts"; import { runGit } from "./git.ts"; import { resolveOperatorId } from "./naming.ts"; import { DEFAULT_ORCHESTRATOR_CONFIG, WorktreeError } from "./types.ts"; -import type { AllocatedLane, BulkWorktreeError, CreateLaneWorktreesResult, CreateWorktreeOptions, LaneTaskOutcome, OrchestratorConfig, PreflightCheck, PreflightResult, RemoveAllWorktreesResult, RemoveWorktreeOutcome, RemoveWorktreeResult, WorktreeInfo } from "./types.ts"; +import type { AllocatedLane, BulkWorktreeError, CreateLaneWorktreesResult, CreateWorktreeOptions, LaneTaskOutcome, OrchestratorConfig, PreflightCheck, PreflightResult, RemoveAllWorktreesResult, RemoveWorktreeOutcome, RemoveWorktreeResult, WorktreeInfo, WorkspaceConfig } from "./types.ts"; +import { DEFAULT_SUBMODULE_POLICY, collectWorkspaceSyncSummary, workspaceSyncSummaryToChecks } from "./workspace.ts"; // ── Worktree Helpers ───────────────────────────────────────────────── @@ -66,8 +68,8 @@ export function resolveWorktreeBasePath( * @param opId - Operator identifier (sanitized, e.g., "henrylach") * @param batchId - Batch ID timestamp (e.g. "20260308T111750") */ -export function generateBatchContainerName(opId: string, batchId: string): string { - return `${opId}-${batchId}`; +export function generateBatchContainerName(opId: string, batchId: string, repoId?: string): string { + return repoId ? `${opId}-${batchId}-${repoId}` : `${opId}-${batchId}`; } /** @@ -92,10 +94,11 @@ export function generateBatchContainerPath( batchId: string, repoRoot: string, config?: OrchestratorConfig, + repoId?: string, ): string { const effectiveConfig = config || DEFAULT_ORCHESTRATOR_CONFIG; const basePath = resolveWorktreeBasePath(repoRoot, effectiveConfig); - return resolve(basePath, generateBatchContainerName(opId, batchId)); + return resolve(basePath, generateBatchContainerName(opId, batchId, repoId)); } /** @@ -127,10 +130,11 @@ export function generateWorktreePath( opId: string, config?: OrchestratorConfig, batchId?: string, + repoId?: string, ): string { if (batchId) { // New batch-scoped container layout - const containerPath = generateBatchContainerPath(opId, batchId, repoRoot, config); + const containerPath = generateBatchContainerPath(opId, batchId, repoRoot, config, repoId); return resolve(containerPath, `lane-${laneNumber}`); } @@ -160,8 +164,9 @@ export function generateMergeWorktreePath( opId: string, batchId: string, config?: OrchestratorConfig, + repoId?: string, ): string { - const containerPath = generateBatchContainerPath(opId, batchId, repoRoot, config); + const containerPath = generateBatchContainerPath(opId, batchId, repoRoot, config, repoId); return resolve(containerPath, "merge"); } @@ -331,10 +336,10 @@ export function isRegisteredWorktree(targetPath: string, cwd: string): boolean { * @throws - WorktreeError with stable error code on failure */ export function createWorktree(opts: CreateWorktreeOptions, repoRoot: string): WorktreeInfo { - const { laneNumber, batchId, baseBranch, prefix, opId, config } = opts; + const { laneNumber, batchId, baseBranch, prefix, opId, config, repoId } = opts; const branch = generateBranchName(laneNumber, batchId, opId); - const worktreePath = generateWorktreePath(prefix, laneNumber, repoRoot, opId, config, batchId); + const worktreePath = generateWorktreePath(prefix, laneNumber, repoRoot, opId, config, batchId, repoId); // ── Pre-check 1: Validate base branch exists ───────────────── const baseBranchCheck = runGit( @@ -441,6 +446,7 @@ export function createWorktree(opts: CreateWorktreeOptions, repoRoot: string): W path: resolve(worktreePath), branch, laneNumber, + repoId, }; } @@ -1211,7 +1217,7 @@ export function preserveBranch( * only returns worktrees inside the `{opId}-{batchId}/` container * @returns - WorktreeInfo[] sorted by laneNumber (ascending) */ -export function listWorktrees(prefix: string, repoRoot: string, opId: string, batchId?: string): WorktreeInfo[] { +export function listWorktrees(prefix: string, repoRoot: string, opId: string, batchId?: string, repoId?: string): WorktreeInfo[] { const entries = parseWorktreeList(repoRoot); const results: WorktreeInfo[] = []; @@ -1234,7 +1240,11 @@ export function listWorktrees(prefix: string, repoRoot: string, opId: string, ba // When batchId is provided, match only the exact container for batch isolation. // When omitted, match any container belonging to this operator (all batches). const containerPattern = batchId - ? new RegExp(`^${escapeRegex(generateBatchContainerName(opId, batchId))}$`) + ? new RegExp( + repoId + ? `^${escapeRegex(generateBatchContainerName(opId, batchId, repoId))}$` + : `^${escapeRegex(generateBatchContainerName(opId, batchId))}(?:-.+)?$`, + ) : new RegExp(`^${escapeRegex(opId)}-\\S+$`); for (const entry of entries) { @@ -1323,6 +1333,7 @@ export function createLaneWorktrees( config: OrchestratorConfig, repoRoot: string, baseBranch: string, + repoId?: string, ): CreateLaneWorktreesResult { const prefix = config.orchestrator.worktree_prefix; const opId = resolveOperatorId(config); @@ -1332,7 +1343,7 @@ export function createLaneWorktrees( for (let lane = 1; lane <= count; lane++) { try { const wt = createWorktree( - { laneNumber: lane, batchId, baseBranch, prefix, opId, config }, + { laneNumber: lane, batchId, baseBranch, prefix, opId, repoId, config }, repoRoot, ); created.push(wt); @@ -1399,11 +1410,12 @@ export function ensureLaneWorktrees( config: OrchestratorConfig, repoRoot: string, baseBranch: string, + repoId?: string, ): CreateLaneWorktreesResult { const prefix = config.orchestrator.worktree_prefix; const opId = resolveOperatorId(config); - const existing = listWorktrees(prefix, repoRoot, opId, batchId); + const existing = listWorktrees(prefix, repoRoot, opId, batchId, repoId); const existingByLane = new Map(); for (const wt of existing) { existingByLane.set(wt.laneNumber, wt); @@ -1435,7 +1447,7 @@ export function ensureLaneWorktrees( try { const wt = createWorktree( - { laneNumber: lane, batchId, baseBranch, prefix, opId, config }, + { laneNumber: lane, batchId, baseBranch, prefix, opId, repoId, config }, repoRoot, ); createdNow.push(wt); @@ -1646,6 +1658,41 @@ export function meetsMinVersion(actual: [number, number], minimum: [number, numb return false; } +interface PreflightOptions { + workspaceRoot?: string; + pointerConfigRoot?: string; + workspaceConfig?: WorkspaceConfig | null; +} + +type PreflightSubmodulePolicy = { + failureMode: "permissive" | "strict"; + onSubmoduleDrift: "manual" | "init-only" | "recursive-on-drift"; + repoIdStrategy: "path-basename"; +}; + +function resolvePreflightSubmodulePolicy(repoRoot?: string, options?: PreflightOptions): PreflightSubmodulePolicy { + const configCwd = options?.workspaceRoot ?? repoRoot ?? process.cwd(); + try { + const fullConfig = loadProjectConfig(configCwd, options?.pointerConfigRoot); + return { + failureMode: fullConfig.orchestrator.failure.submoduleFailureMode ?? DEFAULT_SUBMODULE_POLICY.failureMode, + onSubmoduleDrift: fullConfig.orchestrator.failure.onSubmoduleDrift ?? DEFAULT_SUBMODULE_POLICY.onSubmoduleDrift, + repoIdStrategy: fullConfig.orchestrator.orchestrator.submoduleRepoIdStrategy ?? DEFAULT_SUBMODULE_POLICY.repoIdStrategy, + }; + } catch { + return { ...DEFAULT_SUBMODULE_POLICY }; + } +} + +function collectSubmoduleChecks( + repoRoot: string | undefined, + options: PreflightOptions | undefined, + policy: PreflightSubmodulePolicy, +): PreflightCheck[] { + const summary = collectWorkspaceSyncSummary(repoRoot, options?.workspaceConfig, policy); + return workspaceSyncSummaryToChecks(summary); +} + /** * Run preflight checks for all orchestrator dependencies. * @@ -1656,8 +1703,9 @@ export function meetsMinVersion(actual: [number, number], minimum: [number, numb * * Compatibility checks: * - Runtime backend mode visibility (subprocess-only) + * - Workspace submodule policy / drift visibility */ -export function runPreflight(config: OrchestratorConfig, repoRoot?: string): PreflightResult { +export function runPreflight(config: OrchestratorConfig, repoRoot?: string, options?: PreflightOptions): PreflightResult { const checks: PreflightCheck[] = []; // ── Git version ────────────────────────────────────────────── @@ -1728,6 +1776,9 @@ export function runPreflight(config: OrchestratorConfig, repoRoot?: string): Pre }); } + const submodulePolicy = resolvePreflightSubmodulePolicy(repoRoot, options); + checks.push(...collectSubmoduleChecks(repoRoot, options, submodulePolicy)); + return { passed: checks.every((c) => c.status !== "fail"), checks, diff --git a/extensions/tests/auto-integration.integration.test.ts b/extensions/tests/auto-integration.integration.test.ts index e7acad33..49c07c10 100644 --- a/extensions/tests/auto-integration.integration.test.ts +++ b/extensions/tests/auto-integration.integration.test.ts @@ -1564,6 +1564,62 @@ describe("16.x — presentBatchSummary", () => { const text = pi.messages[0].opts.content[0].text; expect(text).toContain("3 task(s)"); }); + + it("16.29: summary file preserves rollback safe-stop reason and persistence warning after resume", () => { + const pi = makeMockPi(); + const now = Date.now(); + const batchState = makeIntegrationBatchState({ + phase: "paused", + failedTasks: 1, + succeededTasks: 0, + totalTasks: 1, + waveResults: [ + { + waveIndex: 0, + startedAt: now - 5_000, + endedAt: now - 4_000, + laneResults: [], + policyApplied: "skip-dependents", + stoppedEarly: false, + failedTaskIds: ["TP-001"], + skippedTaskIds: [], + succeededTaskIds: [], + blockedTaskIds: [], + laneCount: 1, + overallStatus: "failed", + finalMonitorState: null, + allocatedLanes: [], + }, + ] as any, + }); + + presentBatchSummary( + pi as any, + batchState, + tmpDir, + "op1", + undefined, + [ + { + waveIndex: 0, + status: "failed", + failedLane: 1, + failureReason: + "Safe-stop at wave 1: verification rollback failed. WARNING: 1 transaction record(s) failed to persist — recovery file(s) may be missing.", + }, + ], + ); + + expect(pi.messages.length).toBe(1); + expect(pi.messages[0].opts.content[0].text).toContain("📊 **Batch Summary**"); + expect(pi.messages[0].opts.content[0].text).toContain("1 task(s)"); + + const filepath = join(tmpDir, ".pi", "supervisor", "op1-20260322T120000-summary.md"); + const summary = readFileSync(filepath, "utf-8"); + expect(summary).toContain("merge failed: Safe-stop at wave 1: verification rollback failed."); + expect(summary).toContain("transaction record(s) failed to persist"); + expect(summary).toContain("recovery file(s) may be missing"); + }); }); // ═════════════════════════════════════════════════════════════════════ diff --git a/extensions/tests/diagnostic-reports.test.ts b/extensions/tests/diagnostic-reports.test.ts index 9c5dd749..82e51bb0 100644 --- a/extensions/tests/diagnostic-reports.test.ts +++ b/extensions/tests/diagnostic-reports.test.ts @@ -372,8 +372,9 @@ describe("buildMarkdownReport", () => { const report = buildMarkdownReport(input, events); expect(report).toContain("## Per-Task Results"); - expect(report).toContain("| TP-001 | succeeded | completed | $0.1000 |"); - expect(report).toContain("| TP-002 | failed | crash | $0.0500 |"); + expect(report).toContain("| Task | Status | Classification | Reason |"); + expect(report).toContain("| TP-001 | succeeded | completed | completed normally | $0.1000 |"); + expect(report).toContain("| TP-002 | failed | crash | crash | $0.0500 |"); }); it("shows empty message when no tasks", () => { @@ -407,12 +408,33 @@ describe("buildMarkdownReport", () => { expect(report).toContain("## Per-Repo Breakdown"); expect(report).toContain("### backend"); expect(report).toContain("### frontend"); + expect(report).toContain("| Task | Status | Classification | Reason | Cost | Duration |"); // frontend has 2 tasks (1 succeeded, 1 failed) expect(report).toContain("Tasks: 2 (1 succeeded, 1 failed)"); // backend has 1 task (1 succeeded, 0 failed) expect(report).toContain("Tasks: 1 (1 succeeded, 0 failed)"); }); + it("includes exit reasons in the per-task report output", () => { + const input = makeInput({ + tasks: [ + makeTask("TP-009", { + status: "failed", + exitReason: "Unsafe submodule state after task success: third_party/tools/bof3-disk has uncommitted submodule changes", + }), + ], + diagnostics: { + taskExits: { + "TP-009": { classification: "process_crash", cost: 0, durationSec: 12, retries: 0 }, + }, + batchCost: 0, + }, + }); + const report = buildMarkdownReport(input, buildDiagnosticEvents(input)); + + expect(report).toContain("Unsafe submodule state after task success"); + }); + it("does NOT include per-repo breakdown in repo mode", () => { const input = makeInput({ mode: "repo", diff --git a/extensions/tests/discovery-routing.test.ts b/extensions/tests/discovery-routing.test.ts index e02f7a5c..03dbe4e1 100644 --- a/extensions/tests/discovery-routing.test.ts +++ b/extensions/tests/discovery-routing.test.ts @@ -6,8 +6,8 @@ * * Test categories: * 1.x — Prompt with no execution target (backward compat) - * 2.x — Section-based `## Execution Target` with `Repo:` line - * 3.x — Inline `**Repo:** ` declaration + * 2.x — Section-based `## Execution Target` with `Repo:` or `Repos:` line + * 3.x — Inline `**Repo:** ` or `**Repos:** ...` declaration * 4.x — Whitespace/case/markdown decoration variants * 5.x — Both section + inline present (section wins) * 6.x — Invalid repo ID format (non-matching = undefined) @@ -220,6 +220,53 @@ Repo: my-cool-service-2 expect(result.error).toBeNull(); expect(result.task!.promptRepoId).toBe("my-cool-service-2"); }); + + it("2.5: parses ordered repo list from Repos: line", () => { + const dir = makeTestDir("section-repos"); + const content = minimalPrompt(` +## Execution Target + +Repos: dashboard, administration +`); + const promptPath = writePrompt(dir, content); + const result = parsePromptForOrchestrator(promptPath, dir, "default"); + + expect(result.error).toBeNull(); + expect(result.task!.promptRepoId).toBe("dashboard"); + expect(result.task!.promptRepoIds).toEqual(["dashboard", "administration"]); + }); + + it("2.6: normalizes and de-duplicates Repos: values", () => { + const dir = makeTestDir("section-repos-normalized"); + const content = minimalPrompt(` +## Execution Target + +**Repos:** API, frontend-app, api +`); + const promptPath = writePrompt(dir, content); + const result = parsePromptForOrchestrator(promptPath, dir, "default"); + + expect(result.error).toBeNull(); + expect(result.task!.promptRepoId).toBe("api"); + expect(result.task!.promptRepoIds).toEqual(["api", "frontend-app"]); + }); + + it("2.7: parses ordered repo list from Repos bullet list", () => { + const dir = makeTestDir("section-repos-bullets"); + const content = minimalPrompt(` +## Execution Target + +Repos: +- dashboard +- administration +`); + const promptPath = writePrompt(dir, content); + const result = parsePromptForOrchestrator(promptPath, dir, "default"); + + expect(result.error).toBeNull(); + expect(result.task!.promptRepoId).toBe("dashboard"); + expect(result.task!.promptRepoIds).toEqual(["dashboard", "administration"]); + }); }); // ── 3.x: Inline `**Repo:** ` declaration ──────────────────────── @@ -277,6 +324,34 @@ describe("3.x: Inline repo declaration", () => { expect(result.error).toBeNull(); expect(result.task!.promptRepoId).toBe("frontend"); }); + + it("3.3: parses inline **Repos:** field", () => { + const dir = makeTestDir("inline-repos"); + const content = `# Task: TP-100 - Test Task + +**Created:** 2026-03-15 +**Size:** M +**Repos:** api, frontend + +## Dependencies + +**None** + +## Steps + +### Step 0: Do something + +- [ ] Something + +--- +`; + const promptPath = writePrompt(dir, content); + const result = parsePromptForOrchestrator(promptPath, dir, "default"); + + expect(result.error).toBeNull(); + expect(result.task!.promptRepoId).toBe("api"); + expect(result.task!.promptRepoIds).toEqual(["api", "frontend"]); + }); }); // ── 4.x: Whitespace/case/markdown variants ────────────────────────── @@ -671,6 +746,7 @@ function makeWorkspaceConfig( routing: { tasksRoot: "/workspace/tasks", defaultRepo, + taskPacketRepo: defaultRepo, }, configPath: "/workspace/.pi/taskplane-workspace.yaml", }; @@ -743,6 +819,29 @@ describe("9.x: Prompt repo wins over area and default", () => { expect(errors).toHaveLength(0); expect(task.resolvedRepoId).toBe("api"); + expect(task.resolvedRepoIds).toEqual(["api"]); + }); + + it("9.1b: promptRepoIds are preserved after routing while primary repo stays first", () => { + const workspaceConfig = makeWorkspaceConfig( + { + dashboard: { path: "/repos/dashboard" }, + administration: { path: "/repos/administration" }, + shared: { path: "/repos/shared" }, + }, + "shared", + ); + const taskAreas: Record = { + default: { path: "/workspace/tasks", prefix: "TP", context: "", repoId: "shared" }, + }; + const task = makeTask({ taskId: "TP-101", areaName: "default", promptRepoIds: ["dashboard", "administration"] }); + const discovery = makeDiscoveryResult([task]); + + const errors = resolveTaskRouting(discovery, taskAreas, workspaceConfig); + + expect(errors).toHaveLength(0); + expect(task.resolvedRepoId).toBe("dashboard"); + expect(task.resolvedRepoIds).toEqual(["dashboard", "administration"]); }); it("9.2: promptRepoId overrides area repoId", () => { @@ -763,6 +862,7 @@ describe("9.x: Prompt repo wins over area and default", () => { expect(errors).toHaveLength(0); expect(task.resolvedRepoId).toBe("api"); + expect(task.resolvedRepoIds).toEqual(["api"]); }); }); @@ -849,6 +949,7 @@ describe("11.x: Default repo fallback when prompt + area have no repo", () => { expect(errors).toHaveLength(0); expect(task.resolvedRepoId).toBe("api"); + expect(task.resolvedRepoIds).toEqual(["api"]); }); it("11.2: area without repoId and no promptRepoId falls to default", () => { @@ -947,7 +1048,7 @@ describe("11.5x: File scope inference routes task to matching repo", () => { expect(task.resolvedRepoId).toBe("api-service"); // falls to default }); - it("11.54: prompt repo still wins over file scope", () => { + it("11.54: explicit execution target conflicting with file scope returns a mismatch error", () => { const workspaceConfig = makeWorkspaceConfig( { "api-service": { path: "/repos/api-service" }, @@ -968,8 +1069,11 @@ describe("11.5x: File scope inference routes task to matching repo", () => { const errors = resolveTaskRouting(discovery, taskAreas, workspaceConfig); - expect(errors).toHaveLength(0); - expect(task.resolvedRepoId).toBe("api-service"); // prompt wins + expect(errors).toHaveLength(1); + expect(errors[0].code).toBe("TASK_REPO_SCOPE_MISMATCH"); + expect(errors[0].message).toContain("api-service"); + expect(errors[0].message).toContain("web-client"); + expect(task.resolvedRepoId).toBeUndefined(); }); it("11.55: empty file scope falls through to default", () => { @@ -994,6 +1098,36 @@ describe("11.5x: File scope inference routes task to matching repo", () => { expect(errors).toHaveLength(0); expect(task.resolvedRepoId).toBe("api-service"); }); + + it("11.56: mixed repo-prefixed file scope preserves ordered multi-repo routing", () => { + const workspaceConfig = makeWorkspaceConfig( + { + "api-service": { path: "/repos/api-service" }, + "web-client": { path: "/repos/web-client" }, + "shared-libs": { path: "/repos/shared-libs" }, + }, + "shared-libs", + ); + const taskAreas: Record = { + general: { path: "/workspace/tasks", prefix: "TP", context: "" }, + }; + const task = makeTask({ + taskId: "TP-009", + areaName: "general", + fileScope: [ + "web-client/src/App.js", + "api-service/src/routes/status.js", + "web-client/src/components/Nav.js", + ], + }); + const discovery = makeDiscoveryResult([task]); + + const errors = resolveTaskRouting(discovery, taskAreas, workspaceConfig); + + expect(errors).toHaveLength(0); + expect(task.resolvedRepoId).toBe("web-client"); + expect(task.resolvedRepoIds).toEqual(["web-client", "api-service"]); + }); }); // ── 12.x: TASK_REPO_UNKNOWN ───────────────────────────────────────── @@ -1076,6 +1210,7 @@ describe("13.x: TASK_REPO_UNRESOLVED when all sources are undefined", () => { routing: { tasksRoot: "/workspace/tasks", defaultRepo: "", // empty default + taskPacketRepo: "api", }, configPath: "/workspace/.pi/taskplane-workspace.yaml", }; @@ -1269,7 +1404,7 @@ describe("16.x: Routing errors appear as fatal errors in formatted output", () = const workspaceConfig: WorkspaceConfig = { mode: "workspace", repos: repoMap, - routing: { tasksRoot: "/workspace/tasks", defaultRepo: "" }, + routing: { tasksRoot: "/workspace/tasks", defaultRepo: "", taskPacketRepo: "api" }, configPath: "/workspace/.pi/taskplane-workspace.yaml", }; const taskAreas: Record = { @@ -1620,7 +1755,7 @@ Repo: nonexistent const workspaceConfig: WorkspaceConfig = { mode: "workspace", repos: repoMap, - routing: { tasksRoot: "/workspace/tasks", defaultRepo: "" }, // empty default + routing: { tasksRoot: "/workspace/tasks", defaultRepo: "", taskPacketRepo: "api" }, // empty default configPath: "/workspace/.pi/taskplane-workspace.yaml", }; @@ -1839,6 +1974,19 @@ describe("16.x: formatDiscoveryResults repo annotation", () => { expect(output).toContain("→ repo: api"); }); + it("16.1b: shows plural repo annotation for tasks with resolvedRepoIds", () => { + const task = makeTask({ taskId: "TP-101", areaName: "default" }); + task.resolvedRepoId = "api"; + task.resolvedRepoIds = ["api", "frontend"]; + const discovery = makeDiscoveryResult([task]); + + const output = formatDiscoveryResults(discovery); + + expect(output).toContain("TP-101"); + expect(output).toContain("→ repos: api, frontend"); + expect(output).not.toContain("→ repo: api"); + }); + it("16.2: omits repo annotation when resolvedRepoId is absent", () => { const task = makeTask({ taskId: "TP-100", areaName: "default" }); // no resolvedRepoId @@ -1900,6 +2048,7 @@ describe("17.x: Actionable routing error guidance", () => { routing: { tasksRoot: "/workspace/tasks", defaultRepo: "", // empty = unresolvable + taskPacketRepo: "api", }, configPath: "/workspace/.pi/taskplane-workspace.yaml", }; @@ -2179,6 +2328,95 @@ describe("20.x: Strict mode — accepts tasks with explicit execution target", ( expect(task2.resolvedRepoId).toBeUndefined(); expect(task3.resolvedRepoId).toBe("frontend"); }); + + it("20.4: strict mode accepts explicit promptRepoIds and routes to the first declared repo", () => { + const workspaceConfig = makeWorkspaceConfig( + { + api: { path: "/repos/api" }, + frontend: { path: "/repos/frontend" }, + }, + "api", + ); + workspaceConfig.routing.strict = true; + + const taskAreas: Record = { + default: { path: "/workspace/tasks", prefix: "TP", context: "" }, + }; + const task = makeTask({ + taskId: "TP-110", + areaName: "default", + promptRepoId: "api", + promptRepoIds: ["api", "frontend"], + }); + const discovery = makeDiscoveryResult([task]); + + const errors = resolveTaskRouting(discovery, taskAreas, workspaceConfig); + + expect(errors).toHaveLength(0); + expect(task.resolvedRepoId).toBe("api"); + expect(task.resolvedRepoIds).toEqual(["api", "frontend"]); + }); + + it("20.5: strict mode rejects promptRepoIds containing an unknown repo", () => { + const workspaceConfig = makeWorkspaceConfig( + { + api: { path: "/repos/api" }, + frontend: { path: "/repos/frontend" }, + }, + "api", + ); + workspaceConfig.routing.strict = true; + + const taskAreas: Record = { + default: { path: "/workspace/tasks", prefix: "TP", context: "" }, + }; + const task = makeTask({ + taskId: "TP-111", + areaName: "default", + promptRepoId: "api", + promptRepoIds: ["api", "ghost"], + }); + const discovery = makeDiscoveryResult([task]); + + const errors = resolveTaskRouting(discovery, taskAreas, workspaceConfig); + + expect(errors).toHaveLength(1); + expect(errors[0].code).toBe("TASK_REPO_UNKNOWN"); + expect(errors[0].message).toContain("Execution Target Repos"); + expect(errors[0].message).toContain("ghost"); + }); + + it("20.6: explicit execution targets must cover repo-prefixed file scope entries", () => { + const workspaceConfig = makeWorkspaceConfig( + { + api: { path: "/repos/api" }, + frontend: { path: "/repos/frontend" }, + docs: { path: "/repos/docs" }, + }, + "api", + ); + workspaceConfig.routing.strict = true; + + const taskAreas: Record = { + default: { path: "/workspace/tasks", prefix: "TP", context: "" }, + }; + const task = makeTask({ + taskId: "TP-112", + areaName: "default", + promptRepoId: "api", + promptRepoIds: ["api", "frontend"], + fileScope: ["api/src/server.ts", "docs/content/index.md"], + }); + const discovery = makeDiscoveryResult([task]); + + const errors = resolveTaskRouting(discovery, taskAreas, workspaceConfig); + + expect(errors).toHaveLength(1); + expect(errors[0].code).toBe("TASK_REPO_SCOPE_MISMATCH"); + expect(errors[0].message).toContain("api, frontend"); + expect(errors[0].message).toContain("docs"); + expect(task.resolvedRepoId).toBeUndefined(); + }); }); // ── 21.x: Permissive mode — unchanged behavior (non-regression) ───── @@ -2257,6 +2495,11 @@ describe("22.x: TASK_ROUTING_STRICT is classified as fatal", () => { expect(fatalCodes.has("TASK_ROUTING_STRICT")).toBe(true); }); + it("22.1b: TASK_REPO_SCOPE_MISMATCH is in FATAL_DISCOVERY_CODES", () => { + const fatalCodes = new Set(FATAL_DISCOVERY_CODES); + expect(fatalCodes.has("TASK_REPO_SCOPE_MISMATCH")).toBe(true); + }); + it("22.2: formatDiscoveryResults classifies TASK_ROUTING_STRICT as error not warning", () => { const task = makeTask({ taskId: "TP-100", areaName: "default" }); const discovery = makeDiscoveryResult([task]); @@ -2558,6 +2801,24 @@ describe("25.x: Command surface TASK_ROUTING_STRICT remediation hints", () => { expect(engineSrc).toContain("hasRoutingErrors"); expect(engineSrc).toContain("hasStrictErrors"); }); + + it("25.7: extension.ts treats TASK_REPO_SCOPE_MISMATCH as a routing error", () => { + const extensionSrc = readFileSync( + join(__dirname, "..", "taskplane", "extension.ts"), + "utf-8", + ); + expect(extensionSrc).toContain('"TASK_REPO_SCOPE_MISMATCH"'); + expect(extensionSrc).toContain("repo-prefixed file scope entries"); + }); + + it("25.8: engine.ts treats TASK_REPO_SCOPE_MISMATCH as a routing error", () => { + const engineSrc = readFileSync( + join(__dirname, "..", "taskplane", "engine.ts"), + "utf-8", + ); + expect(engineSrc).toContain('"TASK_REPO_SCOPE_MISMATCH"'); + expect(engineSrc).toContain("repo-prefixed file scope entries"); + }); }); diff --git a/extensions/tests/engine-runtime-v2-routing.test.ts b/extensions/tests/engine-runtime-v2-routing.test.ts index 3df51761..26b0ca7c 100644 --- a/extensions/tests/engine-runtime-v2-routing.test.ts +++ b/extensions/tests/engine-runtime-v2-routing.test.ts @@ -10,7 +10,7 @@ import { describe, it } from "node:test"; import { expect } from "./expect.ts"; -import { mkdtempSync, readFileSync, rmSync } from "fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; @@ -27,6 +27,7 @@ const { } = await import("../taskplane/lane-runner.ts"); const { resolveTaskMonitorState, + parseStatusMdAtPath, } = await import("../taskplane/execution.ts"); const { writeLaneSnapshot, @@ -667,6 +668,88 @@ describe("14.x: Monitor de-TMUX for V2 (TP-112)", () => { } }); + it("14.10b: parseStatusMdAtPath extracts authoritative Current Step header", async () => { + const root = mkdtempSync(join(tmpdir(), "tp-127-status-")); + const statusPath = join(root, "STATUS.md"); + try { + writeFileSync(statusPath, [ + "# TP-127: Status", + "", + "**Current Step:** Step 1: Implement feature", + "**Status:** 🟡 In Progress", + "**Review Counter:** 2", + "**Iteration:** 3", + "", + "### Step 0: Preflight", + "**Status:** Pending", + "- [x] Verify files", + "", + "### Step 1: Implement feature", + "**Status:** Pending", + "- [ ] Ship fix", + ].join("\n")); + + const parsed = await parseStatusMdAtPath(statusPath); + expect(parsed.error).toBe(null); + expect(parsed.parsed?.currentStepLabel).toBe("Step 1: Implement feature"); + expect(parsed.parsed?.currentStepName).toBe("Implement feature"); + expect(parsed.parsed?.currentStepNumber).toBe(1); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("14.10c: resolveTaskMonitorState prefers Current Step header over stale step statuses", async () => { + const root = mkdtempSync(join(tmpdir(), "tp-127-current-step-")); + const now = Date.now(); + const batchId = "b-current-step"; + const taskId = "TP-127"; + const statusPath = join(root, "STATUS.md"); + try { + writeFileSync(statusPath, [ + "# TP-127: Status", + "", + "**Current Step:** Step 1: Implement feature", + "**Status:** 🟡 In Progress", + "**Review Counter:** 0", + "**Iteration:** 1", + "", + "### Step 0: Preflight", + "**Status:** Pending", + "- [x] Verify files", + "", + "### Step 1: Implement feature", + "**Status:** Pending", + "- [ ] Ship fix", + ].join("\n")); + + writeLaneSnapshot(root, batchId, 1, { + taskId, + status: "running", + updatedAt: now, + }); + + const statusResult = await parseStatusMdAtPath(statusPath); + const snapshot = await resolveTaskMonitorState( + taskId, + join(root, ".DONE"), + "lane-1", + statusResult, + freshTracker(taskId, now), + 30 * 60_000, + now, + "v2", + { stateRoot: root, batchId, laneNumber: 1 }, + ); + + expect(snapshot.status).toBe("running"); + expect(snapshot.currentStepName).toBe("Implement feature"); + expect(snapshot.currentStepNumber).toBe(1); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it("14.11: current complete snapshot marks session as dead", async () => { const root = mkdtempSync(join(tmpdir(), "tp-127-")); const now = Date.now(); diff --git a/extensions/tests/engine-segment-frontier.test.ts b/extensions/tests/engine-segment-frontier.test.ts index 624bb892..a62a9690 100644 --- a/extensions/tests/engine-segment-frontier.test.ts +++ b/extensions/tests/engine-segment-frontier.test.ts @@ -58,6 +58,7 @@ describe("TP-133 segment frontier helpers", () => { const task = pending.get("TP-001")!; expect(task.segmentIds).toEqual(["TP-001::api"]); + expect(task.resolvedRepoIds).toEqual(["api"]); expect(task.activeSegmentId).toBeNull(); }); @@ -106,6 +107,7 @@ describe("TP-133 segment frontier helpers", () => { expect(frontier.taskLevelWaveCount).toBe(1); expect(frontier.roundToTaskWave).toEqual([0, 0, 0]); const state = frontier.taskStateById.get("TP-010")!; + expect(pending.get("TP-010")!.resolvedRepoIds).toEqual(["api", "web", "docs"]); expect(state.orderedSegments.map((s) => s.segmentId)).toEqual([ "TP-010::api", "TP-010::web", @@ -133,6 +135,42 @@ describe("TP-133 segment frontier helpers", () => { expect(order.slice(0, 2).sort()).toEqual(["TP-020::api", "TP-020::web"]); }); + it("frontier expansion honors explicit DAG dependencies even when segment order conflicts", () => { + const pending = new Map([ + ["TP-021", makeTask("TP-021", "api")], + ]); + + const plan: TaskSegmentPlan = { + taskId: "TP-021", + mode: "explicit-dag", + segments: [ + { segmentId: "TP-021::docs", taskId: "TP-021", repoId: "docs", order: 0 }, + { segmentId: "TP-021::api", taskId: "TP-021", repoId: "api", order: 1 }, + { segmentId: "TP-021::web", taskId: "TP-021", repoId: "web", order: 2 }, + ], + edges: [ + { fromSegmentId: "TP-021::api", toSegmentId: "TP-021::docs", provenance: "explicit", reason: "explicit" }, + { fromSegmentId: "TP-021::web", toSegmentId: "TP-021::docs", provenance: "explicit", reason: "explicit" }, + ], + }; + + const frontier = buildSegmentFrontierWaves( + [["TP-021"]], + pending, + new Map([["TP-021", plan]]), + ); + + expect(frontier.waves).toEqual([["TP-021"], ["TP-021"], ["TP-021"]]); + const state = frontier.taskStateById.get("TP-021")!; + expect(state.orderedSegments.map((segment) => segment.segmentId)).toEqual([ + "TP-021::api", + "TP-021::web", + "TP-021::docs", + ]); + expect(pending.get("TP-021")!.participatingRepoIds).toEqual(["api", "web", "docs"]); + expect(pending.get("TP-021")!.resolvedRepoIds).toEqual(["api", "web", "docs"]); + }); + it("buildExecutionUnit uses packet-home STATUS/.DONE paths when provided", () => { const lane: AllocatedLane = { laneNumber: 1, diff --git a/extensions/tests/engine-worker-thread.test.ts b/extensions/tests/engine-worker-thread.test.ts index e9707f81..dba7ad2d 100644 --- a/extensions/tests/engine-worker-thread.test.ts +++ b/extensions/tests/engine-worker-thread.test.ts @@ -173,6 +173,43 @@ describe("2.x — Batch state serialization (applySerializedState)", () => { expect(target.pauseSignal.paused).toBe(true); expect(target.dependencyGraph).not.toBeNull(); }); + + it("2.3: applySerializedState copies merge panel state for widget updates", () => { + const target = freshOrchBatchState(); + const serialized: SerializedBatchState = { + phase: "merging", + batchId: "20260326T140000", + baseBranch: "main", + orchBranch: "orch/op-20260326T140000", + mode: "repo", + currentWaveIndex: 1, + totalWaves: 3, + totalTasks: 8, + succeededTasks: 4, + failedTasks: 0, + skippedTasks: 0, + blockedTasks: 0, + startedAt: 4000, + endedAt: null, + errors: [], + currentLanes: [], + mergePanel: { + status: "warning", + waveLabel: "Wave 2", + events: [ + { level: "info", message: "Lane 1 merged" }, + { level: "warning", message: "Lane 2 needs attention" }, + ], + }, + }; + + applySerializedState(target, serialized); + + expect(target.mergePanel).not.toBeUndefined(); + expect(target.mergePanel?.status).toBe("warning"); + expect(target.mergePanel?.waveLabel).toBe("Wave 2"); + expect(target.mergePanel?.events).toEqual(serialized.mergePanel?.events); + }); }); // ══════════════════════════════════════════════════════════════════════ diff --git a/extensions/tests/exit-classification.test.ts b/extensions/tests/exit-classification.test.ts index 8fcf7741..17a1816f 100644 --- a/extensions/tests/exit-classification.test.ts +++ b/extensions/tests/exit-classification.test.ts @@ -143,6 +143,30 @@ describe("classifyExit — all 9 classification paths", () => { }), expected: "process_crash", }, + { + name: "unsafe_submodule_dirty — explicit dirty submodule rejection", + input: makeInput({ + unsafeSubmoduleKind: "dirty-worktree", + exitSummary: makeSummary({ exitCode: 1, error: "Unsafe submodule state after task success: repo has uncommitted submodule changes" }), + }), + expected: "unsafe_submodule_dirty", + }, + { + name: "unsafe_submodule_unpublished_commit — explicit local-only submodule commit rejection", + input: makeInput({ + unsafeSubmoduleKind: "unpublished-commit", + exitSummary: makeSummary({ exitCode: 1, error: "Unsafe submodule state after task success: repo points to local commit abcdef12 not reachable on origin" }), + }), + expected: "unsafe_submodule_unpublished_commit", + }, + { + name: "unsafe_submodule_unreachable_ref — explicit unreachable gitlink rejection", + input: makeInput({ + unsafeSubmoduleKind: "unreachable-ref", + exitSummary: makeSummary({ exitCode: 1, error: "submodule_unreachable_ref: libs/my_lib@abcdef12 on origin" }), + }), + expected: "unsafe_submodule_unreachable_ref", + }, { name: "wall_clock_timeout — timer killed the session", input: makeInput({ timerKilled: true }), @@ -340,6 +364,33 @@ describe("classifyExit — precedence collisions", () => { expected: "api_error", rationale: "API error (priority 2) beats context kill (priority 3b)", }, + { + name: "unsafe_submodule_unpublished_commit beats process_crash", + input: makeInput({ + unsafeSubmoduleKind: "unpublished-commit", + exitSummary: makeSummary({ exitCode: 1, error: "Unsafe submodule state after task success: repo points to local commit abcdef12 not reachable on origin" }), + }), + expected: "unsafe_submodule_unpublished_commit", + rationale: "Explicit runtime safety rejection is more specific than a generic non-zero exit", + }, + { + name: "unsafe_submodule_dirty beats wall_clock_timeout", + input: makeInput({ + unsafeSubmoduleKind: "dirty-worktree", + timerKilled: true, + }), + expected: "unsafe_submodule_dirty", + rationale: "Runtime safety rejection is the root cause when both signals appear", + }, + { + name: "unsafe_submodule_unreachable_ref beats session_vanished", + input: makeInput({ + unsafeSubmoduleKind: "unreachable-ref", + exitSummary: null, + }), + expected: "unsafe_submodule_unreachable_ref", + rationale: "Explicit unsafe-submodule classification is authoritative even without a wrapper summary", + }, { name: "wall_clock_timeout beats process_crash (non-zero exit)", input: makeInput({ @@ -477,13 +528,14 @@ describe("classifyExit — edge cases", () => { // ── 4. Constants Verification ──────────────────────────────────────── describe("EXIT_CLASSIFICATIONS constant", () => { - it("contains exactly 10 values", () => { - expect(EXIT_CLASSIFICATIONS).toHaveLength(10); + it("contains exactly 13 values", () => { + expect(EXIT_CLASSIFICATIONS).toHaveLength(13); }); it("includes all expected values", () => { const expected: ExitClassification[] = [ "completed", "api_error", "model_access_error", "context_overflow", + "unsafe_submodule_dirty", "unsafe_submodule_unpublished_commit", "unsafe_submodule_unreachable_ref", "wall_clock_timeout", "process_crash", "session_vanished", "stall_timeout", "user_killed", "unknown", ]; diff --git a/extensions/tests/filter-artifact-status-lines.test.ts b/extensions/tests/filter-artifact-status-lines.test.ts new file mode 100644 index 00000000..abc91dd5 --- /dev/null +++ b/extensions/tests/filter-artifact-status-lines.test.ts @@ -0,0 +1,86 @@ +/** + * Tests for filterArtifactStatusLines — artifact directory filtering. + * + * Regression test for TP-XXX: task artifact modifications at worktree root level + * (.pi/tasks/*/STATUS.md, .pi/tasks/*/.DONE) were incorrectly passing through the + * submodule dirty-state filter, causing "unsafe submodule state" checkpoint failures + * even when no actual code was modified inside submodules. + */ + +import { describe, it } from "node:test"; +import { expect } from "./expect.ts"; + +describe("filterArtifactStatusLines", () => { + // We can't directly import the private function, so we test through its behavior + // by simulating what detectUnsafeSubmoduleStates does with git status output. + + function simulateFilter( + statusOutput: string, + knownPaths: Set, + ): boolean { + // Simulates filterArtifactStatusLines logic from git.ts (lines 110-143) + const TASKPLANE_ARTIFACTS = new Set([".pi/tasks", ".pi/supervisor"]); + const artifacts = new Set(knownPaths); + + const filtered = statusOutput + .split(/\r?\n/) + .filter((line) => { + if (!line.trim()) return false; + const parts = line.trimStart().split(/\s+/); + if (parts.length >= 2) { + const filePath = parts[1]; + for (const known of artifacts) { + if (filePath === known || filePath.startsWith(known + "/")) { + return false; + } + } + for (const artifact of TASKPLANE_ARTIFACTS) { + if (filePath === artifact || filePath.startsWith(artifact + "/")) { + return false; + } + } + } + return true; + }) + .join("\n"); + + return filtered.trim().length > 0; // returns true if "dirty" after filtering + } + + it("filters out .pi/tasks/STATUS.md artifact lines", () => { + const buggyStatus = "M .pi/tasks/disk/DISK-001-disk-client-inventory-publish-hardening/STATUS.md\n?? .pi/tasks/disk/DISK-001-disk-client-inventory-publish-hardening/.DONE"; + expect(simulateFilter(buggyStatus, new Set())).toBe(false); // should be clean after filtering + }); + + it("filters out .pi/supervisor/ artifact lines", () => { + const status = "M .pi/supervisor/events.jsonl\n?? .pi/supervisor/koija-summary.md"; + expect(simulateFilter(status, new Set())).toBe(false); + }); + + it("keeps actual submodule dirty state", () => { + const knownPaths = new Set(["third_party/tools/rabbitizer"]); + const status = "M third_party/tools/rabbitizer/src/mips.cc\n?? third_party/tools/rabbitizer/src/new_file.c"; + expect(simulateFilter(status, knownPaths)).toBe(true); // should remain dirty + }); + + it("filters other-submodule paths but keeps non-matching submodules", () => { + const knownPaths = new Set([ + "third_party/tools/asm-differ", + "third_party/tools/rabbitizer", + "third_party/tools/m2c", + ]); + const status = "M third_party/tools/asm-differ\n?? third_party/tools/rabbitizer/foo.txt"; + const result = simulateFilter(status, knownPaths); + expect(result).toBe(true); // rabbitizer path remains dirty since it's a real change + }); + + it("handles empty and whitespace-only input", () => { + expect(simulateFilter("", new Set())).toBe(false); + expect(simulateFilter("\n\n \n", new Set())).toBe(false); + }); + + it("does not filter partial path matches (e.g., .pi/tasks-backup)", () => { + const status = "?? .pi/tasks-backup/somefile.txt"; + expect(simulateFilter(status, new Set())).toBe(true); // should NOT be filtered + }); +}); diff --git a/extensions/tests/fixtures/batch-state-v2-polyrepo.json b/extensions/tests/fixtures/batch-state-v2-polyrepo.json index 9e1e0241..bc0dfcad 100644 --- a/extensions/tests/fixtures/batch-state-v2-polyrepo.json +++ b/extensions/tests/fixtures/batch-state-v2-polyrepo.json @@ -54,6 +54,7 @@ "endedAt": 1741478500000, "doneFileFound": true, "exitReason": "Task completed successfully", + "resolvedRepoIds": ["docs"], "resolvedRepoId": "docs" }, { @@ -66,6 +67,7 @@ "endedAt": 1741478520000, "doneFileFound": true, "exitReason": "Task completed successfully", + "resolvedRepoIds": ["api"], "resolvedRepoId": "api" }, { @@ -79,6 +81,7 @@ "doneFileFound": true, "exitReason": "Task completed successfully", "repoId": "frontend", + "resolvedRepoIds": ["frontend"], "resolvedRepoId": "frontend" }, { @@ -91,6 +94,7 @@ "endedAt": null, "doneFileFound": false, "exitReason": "", + "resolvedRepoIds": ["api"], "resolvedRepoId": "api" }, { @@ -104,6 +108,7 @@ "doneFileFound": false, "exitReason": "", "repoId": "frontend", + "resolvedRepoIds": ["frontend"], "resolvedRepoId": "frontend" }, { @@ -116,12 +121,14 @@ "endedAt": null, "doneFileFound": false, "exitReason": "", + "resolvedRepoIds": ["docs"], "resolvedRepoId": "docs" } ], "mergeResults": [ { "waveIndex": 0, + "waveTransactionId": "wave-20260316T120000-w1-fixture", "status": "succeeded", "failedLane": null, "failureReason": null, diff --git a/extensions/tests/fixtures/polyrepo-builder.ts b/extensions/tests/fixtures/polyrepo-builder.ts index 1c865b94..f95a32fd 100644 --- a/extensions/tests/fixtures/polyrepo-builder.ts +++ b/extensions/tests/fixtures/polyrepo-builder.ts @@ -457,6 +457,7 @@ export function buildFixtureParsedTasks(fixture: PolyrepoFixture): Map { + testRoot = join(tmpdir(), `tp-lane-progress-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(testRoot, { recursive: true }); +}); + +afterEach(() => { + rmSync(testRoot, { recursive: true, force: true }); +}); + +describe("lane-runner progress helpers", () => { + it("detectSoftProgress returns false for a clean worktree with unchanged HEAD", () => { + initRepo(testRoot); + writeFileSync(join(testRoot, "file.txt"), "base\n", "utf-8"); + git(testRoot, ["add", "."]); + git(testRoot, ["commit", "-m", "initial"]); + + const previousHead = readGitHead(testRoot); + const result = detectSoftProgress(testRoot, previousHead); + + expect(result.hasProgress).toBe(false); + expect(result.reason).toBe(null); + }); + + it("detectSoftProgress treats uncommitted source changes as progress", () => { + initRepo(testRoot); + writeFileSync(join(testRoot, "main.c"), "int main(void) { return 0; }\n", "utf-8"); + git(testRoot, ["add", "."]); + git(testRoot, ["commit", "-m", "initial"]); + + const previousHead = readGitHead(testRoot); + writeFileSync(join(testRoot, "main.c"), "int main(void) { return 1; }\n", "utf-8"); + + const result = detectSoftProgress(testRoot, previousHead); + + expect(result.hasProgress).toBe(true); + expect(result.reason).toContain("uncommitted worktree changes"); + }); + + it("detectSoftProgress treats HEAD advance as progress even when worktree is clean", () => { + initRepo(testRoot); + writeFileSync(join(testRoot, "Makefile"), "all:\n\t@echo ok\n", "utf-8"); + git(testRoot, ["add", "."]); + git(testRoot, ["commit", "-m", "initial"]); + + const previousHead = readGitHead(testRoot); + writeFileSync(join(testRoot, "Makefile"), "all:\n\t@echo done\n", "utf-8"); + git(testRoot, ["add", "."]); + git(testRoot, ["commit", "-m", "update makefile"]); + + const result = detectSoftProgress(testRoot, previousHead); + + expect(result.hasProgress).toBe(true); + expect(result.reason).toContain("HEAD advanced"); + }); + + it("getStatusProgressTotals counts total items separately from checked items", () => { + const content = [ + "# TP-001: Example — Status", + "", + "### Step 0: Preflight", + "**Status:** ✅ Complete", + "- [x] Verified artifact exists", + "- [ ] Confirmed clean worktree", + "", + "### Step 1: Implement", + "**Status:** 🟨 In Progress", + "- [ ] Create Makefile", + ].join("\n"); + + const totals = getStatusProgressTotals(content); + + expect(totals.checked).toBe(1); + expect(totals.total).toBe(3); + }); + + it("getStatusProgressTotals accepts worker-authored step evidence headings", () => { + const content = [ + "**Current Step:** Step 0: Preflight — Verify existing artifacts exist", + "**Status:** ✅ Complete — all steps verified, git clean", + "", + "## Step 0 Evidence — Verify existing artifacts exist", + "- [x] Verify artifact A", + "- [x] Verify artifact B", + "", + "## Step 1 Evidence — Create Makefile", + "- [x] Create Makefile", + "- [ ] Verify .PHONY", + "", + "## Completion Criteria", + "- [x] Parent-repo integration verified", + ].join("\n"); + + const totals = getStatusProgressTotals(content); + + expect(totals.checked).toBe(3); + expect(totals.total).toBe(4); + }); + + it("getStatusProgressTotals ignores pseudo-headings inside fenced evidence blocks", () => { + const content = [ + "**Current Step:** Step 1: Create the Makefile", + "**Status:** ✅ Complete", + "", + "## Step 1: Create the Makefile at `third_party/tools/bof3-inventory/Makefile`", + "", + "**EVIDENCE:**", + "", + "```bash", + "$ cat third_party/tools/bof3-inventory/Makefile", + "# Makefile for bof3-inventory — top-level build/test/docker targets", + "# Callable from parent repo via: $(MAKE) -C third_party/tools/bof3-inventory ", + "", + ".PHONY: build test api docker-build docker-run cpp-build clean", + "```", + "", + "- [x] File created at exact path", + "- [x] All 7 targets declared in `.PHONY`", + "- [x] Each target uses relative paths", + "- [x] Target implementations match the command specifications above", + ].join("\n"); + + const totals = getStatusProgressTotals(content); + + expect(totals.checked).toBe(4); + expect(totals.total).toBe(4); + }); + + it("buildLaneExitDiagnostic classifies no-progress failures as stall_timeout", () => { + const diagnostic = buildLaneExitDiagnostic( + "failed", + "No progress after 3 iterations", + false, + { durationMs: 341000, toolCalls: 18, costUsd: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }, + undefined, + "default", + ); + + expect(diagnostic?.classification).toBe("stall_timeout"); + expect(diagnostic?.errorMessage).toBe("No progress after 3 iterations"); + }); + + it("buildLaneExitDiagnostic classifies succeeded outcomes as completed", () => { + const diagnostic = buildLaneExitDiagnostic( + "succeeded", + "Task completed", + true, + { durationMs: 1000, toolCalls: 2, costUsd: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }, + ); + + expect(diagnostic?.classification).toBe("completed"); + expect(diagnostic?.errorMessage).toBeNull(); + }); + + it("buildLaneExitDiagnostic classifies dirty submodule safety failures distinctly", () => { + const diagnostic = buildLaneExitDiagnostic( + "failed", + "Unsafe submodule state after task success: libs/my_lib has uncommitted submodule changes", + false, + { durationMs: 1000, toolCalls: 1, costUsd: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, exitCode: 1 }, + ); + + expect(diagnostic?.classification).toBe("unsafe_submodule_dirty"); + }); + + it("buildLaneExitDiagnostic classifies unpublished submodule commits distinctly", () => { + const diagnostic = buildLaneExitDiagnostic( + "failed", + "Unsafe submodule state after task success: libs/my_lib points to local commit abcdef12 not reachable on origin", + false, + { durationMs: 1000, toolCalls: 1, costUsd: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, exitCode: 1 }, + ); + + expect(diagnostic?.classification).toBe("unsafe_submodule_unpublished_commit"); + }); +}); \ No newline at end of file diff --git a/extensions/tests/lane-runner-v2.test.ts b/extensions/tests/lane-runner-v2.test.ts index bd53359f..c0da20a9 100644 --- a/extensions/tests/lane-runner-v2.test.ts +++ b/extensions/tests/lane-runner-v2.test.ts @@ -131,6 +131,20 @@ describe("2.x: Lane-runner execution contract", () => { it("2.13: empty thinking is forwarded as undefined to inherit session defaults", () => { expect(laneRunnerSrc).toContain("thinking: config.workerThinking || undefined"); }); + + it("2.14: forwards TASKPLANE_REPO_PATHS to workers", () => { + expect(laneRunnerSrc).toContain("TASKPLANE_REPO_PATHS: JSON.stringify(config.repoPaths ?? unit.repoPaths)"); + }); + + it("2.15: worker prompt includes the task repo map", () => { + expect(laneRunnerSrc).toContain("`Task repo map:`"); + expect(laneRunnerSrc).toContain("Object.entries(config.repoPaths ?? unit.repoPaths)"); + }); + + it("2.16: captures pre-task submodule diagnostics before worker execution", () => { + expect(laneRunnerSrc).toContain('preTask: captureTaskSubmoduleSnapshot(taskId, "pre-task", config.worktreePath)'); + expect(laneRunnerSrc).toContain("submoduleDiagnostics,"); + }); }); // ── 3. executeLaneV2 integration ──────────────────────────────────── @@ -168,7 +182,7 @@ describe("3.x: executeLaneV2 integration in execution.ts", () => { it("3.6: executeLaneV2 preserves commitTaskArtifacts and worktree reset", () => { const start = executionSrc.indexOf("export async function executeLaneV2("); - const bodySection = executionSrc.slice(start, start + 5000); + const bodySection = executionSrc.slice(start, start + 6500); expect(bodySection).toContain("commitTaskArtifacts("); expect(bodySection).toContain("runGit("); }); @@ -184,6 +198,12 @@ describe("3.x: executeLaneV2 integration in execution.ts", () => { const bodySection = executionSrc.slice(start, start + 5000); expect(bodySection).toContain("buildRuntimeAgentId("); }); + + it("3.9: executeLaneV2 forwards unit.repoPaths into laneRunnerConfig", () => { + const start = executionSrc.indexOf("export async function executeLaneV2("); + const bodySection = executionSrc.slice(start, start + 5000); + expect(bodySection).toContain("repoPaths: unit.repoPaths"); + }); }); // ── 4. No TMUX dependency in the V2 path ──────────────────────────── @@ -225,6 +245,7 @@ describe("5.x: LaneRunnerConfig fields", () => { expect(laneRunnerSrc).toContain("worktreePath: string"); expect(laneRunnerSrc).toContain("branch: string"); expect(laneRunnerSrc).toContain("repoId: string"); + expect(laneRunnerSrc).toContain("repoPaths?: Record"); }); it("5.3: includes worker config fields", () => { diff --git a/extensions/tests/merge-panel-widget.test.ts b/extensions/tests/merge-panel-widget.test.ts new file mode 100644 index 00000000..b04f34ee --- /dev/null +++ b/extensions/tests/merge-panel-widget.test.ts @@ -0,0 +1,62 @@ +import { describe, it } from "node:test"; + +import { expect } from "./expect.ts"; +import { createOrchWidget } from "../taskplane/formatting.ts"; +import { freshOrchBatchState } from "../taskplane/types.ts"; + +const theme = { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, +}; + +describe("merge panel widget", () => { + it("renders a boxed merge status panel during merging", () => { + const batchState = freshOrchBatchState(); + batchState.phase = "merging"; + batchState.batchId = "batch-1"; + batchState.orchBranch = "orch/test"; + batchState.startedAt = Date.now() - 1000; + batchState.currentWaveIndex = 0; + batchState.totalWaves = 1; + batchState.totalTasks = 4; + batchState.mergePanel = { + status: "warning", + waveLabel: "Wave 1", + events: [ + { level: "info", message: "Lane 1 merged cleanly" }, + { level: "warning", message: "Lane 2 needs manual review" }, + ], + }; + + const widget = createOrchWidget(() => batchState, () => null, "orch")(undefined, theme); + const rendered = widget.render(80); + + expect(rendered.join("\n")).toContain("Merge Status"); + expect(rendered.join("\n")).toContain("Wave 1 · Warnings"); + expect(rendered.join("\n")).toContain("Lane 1 merged cleanly"); + expect(rendered.join("\n")).toContain("Lane 2 needs manual review"); + for (const line of rendered) { + expect(line.length <= 80).toBeTruthy(); + } + }); + + it("hides the merge panel outside the merging phase", () => { + const batchState = freshOrchBatchState(); + batchState.phase = "executing"; + batchState.batchId = "batch-2"; + batchState.startedAt = Date.now() - 1000; + batchState.currentWaveIndex = 0; + batchState.totalWaves = 1; + batchState.totalTasks = 4; + batchState.mergePanel = { + status: "success", + waveLabel: "Wave 1", + events: [{ level: "success", message: "Lane 1 merged" }], + }; + + const widget = createOrchWidget(() => batchState, () => null, "orch")(undefined, theme); + const rendered = widget.render(80).join("\n"); + + expect(rendered).not.toContain("Merge Status"); + }); +}); \ No newline at end of file diff --git a/extensions/tests/merge-repo-scoped.test.ts b/extensions/tests/merge-repo-scoped.test.ts index 18630266..ddcdf91e 100644 --- a/extensions/tests/merge-repo-scoped.test.ts +++ b/extensions/tests/merge-repo-scoped.test.ts @@ -14,6 +14,7 @@ import { groupLanesByRepo, determineMergeOrder, + formatRepoAtomicFailureSummary, formatRepoMergeSummary, computeMergeFailurePolicy, ORCH_MESSAGES, @@ -273,8 +274,9 @@ function runAllTests(): void { console.log("\n── 9. Status rollup: lane-level + repo-level evidence ──"); { // Helper: simulate the status rollup logic from mergeWaveByRepo(). - // Uses BOTH lane-level evidence (anyLaneSucceeded) and repo-level evidence - // (anyRepoFailed) to match the actual implementation. + // Single-repo groups preserve the legacy partial behavior. + // Multi-repo groups are atomic: any repo failure means the aggregate fails + // after already-advanced repo refs are rolled back. // // Parameters: // laneResults: simulated MergeLaneResult[] with result status @@ -284,11 +286,13 @@ function runAllTests(): void { laneResults: Array<{ resultStatus: string | null; error: string | null }>, repoStatuses: Array<"succeeded" | "failed" | "partial">, ): "succeeded" | "failed" | "partial" { + const strictAtomicCrossRepo = repoStatuses.length > 1; const anyLaneSucceeded = laneResults.some( r => r.resultStatus === "SUCCESS" || r.resultStatus === "CONFLICT_RESOLVED", ); const anyRepoFailed = repoStatuses.some(s => s !== "succeeded"); if (!anyRepoFailed) return "succeeded"; + if (strictAtomicCrossRepo) return "failed"; if (anyLaneSucceeded) return "partial"; return "failed"; } @@ -321,9 +325,8 @@ function runAllTests(): void { ); // Case D: All repos partial (some succeed in each repo, each has a failure) - // This is the edge case from R002 finding #2. - // Each repo is "partial" (has both succeeded and failed lanes), but globally - // there ARE successful merges, so aggregate should be "partial", not "failed". + // In strict multi-repo mode, those succeeded refs are rolled back, so the + // aggregate is failed rather than partial. assert( computeAggregateStatus( [ @@ -333,8 +336,8 @@ function runAllTests(): void { { resultStatus: "BUILD_FAILURE", error: null }, // repo-b lane 2 (failure) ], ["partial", "partial"], - ) === "partial", - "rollup: all repos partial → global partial (not failed)", + ) === "failed", + "rollup: all repos partial → global failed after atomic rollback", ); // Case E: No lanes at all (vacuous) → succeeded @@ -373,15 +376,14 @@ function runAllTests(): void { "rollup: repo setup failure with no lanes → failed", ); - // Case I: One repo setup-fails, another succeeds → partial - // Repo A: setup failure (no lanes merged) - // Repo B: all lanes merged successfully + // Case I: One repo setup-fails, another succeeds → failed + // Repo B's ref is rolled back because cross-repo merge is atomic. assert( computeAggregateStatus( [{ resultStatus: "SUCCESS", error: null }], // only repo B's lanes ["failed", "succeeded"], // repo A failed setup, repo B succeeded - ) === "partial", - "rollup: repo setup failure + other repo success → partial", + ) === "failed", + "rollup: repo setup failure + other repo success → failed after rollback", ); // Case J: All repos setup-fail → failed @@ -393,9 +395,9 @@ function runAllTests(): void { "rollup: all repos setup failure → failed", ); - // Case K: One repo setup-fails, another is partial → partial + // Case K: One repo setup-fails, another is partial → failed // Repo A: setup failure (no lanes) - // Repo B: partial (some lanes succeeded, some failed) + // Repo B: partial (some lanes succeeded, some failed), then rolled back assert( computeAggregateStatus( [ @@ -403,8 +405,8 @@ function runAllTests(): void { { resultStatus: "BUILD_FAILURE", error: null }, // repo B lane 2 ], ["failed", "partial"], // repo A setup fail, repo B partial - ) === "partial", - "rollup: repo setup failure + other repo partial → partial", + ) === "failed", + "rollup: repo setup failure + other repo partial → failed after rollback", ); } @@ -651,8 +653,73 @@ function runAllTests(): void { assert(templateOutput.includes("web"), "template: includes repo lines"); } - // ─── 18. formatRepoMergeSummary: mixed-outcome-lane partial (no repo divergence) ── - console.log("\n── 18. formatRepoMergeSummary: mixed-outcome-lane partial → no repo summary ──"); + // ─── 18. formatRepoAtomicFailureSummary: atomic rollback failure summary ── + console.log("\n── 18. formatRepoAtomicFailureSummary: atomic rollback failure summary ──"); + { + const mergeResult: MergeWaveResult = { + waveIndex: 2, + status: "failed", + laneResults: [], + failedLane: 2, + failureReason: "[repo:web] merge conflict. Cross-repo atomic merge rolled back 1 repo group(s).", + totalDurationMs: 4000, + repoResults: [ + { + repoId: "api", + status: "failed", + laneResults: [{ + laneNumber: 1, laneId: "api/lane-1", sourceBranch: "task/lane-1", + targetBranch: "main", result: { status: "SUCCESS", source_branch: "task/lane-1", target_branch: "main", merge_commit: "abc1234", conflicts: [], verification: { ran: true, passed: true, output: "" } }, + error: null, durationMs: 5000, repoId: "api", + }], + failedLane: null, + failureReason: "cross_repo_atomic_rollback: rolled back because another repo in the wave failed", + }, + { + repoId: "web", + status: "failed", + laneResults: [{ + laneNumber: 2, laneId: "web/lane-2", sourceBranch: "task/lane-2", + targetBranch: "main", result: { status: "CONFLICT_UNRESOLVED", source_branch: "task/lane-2", target_branch: "main", merge_commit: "", conflicts: [{ file: "index.ts", type: "content", resolved: false }], verification: { ran: false, passed: false, output: "" } }, + error: null, durationMs: 3000, repoId: "web", + }], + failedLane: 2, + failureReason: "merge conflict", + }, + ], + }; + + const summary = formatRepoAtomicFailureSummary(mergeResult); + assert(summary !== null, "atomic-failure: produces summary when a repo was rolled back"); + assert(summary!.includes("rolled back atomically"), "atomic-failure: summary mentions atomic rollback"); + assert(summary!.includes("api"), "atomic-failure: summary mentions rolled-back repo"); + assert(summary!.includes("web"), "atomic-failure: summary mentions directly failed repo"); + assert(summary!.includes("↩"), "atomic-failure: rolled-back repo uses rollback icon"); + assert(summary!.includes("❌"), "atomic-failure: direct failure repo uses failure icon"); + } + + // ─── 19. formatRepoAtomicFailureSummary: no summary without atomic rollback ── + console.log("\n── 19. formatRepoAtomicFailureSummary: no summary without atomic rollback ──"); + { + const mergeResult: MergeWaveResult = { + waveIndex: 2, + status: "failed", + laneResults: [], + failedLane: 2, + failureReason: "merge conflict", + totalDurationMs: 2000, + repoResults: [ + { repoId: "api", status: "failed", laneResults: [], failedLane: 1, failureReason: "merge conflict" }, + { repoId: "web", status: "failed", laneResults: [], failedLane: 2, failureReason: "merge conflict" }, + ], + }; + + const summary = formatRepoAtomicFailureSummary(mergeResult); + assert(summary === null, "atomic-failure: no summary when no repo was rolled back"); + } + + // ─── 20. formatRepoMergeSummary: mixed-outcome-lane partial (no repo divergence) ── + console.log("\n── 20. formatRepoMergeSummary: mixed-outcome-lane partial → no repo summary ──"); { // Partial caused by mixed-outcome lanes within a single repo, both repos ended up "partial" // This should NOT produce a repo-divergence summary because no repos diverge diff --git a/extensions/tests/merge-wave-by-repo-atomic-rollback.integration.test.ts b/extensions/tests/merge-wave-by-repo-atomic-rollback.integration.test.ts new file mode 100644 index 00000000..98d134e0 --- /dev/null +++ b/extensions/tests/merge-wave-by-repo-atomic-rollback.integration.test.ts @@ -0,0 +1,517 @@ +import { afterEach, beforeEach, describe, it, mock } from "node:test"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { expect } from "./expect.ts"; + +const agentHostModuleUrl = new URL("../taskplane/agent-host.ts", import.meta.url).href; +const settingsLoaderModuleUrl = new URL("../taskplane/settings-loader.ts", import.meta.url).href; + +function git(cwd: string, args: string[]): string { + return execFileSync("git", args, { + cwd, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); +} + +function extractPromptSection(prompt: string, title: string): string { + const match = prompt.match(new RegExp(`## ${title}\\n([^\\n]+)`)); + if (!match) { + throw new Error(`Missing ${title} section in merge prompt`); + } + return match[1].trim(); +} + +function extractResultFile(prompt: string): string { + const match = prompt.match(/result_file:\s+(.+)/); + if (!match) { + throw new Error("Missing result_file in merge prompt"); + } + return match[1].trim(); +} + +const mockSpawnAgent = mock.fn((opts: Record) => { + const prompt = String(opts.prompt ?? ""); + const cwd = String(opts.cwd ?? ""); + const sourceBranch = extractPromptSection(prompt, "Source Branch"); + const targetBranch = extractPromptSection(prompt, "Target Branch"); + const resultFilePath = extractResultFile(prompt); + + const result = (() => { + if (!sourceBranch.includes("lane-web")) { + git(cwd, ["merge", "--no-ff", "--no-edit", sourceBranch]); + const mergeCommit = git(cwd, ["rev-parse", "HEAD"]); + return { + status: "SUCCESS", + source_branch: sourceBranch, + target_branch: targetBranch, + merge_commit: mergeCommit, + conflicts: [], + verification: { ran: false, passed: true, output: "" }, + }; + } + + return { + status: "BUILD_FAILURE", + source_branch: sourceBranch, + target_branch: targetBranch, + merge_commit: "", + conflicts: [], + verification: { ran: true, passed: false, output: "simulated merge-agent failure" }, + }; + })(); + + mkdirSync(dirname(resultFilePath), { recursive: true }); + writeFileSync(resultFilePath, JSON.stringify(result, null, 2), "utf-8"); + + return { + promise: Promise.resolve({ + exitCode: 0, + signal: null, + durationMs: 1, + killed: false, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + costUsd: 0, + toolCalls: 0, + lastTool: "", + retries: 0, + compactions: 0, + contextUsage: null, + error: null, + agentEnded: true, + stderrTail: "", + }), + kill: () => {}, + }; +}); + +mock.module(agentHostModuleUrl, { + namedExports: { + spawnAgent: mockSpawnAgent, + }, +}); + +mock.module(settingsLoaderModuleUrl, { + namedExports: { + loadPiSettingsPackages: () => [], + filterExcludedExtensions: (packages: unknown[]) => packages ?? [], + }, +}); + +const { mergeWaveByRepo } = await import(new URL("../taskplane/merge.ts", import.meta.url).href); +const { DEFAULT_ORCHESTRATOR_CONFIG } = await import("../taskplane/types.ts"); + +let fixtureRoot = ""; + +function initRepo(name: string, taskId: string): string { + const repoDir = mkdtempSync(join(tmpdir(), `tp-merge-${name}-`)); + git(repoDir, ["init", "--initial-branch=main"]); + git(repoDir, ["config", "user.email", "test@example.com"]); + git(repoDir, ["config", "user.name", "Taskplane Test"]); + mkdirSync(join(repoDir, "tasks", taskId), { recursive: true }); + writeFileSync(join(repoDir, "README.md"), `# ${name}\n`, "utf-8"); + writeFileSync(join(repoDir, "tasks", taskId, "PROMPT.md"), `# ${taskId}\n`, "utf-8"); + git(repoDir, ["add", "."]); + git(repoDir, ["commit", "-m", "initial commit"]); + return repoDir; +} + +function initPlainRepo(repoDir: string): void { + mkdirSync(repoDir, { recursive: true }); + git(repoDir, ["init", "--initial-branch=main"]); + git(repoDir, ["config", "user.email", "test@example.com"]); + git(repoDir, ["config", "user.name", "Taskplane Test"]); +} + +function commitAll(repoDir: string, message: string): void { + git(repoDir, ["add", "."]); + git(repoDir, ["commit", "-m", message]); +} + +function addSubmodule(superRepo: string, subRepo: string, submodulePath: string): void { + git(superRepo, ["-c", "protocol.file.allow=always", "submodule", "add", subRepo, submodulePath]); + commitAll(superRepo, `add ${submodulePath}`); +} + +function createLaneBranch(repoDir: string, branchName: string, relPath: string, content: string): string { + git(repoDir, ["checkout", "-b", branchName]); + writeFileSync(join(repoDir, relPath), content, "utf-8"); + git(repoDir, ["add", relPath]); + git(repoDir, ["commit", "-m", `update ${relPath}`]); + const branchHead = git(repoDir, ["rev-parse", "HEAD"]); + git(repoDir, ["checkout", "main"]); + return branchHead; +} + +function makeTask(taskId: string, repoRoot: string) { + return { + taskId, + taskName: `Task ${taskId}`, + reviewLevel: 1, + size: "M", + dependencies: [], + fileScope: [], + taskFolder: join(repoRoot, "tasks", taskId), + promptPath: join(repoRoot, "tasks", taskId, "PROMPT.md"), + areaName: "default", + status: "pending", + }; +} + +function makeLane(laneNumber: number, repoId: string, repoRoot: string, branch: string, taskId: string) { + return { + laneNumber, + laneId: `${repoId}/lane-${laneNumber}`, + laneSessionId: `orch-${repoId}-lane-${laneNumber}`, + worktreePath: repoRoot, + branch, + tasks: [ + { + taskId, + order: 0, + task: makeTask(taskId, repoRoot), + estimatedMinutes: 5, + }, + ], + strategy: "affinity-first", + estimatedLoad: 1, + estimatedMinutes: 5, + repoId, + }; +} + +describe("mergeWaveByRepo cross-repo atomic rollback", () => { + beforeEach(() => { + fixtureRoot = mkdtempSync(join(tmpdir(), "tp-merge-wave-by-repo-")); + mkdirSync(join(fixtureRoot, ".pi"), { recursive: true }); + mockSpawnAgent.mock.resetCalls(); + }); + + afterEach(() => { + if (fixtureRoot) { + rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); + + it("rolls back an already-advanced repo when another repo merge fails", async () => { + const apiRepo = initRepo("api", "TP-700"); + const webRepo = initRepo("web", "TP-701"); + + const apiInitialHead = git(apiRepo, ["rev-parse", "refs/heads/main"]); + const webInitialHead = git(webRepo, ["rev-parse", "refs/heads/main"]); + + createLaneBranch(apiRepo, "task/lane-api", "api.txt", "api branch change\n"); + createLaneBranch(webRepo, "task/lane-web", "web.txt", "web branch change\n"); + + const allocatedLanes = [ + makeLane(1, "api", apiRepo, "task/lane-api", "TP-700"), + makeLane(2, "web", webRepo, "task/lane-web", "TP-701"), + ]; + + const waveResult = { + waveIndex: 0, + startedAt: Date.now(), + endedAt: Date.now(), + laneResults: [ + { laneNumber: 1, laneId: "api/lane-1", tasks: [{ taskId: "TP-700", status: "succeeded" }], overallStatus: "succeeded", startTime: Date.now(), endTime: Date.now() }, + { laneNumber: 2, laneId: "web/lane-2", tasks: [{ taskId: "TP-701", status: "succeeded" }], overallStatus: "succeeded", startTime: Date.now(), endTime: Date.now() }, + ], + policyApplied: "skip-dependents", + stoppedEarly: false, + failedTaskIds: [], + skippedTaskIds: [], + succeededTaskIds: ["TP-700", "TP-701"], + blockedTaskIds: [], + laneCount: 2, + overallStatus: "succeeded", + finalMonitorState: null, + allocatedLanes, + } as any; + + const workspaceConfig = { + repos: new Map([ + ["api", { path: apiRepo }], + ["web", { path: webRepo }], + ]), + } as any; + + const config = { + ...DEFAULT_ORCHESTRATOR_CONFIG, + merge: { + ...DEFAULT_ORCHESTRATOR_CONFIG.merge, + verify: [], + }, + verification: { + ...DEFAULT_ORCHESTRATOR_CONFIG.verification, + enabled: false, + }, + }; + + const result = await mergeWaveByRepo( + allocatedLanes as any, + waveResult, + 0, + config, + apiRepo, + "20260422T120000", + "main", + workspaceConfig, + fixtureRoot, + fixtureRoot, + undefined, + null, + false, + "v2", + ); + + expect(mockSpawnAgent.mock.calls.length).toBe(2); + expect(result.status).toBe("failed"); + expect(result.rollbackFailed).toBeUndefined(); + expect(result.failureReason).toContain("Cross-repo atomic merge rolled back 1 repo group(s)."); + + const apiRepoOutcome = result.repoResults.find((entry) => entry.repoId === "api"); + const webRepoOutcome = result.repoResults.find((entry) => entry.repoId === "web"); + expect(apiRepoOutcome).toBeDefined(); + expect(webRepoOutcome).toBeDefined(); + expect(apiRepoOutcome!.status).toBe("failed"); + expect(apiRepoOutcome!.failureReason).toContain("cross_repo_atomic_rollback"); + expect(webRepoOutcome!.status).toBe("failed"); + expect(webRepoOutcome!.failureReason).toContain("simulated merge-agent failure"); + + const transactionRecords = result.transactionRecords ?? []; + expect(transactionRecords).toHaveLength(2); + const apiTxn = transactionRecords.find((record) => record.repoId === "api"); + const webTxn = transactionRecords.find((record) => record.repoId === "web"); + expect(apiTxn).toBeDefined(); + expect(webTxn).toBeDefined(); + expect(apiTxn!.status).toBe("rolled_back"); + expect(apiTxn!.rollbackAttempted).toBe(true); + expect(apiTxn!.rollbackResult).toContain("cross_repo_atomic_rollback to"); + expect(webTxn!.status).toBe("merge_failed"); + + const apiCurrentHead = git(apiRepo, ["rev-parse", "refs/heads/main"]); + const webCurrentHead = git(webRepo, ["rev-parse", "refs/heads/main"]); + expect(apiCurrentHead).toBe(apiInitialHead); + expect(webCurrentHead).toBe(webInitialHead); + + const persistedApiTxnPath = join( + fixtureRoot, + ".pi", + "verification", + apiTxn!.opId, + `txn-${apiTxn!.waveTransactionId}-repo-api-lane-${apiTxn!.laneNumber}.json`, + ); + const persistedApiTxn = JSON.parse(readFileSync(persistedApiTxnPath, "utf-8")); + expect(persistedApiTxn.status).toBe("rolled_back"); + expect(persistedApiTxn.rollbackAttempted).toBe(true); + expect(result.persistenceErrors).toBeUndefined(); + }); + + it("merges a published submodule gitlink update without tripping the safety guard", async () => { + const repo = initRepo("submodule-host", "TP-710"); + const submoduleOrigin = join(fixtureRoot, "submodule-origin"); + initPlainRepo(submoduleOrigin); + git(submoduleOrigin, ["config", "receive.denyCurrentBranch", "updateInstead"]); + writeFileSync(join(submoduleOrigin, "lib.txt"), "base\n", "utf-8"); + commitAll(submoduleOrigin, "initial submodule commit"); + + addSubmodule(repo, submoduleOrigin, "libs/my_lib"); + git(join(repo, "libs", "my_lib"), ["config", "user.email", "test@example.com"]); + git(join(repo, "libs", "my_lib"), ["config", "user.name", "Taskplane Test"]); + + const initialHead = git(repo, ["rev-parse", "refs/heads/main"]); + + git(repo, ["checkout", "-b", "task/lane-submodule"]); + writeFileSync(join(repo, "libs", "my_lib", "lib.txt"), "base\npublished change\n", "utf-8"); + git(join(repo, "libs", "my_lib"), ["add", "lib.txt"]); + git(join(repo, "libs", "my_lib"), ["commit", "-m", "published submodule commit"]); + const publishedCommit = git(join(repo, "libs", "my_lib"), ["rev-parse", "HEAD"]); + git(join(repo, "libs", "my_lib"), ["push", "origin", "HEAD:main"]); + git(repo, ["add", "libs/my_lib"]); + git(repo, ["commit", "-m", "advance submodule gitlink"]); + git(repo, ["checkout", "main"]); + git(repo, ["-c", "protocol.file.allow=always", "submodule", "update", "--init", "--recursive"]); + + const allocatedLanes = [ + makeLane(1, "submodule", repo, "task/lane-submodule", "TP-710"), + ]; + + const waveResult = { + waveIndex: 0, + startedAt: Date.now(), + endedAt: Date.now(), + laneResults: [ + { laneNumber: 1, laneId: "submodule/lane-1", tasks: [{ taskId: "TP-710", status: "succeeded" }], overallStatus: "succeeded", startTime: Date.now(), endTime: Date.now() }, + ], + policyApplied: "skip-dependents", + stoppedEarly: false, + failedTaskIds: [], + skippedTaskIds: [], + succeededTaskIds: ["TP-710"], + blockedTaskIds: [], + laneCount: 1, + overallStatus: "succeeded", + finalMonitorState: null, + allocatedLanes, + } as any; + + const workspaceConfig = { + repos: new Map([ + ["submodule", { path: repo }], + ]), + } as any; + + const config = { + ...DEFAULT_ORCHESTRATOR_CONFIG, + merge: { + ...DEFAULT_ORCHESTRATOR_CONFIG.merge, + verify: [], + }, + verification: { + ...DEFAULT_ORCHESTRATOR_CONFIG.verification, + enabled: false, + }, + }; + + const result = await mergeWaveByRepo( + allocatedLanes as any, + waveResult, + 0, + config, + repo, + "20260422T130000", + "main", + workspaceConfig, + fixtureRoot, + fixtureRoot, + undefined, + null, + false, + "v2", + ); + + expect(result.status).toBe("succeeded"); + expect(result.failureReason).toBeNull(); + expect(result.repoResults).toHaveLength(1); + expect(result.repoResults[0].status).toBe("succeeded"); + + const transactionRecords = result.transactionRecords ?? []; + expect(transactionRecords).toHaveLength(1); + expect(transactionRecords[0].status).toBe("committed"); + + const currentHead = git(repo, ["rev-parse", "refs/heads/main"]); + expect(currentHead).not.toBe(initialHead); + + const mergedGitlink = git(repo, ["rev-parse", "refs/heads/main:libs/my_lib"]); + expect(mergedGitlink).toBe(publishedCommit); + }); + + it("rolls back an unpublished submodule gitlink update after merge-time validation fails", async () => { + const repo = initRepo("submodule-host-unpublished", "TP-711"); + const submoduleOrigin = join(fixtureRoot, "submodule-origin-unpublished"); + initPlainRepo(submoduleOrigin); + git(submoduleOrigin, ["config", "receive.denyCurrentBranch", "updateInstead"]); + writeFileSync(join(submoduleOrigin, "lib.txt"), "base\n", "utf-8"); + commitAll(submoduleOrigin, "initial submodule commit"); + + addSubmodule(repo, submoduleOrigin, "libs/my_lib"); + git(join(repo, "libs", "my_lib"), ["config", "user.email", "test@example.com"]); + git(join(repo, "libs", "my_lib"), ["config", "user.name", "Taskplane Test"]); + + const initialHead = git(repo, ["rev-parse", "refs/heads/main"]); + const initialGitlink = git(repo, ["rev-parse", "refs/heads/main:libs/my_lib"]); + + git(repo, ["checkout", "-b", "task/lane-submodule-unpublished"]); + writeFileSync(join(repo, "libs", "my_lib", "lib.txt"), "base\nunpublished change\n", "utf-8"); + git(join(repo, "libs", "my_lib"), ["add", "lib.txt"]); + git(join(repo, "libs", "my_lib"), ["commit", "-m", "unpublished submodule commit"]); + const unpublishedCommit = git(join(repo, "libs", "my_lib"), ["rev-parse", "HEAD"]); + git(repo, ["add", "libs/my_lib"]); + git(repo, ["commit", "-m", "advance unpublished submodule gitlink"]); + git(repo, ["checkout", "main"]); + git(repo, ["-c", "protocol.file.allow=always", "submodule", "update", "--init", "--recursive"]); + + const allocatedLanes = [ + makeLane(1, "submodule", repo, "task/lane-submodule-unpublished", "TP-711"), + ]; + + const waveResult = { + waveIndex: 0, + startedAt: Date.now(), + endedAt: Date.now(), + laneResults: [ + { laneNumber: 1, laneId: "submodule/lane-1", tasks: [{ taskId: "TP-711", status: "succeeded" }], overallStatus: "succeeded", startTime: Date.now(), endTime: Date.now() }, + ], + policyApplied: "skip-dependents", + stoppedEarly: false, + failedTaskIds: [], + skippedTaskIds: [], + succeededTaskIds: ["TP-711"], + blockedTaskIds: [], + laneCount: 1, + overallStatus: "succeeded", + finalMonitorState: null, + allocatedLanes, + } as any; + + const workspaceConfig = { + repos: new Map([ + ["submodule", { path: repo }], + ]), + } as any; + + const config = { + ...DEFAULT_ORCHESTRATOR_CONFIG, + merge: { + ...DEFAULT_ORCHESTRATOR_CONFIG.merge, + verify: [], + }, + verification: { + ...DEFAULT_ORCHESTRATOR_CONFIG.verification, + enabled: false, + }, + }; + + const result = await mergeWaveByRepo( + allocatedLanes as any, + waveResult, + 0, + config, + repo, + "20260422T131000", + "main", + workspaceConfig, + fixtureRoot, + fixtureRoot, + undefined, + null, + false, + "v2", + ); + + expect(result.status).toBe("failed"); + expect(result.failureReason).toContain("Post-merge submodule gitlink validation failed"); + expect(result.repoResults).toHaveLength(1); + expect(result.repoResults[0].status).toBe("failed"); + expect(result.repoResults[0].failureReason).toContain("Post-merge submodule gitlink validation failed"); + + const transactionRecords = result.transactionRecords ?? []; + expect(transactionRecords).toHaveLength(1); + expect(transactionRecords[0].status).toBe("rolled_back"); + expect(transactionRecords[0].rollbackAttempted).toBe(true); + expect(transactionRecords[0].rollbackResult).toBe("success"); + + const currentHead = git(repo, ["rev-parse", "refs/heads/main"]); + expect(currentHead).toBe(initialHead); + + const currentGitlink = git(repo, ["rev-parse", "refs/heads/main:libs/my_lib"]); + expect(currentGitlink).toBe(initialGitlink); + expect(currentGitlink).not.toBe(unpublishedCommit); + }); +}); \ No newline at end of file diff --git a/extensions/tests/mocks/pi-coding-agent.ts b/extensions/tests/mocks/pi-coding-agent.ts index b13ef1ed..48caf8a3 100644 --- a/extensions/tests/mocks/pi-coding-agent.ts +++ b/extensions/tests/mocks/pi-coding-agent.ts @@ -2,5 +2,17 @@ export type ExtensionAPI = any; export type ExtensionContext = any; // Stub value exports used by source files -export class DynamicBorder {} +export class DynamicBorder { + private color: (text: string) => string; + + constructor(color: (text: string) => string = (text) => text) { + this.color = color; + } + + invalidate(): void {} + + render(width: number): string[] { + return [this.color("─".repeat(Math.max(1, width)))]; + } +} export function getSettingsListTheme(): any { return {}; } diff --git a/extensions/tests/mocks/pi-tui.ts b/extensions/tests/mocks/pi-tui.ts index 00831825..ad63aab0 100644 --- a/extensions/tests/mocks/pi-tui.ts +++ b/extensions/tests/mocks/pi-tui.ts @@ -1,10 +1,104 @@ -export function truncateToWidth(input: string): string { - return input; +export function truncateToWidth(input: string, width?: number): string { + if (typeof width !== "number" || width <= 0 || input.length <= width) return input; + return input.slice(0, width); +} + +export function visibleWidth(input: string): number { + return input.length; +} + +export function wrapTextWithAnsi(input: string, width: number): string[] { + if (!input) return [""]; + if (width <= 0) return [input]; + + const lines: string[] = []; + for (const rawLine of input.split("\n")) { + if (rawLine.length === 0) { + lines.push(""); + continue; + } + + let line = rawLine; + while (line.length > width) { + lines.push(line.slice(0, width)); + line = line.slice(width); + } + lines.push(line); + } + + return lines; } // Stub TUI components used by source files -export class Container {} -export class Text {} +export class Container { + children: any[] = []; + + addChild(child: any): void { + this.children.push(child); + } + + removeChild(child: any): void { + this.children = this.children.filter((existing) => existing !== child); + } + + clear(): void { + this.children = []; + } + + invalidate(): void { + for (const child of this.children) child.invalidate?.(); + } + + render(width: number): string[] { + return this.children.flatMap((child) => child.render(width)); + } +} + +export class Text { + private text: string; + private paddingX: number; + private _paddingY: number; + + constructor( + text: string = "", + paddingX: number = 0, + paddingY: number = 0, + ) { + this.text = text; + this.paddingX = paddingX; + this._paddingY = paddingY; + } + + setText(text: string): void { + this.text = text; + } + + invalidate(): void {} + + render(width: number): string[] { + if (!this.text) return []; + + const contentWidth = Math.max(1, width - this.paddingX * 2); + const leftPad = " ".repeat(this.paddingX); + const rendered: string[] = []; + + for (const rawLine of this.text.split("\n")) { + if (rawLine.length === 0) { + rendered.push(leftPad); + continue; + } + + let line = rawLine; + while (line.length > contentWidth) { + rendered.push(leftPad + line.slice(0, contentWidth)); + line = line.slice(contentWidth); + } + rendered.push(leftPad + line); + } + + return rendered; + } +} export class SelectList {} export class SettingsList {} diff --git a/extensions/tests/orch-plan-widget.test.ts b/extensions/tests/orch-plan-widget.test.ts new file mode 100644 index 00000000..188ba759 --- /dev/null +++ b/extensions/tests/orch-plan-widget.test.ts @@ -0,0 +1,176 @@ +import { describe, it } from "node:test"; + +import { expect } from "./expect.ts"; +import { CollapsibleRibbonWidget } from "../taskplane/widgets/collapsible-ribbon.ts"; + +describe("orch plan widget lines", () => { + it("splits multiline sections and keeps blank separators between sections", () => { + const lines = CollapsibleRibbonWidget.buildSectionLines([ + "Preflight Check:\n✅ git\nAll required checks passed.", + "📋 Discovery Results\nPending tasks: 20", + "🌊 Execution Plan: 6 wave(s)", + ]); + + expect(lines).toEqual([ + "Preflight Check:", + "✅ git", + "All required checks passed.", + "", + "📋 Discovery Results", + "Pending tasks: 20", + "", + "🌊 Execution Plan: 6 wave(s)", + ]); + }); + + it("drops empty sections and normalizes CRLF input", () => { + const lines = CollapsibleRibbonWidget.buildSectionLines([ + null, + "", + "Section A\r\nLine 2\r\n", + undefined, + "Section B", + ]); + + expect(lines).toEqual([ + "Section A", + "Line 2", + "", + "Section B", + ]); + }); + + it("renders a simple orch-plan box so content is not capped at ten lines", () => { + const factory = new CollapsibleRibbonWidget({ + title: "/orch-plan all --sync", + status: "running", + phase: "Computing waves", + sections: [Array.from({ length: 12 }, (_, index) => `Line ${index + 1}`).join("\n")], + }).factory(); + + expect(factory).toBeDefined(); + + const widget = factory!(undefined, { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + }); + const rendered = widget.render(80).join("\n"); + + expect(rendered).toContain("/orch-plan all --sync"); + expect(rendered).toContain("Computing waves"); + expect(rendered).toContain("Line 12"); + expect(rendered).not.toContain("... (widget truncated)"); + }); + + it("renders a simple orch-plan box and wraps to the inner width", () => { + const factory = new CollapsibleRibbonWidget({ + title: "/orch-plan all", + status: "success", + phase: "Plan ready", + sections: ["This line is long enough to wrap across multiple rows in the widget body."], + padding: 1, + }).factory(); + + expect(factory).toBeDefined(); + + const widget = factory!(undefined, { + bg: (_color: string, text: string) => text, + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + }); + const rendered = widget.render(20); + + expect(rendered.length > 5).toBeTruthy(); + expect(rendered[0].trim()).toBe(""); + expect(rendered[1].trim()).toBe("● /orch-plan all"); + expect(rendered[2].trim()).toBe("● Plan ready"); + expect(rendered[rendered.length - 1].trim()).toBe(""); + + for (const line of rendered) { + expect(line).toHaveLength(20); + } + }); + + it("renders a collapsed orch-plan summary without pinning the full body", () => { + const factory = new CollapsibleRibbonWidget({ + title: "/orch-plan all", + status: "success", + phase: "Plan ready", + sections: ["Wave 1\nWave 2"], + expandHint: "Ctrl+O", + collapsed: true, + padding: 1, + }).factory(); + + expect(factory).toBeDefined(); + + const widget = factory!(undefined, { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + }); + const rendered = widget.render(28).join("\n"); + + expect(rendered).toContain("● /orch-plan all"); + expect(rendered).toContain("✓ Plan ready · Wave 1"); + expect(rendered).toContain("Expand: Ctrl+O"); + expect(rendered).not.toContain("Wave 2"); + }); + + it("renders an expanded closed-state summary before the full details", () => { + const factory = new CollapsibleRibbonWidget({ + title: "/orch-plan all", + status: "success", + phase: "Plan ready", + sections: ["Wave 1\nWave 2"], + viewState: "closed", + padding: 1, + }).factory(); + + expect(factory).toBeDefined(); + + const widget = factory!(undefined, { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + }); + const rendered = widget.render(32).join("\n"); + + expect(rendered).toContain("● /orch-plan all"); + expect(rendered).toContain("✓ Plan ready · Wave 1"); + expect(rendered).toContain("Wave 2"); + }); + + it("serializes a plain fallback header with title and terminal status", () => { + const lines = new CollapsibleRibbonWidget({ + title: "/orch-plan all", + status: "success", + phase: "Plan ready", + sections: ["Wave 1", "Wave 2"], + }).lines(); + + expect(lines).toEqual([ + "/orch-plan all", + "✓ Plan ready", + "", + "Wave 1", + "", + "Wave 2", + ]); + }); + + it("serializes a collapsed ribbon summary for fallback surfaces", () => { + const message = new CollapsibleRibbonWidget({ + title: "/orch-plan all", + status: "running", + phase: "Computing waves", + sections: ["Wave 1"], + expandHint: "Ctrl+O", + }).message(); + + expect(message.text.split("\n")).toEqual([ + "● /orch-plan all", + "● Computing waves · Wave 1", + "Expand: Ctrl+O", + ]); + expect(message.details.collapsed).toBeTruthy(); + }); +}); diff --git a/extensions/tests/orch-resume-tool.integration.test.ts b/extensions/tests/orch-resume-tool.integration.test.ts new file mode 100644 index 00000000..6dc6cff7 --- /dev/null +++ b/extensions/tests/orch-resume-tool.integration.test.ts @@ -0,0 +1,272 @@ +/** + * orch_resume tool harness tests + * + * Validates the registered extension tool surface, not just resume internals: + * - extension.ts registers orch_resume + * - session_start initializes execution context for the tool + * - tool returns the immediate async launch message + * - force propagates to worker init payload + * - a second resume is blocked while the first remains launching + */ + +import { afterEach, beforeEach, describe, it, mock } from "node:test"; +import { expect } from "./expect.ts"; +import { EventEmitter } from "node:events"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const mockFork = mock.fn(); +const mockBuildExecutionContext = mock.fn(); +const mockRunMigrations = mock.fn(() => ({ messages: [] })); + +class DummyWidget { + constructor(..._args: any[]) {} + addChild(..._args: any[]) {} + factory() { + return () => undefined; + } +} + +const Type = new Proxy({}, { + get: () => (...args: any[]) => ({ args }), +}); + +const origChildProcess = await import("node:child_process"); +const workspaceModuleUrl = new URL("../taskplane/workspace.ts", import.meta.url).href; +const configModuleUrl = new URL("../taskplane/config.ts", import.meta.url).href; +const migrationsModuleUrl = new URL("../taskplane/migrations.ts", import.meta.url).href; +const realWorkspace = await import(new URL("../taskplane/workspace.ts?orch-resume-tool-real", import.meta.url).href); +const realConfig = await import(new URL("../taskplane/config.ts?orch-resume-tool-real", import.meta.url).href); +const realMigrations = await import(new URL("../taskplane/migrations.ts?orch-resume-tool-real", import.meta.url).href); + +mock.module("@mariozechner/pi-coding-agent", { + namedExports: { + BorderedLoader: DummyWidget, + DynamicBorder: DummyWidget, + getSettingsListTheme: () => ({}), + }, +}); + +mock.module("@mariozechner/pi-ai", { + namedExports: { + Type, + }, +}); + +mock.module("@mariozechner/pi-tui", { + namedExports: { + Box: DummyWidget, + Container: DummyWidget, + SelectList: DummyWidget, + SettingsList: DummyWidget, + Text: DummyWidget, + truncateToWidth: (text: string) => text, + visibleWidth: (text: string) => text.length, + wrapTextWithAnsi: (text: string) => [text], + }, +}); + +mock.module("child_process", { + namedExports: { + ...origChildProcess, + fork: mockFork, + }, +}); + +mock.module(workspaceModuleUrl, { + namedExports: { + ...realWorkspace, + buildExecutionContext: mockBuildExecutionContext, + }, +}); + +mock.module(configModuleUrl, { + namedExports: { + ...realConfig, + loadSupervisorConfig: mock.fn(() => ({ + model: "", + autonomy: "supervised", + })), + }, +}); + +mock.module(migrationsModuleUrl, { + namedExports: { + ...realMigrations, + runMigrations: mockRunMigrations, + }, +}); + +const { default: taskplaneExtension } = await import("../taskplane/extension.ts"); +const { DEFAULT_ORCHESTRATOR_CONFIG, DEFAULT_TASK_RUNNER_CONFIG } = await import("../taskplane/types.ts"); + +type ToolResult = { content: Array<{ type: string; text: string }>; details?: unknown }; +type RegisteredTool = { + name: string; + execute: ( + toolCallId: string, + params: any, + signal?: AbortSignal, + onUpdate?: ((update: unknown) => void) | undefined, + ctx?: any, + ) => Promise; +}; + +interface FakeChildProcess extends EventEmitter { + stderr: EventEmitter; + sent: unknown[]; + send: (message: unknown) => boolean; + kill: () => boolean; +} + +function makeFakeChild(): FakeChildProcess { + const child = new EventEmitter() as FakeChildProcess; + child.stderr = new EventEmitter(); + child.sent = []; + child.send = (message: unknown) => { + child.sent.push(message); + return true; + }; + child.kill = () => true; + return child; +} + +function makeFakePi() { + const tools = new Map(); + const listeners = new Map any>>(); + + return { + tools, + listeners, + registerMessageRenderer() {}, + registerCommand() {}, + registerTool(tool: RegisteredTool) { + tools.set(tool.name, tool); + }, + on(event: string, handler: (...args: any[]) => any) { + const list = listeners.get(event) ?? []; + list.push(handler); + listeners.set(event, list); + }, + sendMessage() {}, + sendUserMessage() {}, + }; +} + +function makeSessionContext(cwd: string) { + const notifications: Array<{ message: string; level: string }> = []; + const statuses: Array<{ key: string; value: string }> = []; + const widgets: Array<{ key: string; value: unknown }> = []; + return { + cwd, + notifications, + statuses, + widgets, + ui: { + notify(message: string, level: string) { + notifications.push({ message, level }); + }, + setStatus(key: string, value: string) { + statuses.push({ key, value }); + }, + setWidget(key: string, value: unknown) { + widgets.push({ key, value }); + }, + custom() { + return undefined; + }, + }, + }; +} + +let tmpDir = ""; +let savedFetch: typeof globalThis.fetch | undefined; +let lastForkedChild: FakeChildProcess | null = null; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "tp-orch-resume-tool-")); + mkdirSync(join(tmpDir, ".pi"), { recursive: true }); + mockFork.mock.resetCalls(); + mockBuildExecutionContext.mock.resetCalls(); + mockRunMigrations.mock.resetCalls(); + savedFetch = globalThis.fetch; + globalThis.fetch = mock.fn(async () => ({ ok: false })) as typeof globalThis.fetch; + mockBuildExecutionContext.mock.mockImplementation((cwd: string) => ({ + cwd, + repoRoot: cwd, + workspaceRoot: cwd, + mode: "repo", + workspaceConfig: null, + pointer: null, + orchestratorConfig: { + ...DEFAULT_ORCHESTRATOR_CONFIG, + orchestrator: { + ...DEFAULT_ORCHESTRATOR_CONFIG.orchestrator, + integration: "manual", + }, + }, + taskRunnerConfig: { + ...DEFAULT_TASK_RUNNER_CONFIG, + task_areas: { default: "tasks" }, + }, + })); + lastForkedChild = null; + mockFork.mock.mockImplementation(() => { + lastForkedChild = makeFakeChild(); + return lastForkedChild; + }); +}); + +afterEach(() => { + if (savedFetch === undefined) { + delete globalThis.fetch; + } else { + globalThis.fetch = savedFetch; + } + rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("orch_resume tool harness", () => { + it("registers the tool and returns the async launch acknowledgement", async () => { + const pi = makeFakePi(); + taskplaneExtension(pi as any); + + expect(pi.tools.has("orch_resume")).toBe(true); + expect(pi.listeners.get("session_start")?.length).toBe(1); + + const sessionCtx = makeSessionContext(tmpDir); + await pi.listeners.get("session_start")![0]({}, sessionCtx as any); + + const tool = pi.tools.get("orch_resume")!; + const result = await tool.execute("call-1", { force: true }, undefined, undefined, sessionCtx as any); + + expect(result.content[0].text).toBe("🔄 Resume initiated for batch. Phase: launching."); + expect(mockFork.mock.calls.length).toBe(1); + + expect(lastForkedChild).not.toBeNull(); + expect(lastForkedChild!.sent).toHaveLength(1); + const initMessage = lastForkedChild!.sent[0] as { type: string; data: Record }; + expect(initMessage.type).toBe("init"); + expect(initMessage.data.mode).toBe("resume"); + expect(initMessage.data.force).toBe(true); + expect(initMessage.data.cwd).toBe(tmpDir); + expect(initMessage.data.workspaceRoot).toBe(tmpDir); + }); + + it("blocks a second resume while the first remains launching", async () => { + const pi = makeFakePi(); + taskplaneExtension(pi as any); + const sessionCtx = makeSessionContext(tmpDir); + await pi.listeners.get("session_start")![0]({}, sessionCtx as any); + + const tool = pi.tools.get("orch_resume")!; + const first = await tool.execute("call-1", {}, undefined, undefined, sessionCtx as any); + const second = await tool.execute("call-2", {}, undefined, undefined, sessionCtx as any); + + expect(first.content[0].text).toBe("🔄 Resume initiated for batch. Phase: launching."); + expect(second.content[0].text).toContain("⚠️ A batch is currently launching"); + expect(second.content[0].text).toContain("Cannot resume"); + expect(mockFork.mock.calls.length).toBe(1); + }); +}); \ No newline at end of file diff --git a/extensions/tests/orch-state-persistence.test.ts b/extensions/tests/orch-state-persistence.test.ts index ce8223ac..29fe6792 100644 --- a/extensions/tests/orch-state-persistence.test.ts +++ b/extensions/tests/orch-state-persistence.test.ts @@ -248,6 +248,18 @@ function validatePersistedState(data: unknown): any { throw new StateFileError("STATE_SCHEMA_INVALID", `tasks[${i}].resolvedRepoId is not a string (got ${typeof t.resolvedRepoId})`); } + if ((t as any).resolvedRepoIds !== undefined) { + if (!Array.isArray((t as any).resolvedRepoIds)) { + throw new StateFileError("STATE_SCHEMA_INVALID", + `tasks[${i}].resolvedRepoIds is not an array (got ${typeof (t as any).resolvedRepoIds})`); + } + for (let j = 0; j < ((t as any).resolvedRepoIds as unknown[]).length; j++) { + if (typeof ((t as any).resolvedRepoIds as unknown[])[j] !== "string") { + throw new StateFileError("STATE_SCHEMA_INVALID", + `tasks[${i}].resolvedRepoIds[${j}] is not a string`); + } + } + } } // Validate lane records @@ -287,6 +299,34 @@ function validatePersistedState(data: unknown): any { throw new StateFileError("STATE_SCHEMA_INVALID", `lanes[${i}].laneNumber is missing or not a number`); } + if ((l as any).repoWorktrees !== undefined) { + const repoWorktrees = (l as any).repoWorktrees; + if (!repoWorktrees || typeof repoWorktrees !== "object" || Array.isArray(repoWorktrees)) { + throw new StateFileError("STATE_SCHEMA_INVALID", + `lanes[${i}].repoWorktrees is not an object`); + } + for (const [repoId, worktree] of Object.entries(repoWorktrees as Record)) { + if (!worktree || typeof worktree !== "object" || Array.isArray(worktree)) { + throw new StateFileError("STATE_SCHEMA_INVALID", + `lanes[${i}].repoWorktrees[${repoId}] is not an object`); + } + const wt = worktree as Record; + for (const field of ["path", "branch"] as const) { + if (typeof wt[field] !== "string") { + throw new StateFileError("STATE_SCHEMA_INVALID", + `lanes[${i}].repoWorktrees[${repoId}].${field} is missing or not a string`); + } + } + if (typeof wt.laneNumber !== "number") { + throw new StateFileError("STATE_SCHEMA_INVALID", + `lanes[${i}].repoWorktrees[${repoId}].laneNumber is missing or not a number`); + } + if (wt.repoId !== undefined && typeof wt.repoId !== "string") { + throw new StateFileError("STATE_SCHEMA_INVALID", + `lanes[${i}].repoWorktrees[${repoId}].repoId is not a string`); + } + } + } if (!Array.isArray(l.taskIds)) { throw new StateFileError("STATE_SCHEMA_INVALID", `lanes[${i}].taskIds is missing or not an array`); @@ -309,10 +349,30 @@ function validatePersistedState(data: unknown): any { throw new StateFileError("STATE_SCHEMA_INVALID", `mergeResults[${i}].waveIndex is missing or not a number`); } + if (m.waveTransactionId !== undefined && typeof m.waveTransactionId !== "string") { + throw new StateFileError("STATE_SCHEMA_INVALID", + `mergeResults[${i}].waveTransactionId is not a string (got ${typeof m.waveTransactionId})`); + } if (typeof m.status !== "string" || !VALID_PERSISTED_MERGE_STATUSES.has(m.status)) { throw new StateFileError("STATE_SCHEMA_INVALID", `mergeResults[${i}].status is invalid: "${m.status}" (expected one of: ${[...VALID_PERSISTED_MERGE_STATUSES].join(", ")})`); } + if (m.rollbackFailed !== undefined && typeof m.rollbackFailed !== "boolean") { + throw new StateFileError("STATE_SCHEMA_INVALID", + `mergeResults[${i}].rollbackFailed is not a boolean (got ${typeof m.rollbackFailed})`); + } + if (m.persistenceErrors !== undefined) { + if (!Array.isArray(m.persistenceErrors)) { + throw new StateFileError("STATE_SCHEMA_INVALID", + `mergeResults[${i}].persistenceErrors is not an array (got ${typeof m.persistenceErrors})`); + } + for (let j = 0; j < (m.persistenceErrors as unknown[]).length; j++) { + if (typeof (m.persistenceErrors as unknown[])[j] !== "string") { + throw new StateFileError("STATE_SCHEMA_INVALID", + `mergeResults[${i}].persistenceErrors[${j}] is not a string`); + } + } + } } // Validate lastError @@ -1320,7 +1380,7 @@ function minimalLane(laneNum: number, taskIds: string[], repoId?: string): any { } // Helper: build minimal lane with ParsedTask objects containing repo fields -function minimalLaneWithRepoTasks(laneNum: number, tasks: Array<{ taskId: string; promptRepoId?: string; resolvedRepoId?: string }>, repoId?: string): any { +function minimalLaneWithRepoTasks(laneNum: number, tasks: Array<{ taskId: string; promptRepoId?: string; resolvedRepoId?: string; resolvedRepoIds?: string[] }>, repoId?: string): any { return { laneNumber: laneNum, laneId: `lane-${laneNum}`, @@ -1335,6 +1395,7 @@ function minimalLaneWithRepoTasks(laneNum: number, tasks: Array<{ taskId: string taskId: t.taskId, promptRepoId: t.promptRepoId, resolvedRepoId: t.resolvedRepoId, + resolvedRepoIds: t.resolvedRepoIds, }, })), strategy: "affinity-first", @@ -1421,6 +1482,9 @@ function serializeBatchState( if (allocated?.allocatedTask.task?.resolvedRepoId !== undefined) { record.resolvedRepoId = allocated.allocatedTask.task.resolvedRepoId; } + if ((allocated?.allocatedTask.task as any)?.resolvedRepoIds !== undefined) { + record.resolvedRepoIds = (allocated!.allocatedTask.task as any).resolvedRepoIds; + } return record; }); @@ -1433,6 +1497,9 @@ function serializeBatchState( branch: lane.branch, taskIds: lane.tasks.map((t: any) => t.taskId), }; + if (lane.repoWorktrees !== undefined) { + record.repoWorktrees = lane.repoWorktrees; + } // v2: Serialize lane repoId if (lane.repoId !== undefined) { record.repoId = lane.repoId; @@ -1446,12 +1513,33 @@ function serializeBatchState( // Clamp to 0 minimum: resume re-exec merges use sentinel waveIndex -1, // which would produce -2 without clamping. const mergeResults = (state.mergeResults || []) - .map((mr: any) => ({ - waveIndex: Math.max(0, mr.waveIndex - 1), - status: mr.status, - failedLane: mr.failedLane, - failureReason: mr.failureReason, - })); + .map((mr: any) => { + const record: Record = { + waveIndex: Math.max(0, mr.waveIndex - 1), + status: mr.status, + failedLane: mr.failedLane, + failureReason: mr.failureReason, + }; + if (typeof mr.waveTransactionId === "string" && mr.waveTransactionId.length > 0) { + record.waveTransactionId = mr.waveTransactionId; + } + if (mr.rollbackFailed) { + record.rollbackFailed = true; + } + if (Array.isArray(mr.persistenceErrors) && mr.persistenceErrors.length > 0) { + record.persistenceErrors = [...mr.persistenceErrors]; + } + if (Array.isArray(mr.repoResults) && mr.repoResults.length > 0) { + record.repoResults = mr.repoResults.map((rr: any) => ({ + repoId: rr.repoId, + status: rr.status, + laneNumbers: Array.isArray(rr.laneResults) ? rr.laneResults.map((lr: any) => lr.laneNumber) : [], + failedLane: rr.failedLane, + failureReason: rr.failureReason, + })); + } + return record; + }); const persisted = { schemaVersion: BATCH_STATE_SCHEMA_VERSION, @@ -1491,7 +1579,7 @@ function persistRuntimeState( wavePlan: string[][], lanes: any[], allTaskOutcomes: any[], - discovery: { pending: Map } | null, + discovery: { pending: Map } | null, repoRoot: string, ): void { try { @@ -1510,6 +1598,9 @@ function persistRuntimeState( if (taskRecord.resolvedRepoId === undefined && parsedTask.resolvedRepoId !== undefined) { taskRecord.resolvedRepoId = parsedTask.resolvedRepoId; } + if (taskRecord.resolvedRepoIds === undefined && parsedTask.resolvedRepoIds !== undefined) { + taskRecord.resolvedRepoIds = parsedTask.resolvedRepoIds; + } } } const enrichedJson = JSON.stringify(parsed, null, 2); @@ -1787,7 +1878,7 @@ try { const lanes = [ minimalLaneWithRepoTasks(1, [ - { taskId: "WS-001", promptRepoId: "api", resolvedRepoId: "api" }, + { taskId: "WS-001", promptRepoId: "api", resolvedRepoId: "api", resolvedRepoIds: ["api", "frontend"] }, ], "api"), minimalLaneWithRepoTasks(2, [ { taskId: "WS-002", promptRepoId: undefined, resolvedRepoId: "frontend" }, @@ -1807,6 +1898,7 @@ try { const ws002 = parsed.tasks.find((t: any) => t.taskId === "WS-002"); assertEqual(ws001.repoId, "api", "WS-001 repoId serialized from ParsedTask"); assertEqual(ws001.resolvedRepoId, "api", "WS-001 resolvedRepoId serialized from ParsedTask"); + assertEqual(JSON.stringify(ws001.resolvedRepoIds), JSON.stringify(["api", "frontend"]), "WS-001 resolvedRepoIds serialized from ParsedTask"); assertEqual(ws002.repoId, undefined, "WS-002 repoId undefined (not declared in prompt)"); assertEqual(ws002.resolvedRepoId, "frontend", "WS-002 resolvedRepoId serialized from area/default fallback"); @@ -4240,14 +4332,24 @@ function resolveRepoRoot( // Reimplement collectRepoRoots for test self-containment (mirrors source) function collectRepoRoots( - persistedState: { lanes: Array<{ repoId?: string }> }, + persistedState: { lanes: Array<{ repoId?: string; repoWorktrees?: Record }> }, defaultRepoRoot: string, workspaceConfig?: { repos: Map } | null, ): string[] { const roots = new Set(); + const collectLaneRepoIds = (lane: { repoId?: string; repoWorktrees?: Record }): Set => { + const repoIds = new Set(); + repoIds.add(lane.repoId); + for (const [repoKey, worktree] of Object.entries(lane.repoWorktrees ?? {})) { + repoIds.add(worktree.repoId ?? repoKey); + } + return repoIds; + }; for (const lane of persistedState.lanes) { - const root = resolveRepoRoot(lane.repoId, defaultRepoRoot, workspaceConfig); - roots.add(root); + for (const repoId of collectLaneRepoIds(lane)) { + const root = resolveRepoRoot(repoId, defaultRepoRoot, workspaceConfig); + roots.add(root); + } } roots.add(defaultRepoRoot); return [...roots]; @@ -4395,16 +4497,22 @@ function collectRepoRoots( repos: new Map([ ["api", { path: "/repos/api" }], ["frontend", { path: "/repos/frontend" }], + ["shared", { path: "/repos/shared" }], ]), }; const defaultRoot = "/repos/default"; const state = workspacePersistedState(); + (state.lanes[0] as any).repoWorktrees = { + api: { repoId: "api" }, + shared: { repoId: "shared" }, + }; const roots = collectRepoRoots(state, defaultRoot, wsConfig); assert(roots.includes("/repos/api"), "collectRepoRoots includes api root"); assert(roots.includes("/repos/frontend"), "collectRepoRoots includes frontend root"); + assert(roots.includes("/repos/shared"), "collectRepoRoots includes secondary repoWorktree root"); assert(roots.includes(defaultRoot), "collectRepoRoots includes default root"); - assertEqual(roots.length, 3, "collectRepoRoots returns 3 unique roots"); + assertEqual(roots.length, 4, "collectRepoRoots returns 4 unique roots"); } { @@ -5214,7 +5322,7 @@ console.log("\n── TP-007 Step 2: reconstructAllocatedLanes & collectAllRepoR // ── Reimplement Step 2 helpers for test self-containment ───────────── function reconstructAllocatedLanes( - persistedLanes: Array<{ laneNumber: number; laneId: string; laneSessionId: string; worktreePath: string; branch: string; taskIds: string[]; repoId?: string }>, + persistedLanes: Array<{ laneNumber: number; laneId: string; laneSessionId: string; worktreePath: string; repoWorktrees?: Record; branch: string; taskIds: string[]; repoId?: string }>, persistedTasks?: Array<{ taskId: string; repoId?: string; resolvedRepoId?: string; taskFolder?: string }>, ): any[] { const taskLookup = new Map(); @@ -5229,6 +5337,7 @@ function reconstructAllocatedLanes( laneId: lr.laneId, laneSessionId: lr.laneSessionId, worktreePath: lr.worktreePath, + repoWorktrees: lr.repoWorktrees, branch: lr.branch, tasks: lr.taskIds.map((taskId: string) => { const persistedTask = taskLookup.get(taskId); @@ -5257,15 +5366,25 @@ function reconstructAllocatedLanes( } function collectAllRepoRoots( - laneSources: Array>, + laneSources: Array }>>, defaultRepoRoot: string, workspaceConfig?: { repos: Map } | null, ): string[] { const roots = new Set(); + const collectLaneRepoIds = (lane: { repoId?: string; repoWorktrees?: Record }): Set => { + const repoIds = new Set(); + repoIds.add(lane.repoId); + for (const [repoKey, worktree] of Object.entries(lane.repoWorktrees ?? {})) { + repoIds.add(worktree.repoId ?? repoKey); + } + return repoIds; + }; for (const lanes of laneSources) { for (const lane of lanes) { - const root = resolveRepoRoot(lane.repoId, defaultRepoRoot, workspaceConfig); - roots.add(root); + for (const repoId of collectLaneRepoIds(lane)) { + const root = resolveRepoRoot(repoId, defaultRepoRoot, workspaceConfig); + roots.add(root); + } } } roots.add(defaultRepoRoot); @@ -5281,6 +5400,10 @@ function collectAllRepoRoots( laneId: "lane-1", laneSessionId: "orch-lane-1", worktreePath: "/work/wt-1", + repoWorktrees: { + api: { path: "/work/wt-1", branch: "orch/batch-1-lane-1", laneNumber: 1, repoId: "api" }, + web: { path: "/work/wt-1-web", branch: "orch/batch-1-lane-1", laneNumber: 1, repoId: "web" }, + }, branch: "orch/batch-1-lane-1", taskIds: ["T1", "T2"], repoId: "api", @@ -5302,6 +5425,8 @@ function collectAllRepoRoots( assertEqual(allocated[0].laneId, "lane-1", "lane 1 id preserved"); assertEqual(allocated[0].laneSessionId, "orch-lane-1", "lane 1 session preserved"); assertEqual(allocated[0].worktreePath, "/work/wt-1", "lane 1 worktree preserved"); + assertEqual(allocated[0].repoWorktrees.api.path, "/work/wt-1", "lane 1 primary repoWorktree preserved"); + assertEqual(allocated[0].repoWorktrees.web.path, "/work/wt-1-web", "lane 1 secondary repoWorktree preserved"); assertEqual(allocated[0].branch, "orch/batch-1-lane-1", "lane 1 branch preserved"); assertEqual(allocated[0].repoId, "api", "lane 1 repoId preserved"); assertEqual(allocated[0].tasks.length, 2, "lane 1 has 2 task stubs"); @@ -5341,12 +5466,13 @@ function collectAllRepoRoots( ["api", { path: "/repos/api" }], ["frontend", { path: "/repos/frontend" }], ["backend", { path: "/repos/backend" }], + ["shared", { path: "/repos/shared" }], ]), }; // Persisted lanes have api + frontend const persistedLanes = [ - { repoId: "api" as string | undefined }, + { repoId: "api" as string | undefined, repoWorktrees: { shared: { repoId: "shared" } } }, { repoId: "frontend" as string | undefined }, ]; // Newly allocated lanes introduce backend @@ -5358,9 +5484,10 @@ function collectAllRepoRoots( const roots = collectAllRepoRoots([persistedLanes, newLanes], "/default", wsConfig); assert(roots.includes("/repos/api"), "includes api from persisted"); assert(roots.includes("/repos/frontend"), "includes frontend from persisted"); + assert(roots.includes("/repos/shared"), "includes shared from persisted repoWorktrees"); assert(roots.includes("/repos/backend"), "includes backend from new lanes"); assert(roots.includes("/default"), "includes default root"); - assertEqual(roots.length, 4, "4 unique roots (3 repos + default)"); + assertEqual(roots.length, 5, "5 unique roots (4 repos + default)"); } // 2.4: collectAllRepoRoots in repo mode (no workspaceConfig) @@ -5381,6 +5508,10 @@ function collectAllRepoRoots( laneId: "lane-1", laneSessionId: "orch-lane-1", worktreePath: "/work/wt-1", + repoWorktrees: { + api: { path: "/work/wt-1", branch: "orch/batch-1-lane-1", laneNumber: 1, repoId: "api" }, + web: { path: "/work/wt-1-web", branch: "orch/batch-1-lane-1", laneNumber: 1, repoId: "web" }, + }, branch: "orch/batch-1-lane-1", taskIds: ["T1"], repoId: "api", @@ -5459,6 +5590,10 @@ function collectAllRepoRoots( laneId: "lane-1", laneSessionId: "orch-lane-1", worktreePath: "/work/wt-1", + repoWorktrees: { + api: { path: "/work/wt-1", branch: "orch/batch-1-lane-1", laneNumber: 1, repoId: "api" }, + web: { path: "/work/wt-1-web", branch: "orch/batch-1-lane-1", laneNumber: 1, repoId: "web" }, + }, branch: "orch/batch-1-lane-1", taskIds: ["T1"], repoId: "api", @@ -5585,6 +5720,10 @@ function collectAllRepoRoots( laneId: "lane-1", laneSessionId: "orch-lane-1", worktreePath: "/work/wt-1", + repoWorktrees: { + api: { path: "/work/wt-1", branch: "orch/batch-1-lane-1", laneNumber: 1, repoId: "api" }, + web: { path: "/work/wt-1-web", branch: "orch/batch-1-lane-1", laneNumber: 1, repoId: "web" }, + }, branch: "orch/batch-1-lane-1", taskIds: ["T1"], repoId: "api", @@ -5628,6 +5767,7 @@ function collectAllRepoRoots( assertEqual(validated.lanes.length, 1, "round-trip: 1 lane"); assertEqual(validated.lanes[0].repoId, "api", "round-trip: lane repoId preserved"); + assertEqual(validated.lanes[0].repoWorktrees.web.path, "/work/wt-1-web", "round-trip: secondary repoWorktree preserved"); assertEqual(validated.lanes[0].laneNumber, 1, "round-trip: lane number preserved"); assertEqual(validated.lanes[0].laneSessionId, "orch-lane-1", "round-trip: session preserved"); @@ -5639,6 +5779,7 @@ function collectAllRepoRoots( const reReconstruct = reconstructAllocatedLanes(validated.lanes); assertEqual(reReconstruct.length, 1, "re-reconstruct: 1 lane"); assertEqual(reReconstruct[0].repoId, "api", "re-reconstruct: repoId preserved across pause/resume"); + assertEqual(reReconstruct[0].repoWorktrees.web.path, "/work/wt-1-web", "re-reconstruct: repoWorktrees preserved across pause/resume"); } // ── TP-007 Step 2 additional tests ─────────────────────────────────── @@ -5858,6 +5999,48 @@ function collectAllRepoRoots( assertEqual(tf.resolvedRepoId, "frontend", "mixed-repo: TF resolvedRepoId"); } +// 2.18: Transactional merge warnings persist through batch-state serialization +{ + console.log(" ▸ merge checkpoint: rollbackFailed, persistenceErrors, and repoResults survive serialize"); + const state: MinimalBatchState = { + phase: "paused", batchId: "B-merge-state", baseBranch: "main", mode: "workspace", + startedAt: Date.now(), endedAt: null, currentWaveIndex: 1, totalWaves: 2, + totalTasks: 2, succeededTasks: 1, failedTasks: 1, skippedTasks: 0, + blockedTasks: 0, blockedTaskIds: new Set(), + errors: ["merge failed"], + mergeResults: [ + { + waveIndex: 2, + waveTransactionId: "wave-B-merge-state-w2-abc123", + status: "failed", + failedLane: 2, + failureReason: "[repo:web] conflict. Cross-repo atomic merge rolled back 1 repo group(s).", + rollbackFailed: true, + persistenceErrors: ["lane 2 (repo: web): ENOSPC"], + totalDurationMs: 2500, + laneResults: [], + repoResults: [ + { repoId: "api", status: "failed", failedLane: null, failureReason: "cross_repo_atomic_rollback: rolled back", laneResults: [{ laneNumber: 1 }] }, + { repoId: "web", status: "failed", failedLane: 2, failureReason: "merge conflict", laneResults: [{ laneNumber: 2 }] }, + ], + }, + ], + }; + + const json = serializeBatchState(state, [["T1", "T2"]], [], []); + const parsed = JSON.parse(json); + + assertEqual(parsed.mergeResults.length, 1, "merge-state: one persisted merge result"); + assertEqual(parsed.mergeResults[0].waveIndex, 1, "merge-state: wave index normalized to 0-based"); + assertEqual(parsed.mergeResults[0].waveTransactionId, "wave-B-merge-state-w2-abc123", "merge-state: waveTransactionId persisted"); + assertEqual(parsed.mergeResults[0].rollbackFailed, true, "merge-state: rollbackFailed persisted"); + assertEqual(parsed.mergeResults[0].persistenceErrors.length, 1, "merge-state: persistenceErrors persisted"); + assertEqual(parsed.mergeResults[0].persistenceErrors[0], "lane 2 (repo: web): ENOSPC", "merge-state: persistence error content preserved"); + assertEqual(parsed.mergeResults[0].repoResults.length, 2, "merge-state: repoResults persisted"); + assertEqual(parsed.mergeResults[0].repoResults[0].laneNumbers[0], 1, "merge-state: api lane numbers serialized"); + assertEqual(parsed.mergeResults[0].repoResults[1].laneNumbers[0], 2, "merge-state: web lane numbers serialized"); +} + // ═══════════════════════════════════════════════════════════════════════ // Summary // ═══════════════════════════════════════════════════════════════════════ diff --git a/extensions/tests/polyrepo-fixture.test.ts b/extensions/tests/polyrepo-fixture.test.ts index d790075d..7e25d045 100644 --- a/extensions/tests/polyrepo-fixture.test.ts +++ b/extensions/tests/polyrepo-fixture.test.ts @@ -293,6 +293,8 @@ describe("5.x: Static batch-state fixture (v2-polyrepo)", () => { expect(resolvedRepos.has("docs")).toBe(true); expect(resolvedRepos.has("api")).toBe(true); expect(resolvedRepos.has("frontend")).toBe(true); + expect(fixtureData.tasks.every((t: any) => Array.isArray(t.resolvedRepoIds))).toBe(true); + expect(fixtureData.tasks.every((t: any) => t.resolvedRepoIds.length === 1)).toBe(true); }); it("5.3: fixture has 3-wave plan", () => { @@ -325,6 +327,7 @@ describe("5.x: Static batch-state fixture (v2-polyrepo)", () => { it("5.6: merge results include per-repo outcomes", () => { expect(fixtureData.mergeResults.length).toBe(1); // wave 0 completed const merge = fixtureData.mergeResults[0]; + expect(merge.waveTransactionId).toBe("wave-20260316T120000-w1-fixture"); expect(merge.status).toBe("succeeded"); expect(merge.repoResults).toBeDefined(); expect(merge.repoResults.length).toBe(3); @@ -356,6 +359,12 @@ describe("5.x: Static batch-state fixture (v2-polyrepo)", () => { if (task.resolvedRepoId !== undefined) { expect(typeof task.resolvedRepoId).toBe("string"); } + if (task.resolvedRepoIds !== undefined) { + expect(Array.isArray(task.resolvedRepoIds)).toBe(true); + for (const repoId of task.resolvedRepoIds) { + expect(typeof repoId).toBe("string"); + } + } } for (const lane of fixtureData.lanes) { @@ -387,6 +396,7 @@ describe("6.x: ParsedTask builder", () => { const tasks = buildFixtureParsedTasks(fixture); for (const [taskId, expectedRepo] of Object.entries(fixture.expectedRouting)) { expect(tasks.get(taskId)!.resolvedRepoId).toBe(expectedRepo); + expect(tasks.get(taskId)!.resolvedRepoIds).toEqual([expectedRepo]); } }); diff --git a/extensions/tests/polyrepo-regression.test.ts b/extensions/tests/polyrepo-regression.test.ts index 5d5eda4f..89a35bc5 100644 --- a/extensions/tests/polyrepo-regression.test.ts +++ b/extensions/tests/polyrepo-regression.test.ts @@ -63,6 +63,7 @@ import { } from "../taskplane/resume.ts"; import { generateBranchName, generateWorktreePath } from "../taskplane/worktree.ts"; import { sanitizeNameComponent, resolveOperatorId } from "../taskplane/naming.ts"; +import { buildExecutionUnit } from "../taskplane/execution.ts"; import { freshOrchBatchState, BATCH_STATE_SCHEMA_VERSION, @@ -796,6 +797,7 @@ describe("5.x: Resume — polyrepo workspace-mode resume", () => { const ui001Task = frontendLane.tasks.find(t => t.taskId === "UI-001"); expect(ui001Task).toBeDefined(); expect(ui001Task!.task?.resolvedRepoId).toBe("frontend"); + expect((ui001Task!.task as any)?.resolvedRepoIds).toEqual(["frontend"]); }); it("5.8: collectRepoRoots returns unique repo roots from persisted lanes", () => { @@ -805,18 +807,36 @@ describe("5.x: Resume — polyrepo workspace-mode resume", () => { ["docs", { id: "docs", path: "/repos/docs" }], ["api", { id: "api", path: "/repos/api" }], ["frontend", { id: "frontend", path: "/repos/frontend" }], + ["shared", { id: "shared", path: "/repos/shared" }], ]), routing: { tasksRoot: "/workspace/tasks", defaultRepo: "docs" }, configPath: "/workspace/.pi/taskplane-workspace.yaml", }; - const roots = collectRepoRoots(fixtureState, "/workspace", workspaceConfig); + const state = JSON.parse(JSON.stringify(fixtureState)) as PersistedBatchState; + state.lanes[1].repoWorktrees = { + api: { + path: "/tmp/taskplane-wt-2", + branch: "task/op-api-lane-2-20260316T120000", + laneNumber: 2, + repoId: "api", + }, + shared: { + path: "/tmp/taskplane-wt-2-shared", + branch: "task/op-api-lane-2-20260316T120000", + laneNumber: 2, + repoId: "shared", + }, + }; + + const roots = collectRepoRoots(state, "/workspace", workspaceConfig); - // Should include all 3 repo roots + default workspace root - expect(roots.length).toBeGreaterThanOrEqual(3); + // Should include all 3 primary repo roots + secondary repoWorktree root + default workspace root + expect(roots.length).toBeGreaterThanOrEqual(4); expect(roots).toContain("/repos/docs"); expect(roots).toContain("/repos/api"); expect(roots).toContain("/repos/frontend"); + expect(roots).toContain("/repos/shared"); }); it("5.9: full resume scenario — wave-1 done, wave-2 partial, all sessions dead", () => { @@ -867,6 +887,53 @@ describe("5.x: Resume — polyrepo workspace-mode resume", () => { expect(resumePoint.reconnectTaskIds).toContain("AP-002"); expect(resumePoint.completedTaskIds).toContain("UI-002"); }); + + it("5.11: reconstructed multi-repo lane builds execution in the secondary repoWorktree", () => { + const state = JSON.parse(JSON.stringify(fixtureState)) as PersistedBatchState; + const laneRecord = state.lanes.find((lane) => lane.taskIds.includes("AP-002"))!; + laneRecord.repoId = "frontend"; + laneRecord.worktreePath = "/tmp/taskplane-wt-2-api"; + laneRecord.repoWorktrees = { + api: { + path: "/tmp/taskplane-wt-2-api", + branch: laneRecord.branch, + laneNumber: laneRecord.laneNumber, + repoId: "api", + }, + frontend: { + path: "/tmp/taskplane-wt-2-frontend", + branch: laneRecord.branch, + laneNumber: laneRecord.laneNumber, + repoId: "frontend", + }, + }; + + const taskRecord = state.tasks.find((task) => task.taskId === "AP-002")!; + taskRecord.activeSegmentId = "AP-002::frontend"; + taskRecord.segmentIds = ["AP-002::api", "AP-002::frontend"]; + taskRecord.resolvedRepoId = "api"; + taskRecord.resolvedRepoIds = ["api", "frontend"]; + + const lanes = reconstructAllocatedLanes(state.lanes, state.tasks); + const lane = lanes.find((candidate) => candidate.tasks.some((task) => task.taskId === "AP-002"))!; + const task = lane.tasks.find((candidate) => candidate.taskId === "AP-002")!; + const workspaceConfig: WorkspaceConfig = { + mode: "workspace", + repos: new Map([ + ["api", { id: "api", path: "/repos/api" }], + ["frontend", { id: "frontend", path: "/repos/frontend" }], + ]), + routing: { tasksRoot: "/workspace/tasks", defaultRepo: "api" }, + configPath: "/workspace/.pi/taskplane-workspace.yaml", + }; + + const unit = buildExecutionUnit(lane, task, "/repos/api", true, workspaceConfig); + + expect(unit.executionRepoId).toBe("frontend"); + expect(unit.worktreePath).toBe("/tmp/taskplane-wt-2-frontend"); + expect(unit.repoPaths.frontend).toBe("/tmp/taskplane-wt-2-frontend"); + expect(unit.repoPaths.api).toBe("/tmp/taskplane-wt-2-api"); + }); }); @@ -969,7 +1036,119 @@ describe("7.x: Repo-aware persisted state — validation and upconversion", () = expect(validated.schemaVersion).toBe(BATCH_STATE_SCHEMA_VERSION); expect(validated.mode).toBe("workspace"); expect(validated.tasks.every(t => t.resolvedRepoId !== undefined)).toBe(true); + expect(validated.tasks.every(t => JSON.stringify((t as any).resolvedRepoIds) === JSON.stringify([t.resolvedRepoId]))).toBe(true); expect(validated.lanes.every(l => l.repoId !== undefined)).toBe(true); + expect(validated.mergeResults[0].waveTransactionId).toBe("wave-20260316T120000-w1-fixture"); + }); + + it("7.1b: validatePersistedState + reconstructAllocatedLanes preserve repoWorktrees for multi-repo lanes", () => { + const data = JSON.parse( + readFileSync(join(__dirname, "fixtures", "batch-state-v2-polyrepo.json"), "utf-8"), + ); + + data.lanes[1].repoWorktrees = { + api: { + path: "/tmp/taskplane-wt-2", + branch: "task/op-api-lane-2-20260316T120000", + laneNumber: 2, + repoId: "api", + }, + shared: { + path: "/tmp/taskplane-wt-2-shared", + branch: "task/op-api-lane-2-20260316T120000", + laneNumber: 2, + repoId: "shared", + }, + }; + data.tasks[1].resolvedRepoIds = ["api", "shared"]; + data.tasks[3].resolvedRepoIds = ["api", "shared"]; + + const validated = validatePersistedState(data); + const lanes = reconstructAllocatedLanes(validated.lanes, validated.tasks); + const apiLane = lanes.find((lane) => lane.repoId === "api")!; + + expect(apiLane.repoWorktrees).toBeDefined(); + expect(apiLane.repoWorktrees!.api.path).toBe("/tmp/taskplane-wt-2"); + expect(apiLane.repoWorktrees!.shared.path).toBe("/tmp/taskplane-wt-2-shared"); + + const ap001Task = apiLane.tasks.find((task) => task.taskId === "AP-001")!; + expect((ap001Task.task as any)?.resolvedRepoIds).toEqual(["api", "shared"]); + }); + + it("7.1c: serialize + validate + reconstruct preserve explicit segment metadata for resumed frontier lanes", () => { + const state = freshOrchBatchState("20260422T160000", "main", 1, "workspace", "orch/test"); + state.phase = "paused"; + + const task: ParsedTask = { + taskId: "SEG-001", + taskName: "Task SEG-001", + reviewLevel: 1, + size: "M", + dependencies: [], + fileScope: [], + taskFolder: "/workspace/tasks/SEG-001", + promptPath: "/workspace/tasks/SEG-001/PROMPT.md", + areaName: "default", + status: "pending", + promptRepoIds: ["api", "frontend"], + resolvedRepoIds: ["api", "frontend"], + resolvedRepoId: "api", + segmentIds: ["SEG-001::api", "SEG-001::frontend"], + activeSegmentId: "SEG-001::frontend", + explicitSegmentDag: { + repoIds: ["api", "frontend"], + edges: [{ fromRepoId: "api", toRepoId: "frontend" }], + }, + stepSegmentMap: [ + { + stepNumber: 1, + stepName: "Wire API", + segments: [{ repoId: "api", checkboxes: ["Add endpoint"] }], + }, + { + stepNumber: 2, + stepName: "Hook UI", + segments: [{ repoId: "frontend", checkboxes: ["Connect form"] }], + }, + ], + }; + + const allocated: AllocatedLane[] = [{ + laneNumber: 1, + laneId: "api/lane-1", + laneSessionId: "orch-op-api-lane-1", + worktreePath: "/tmp/taskplane-wt-1-api", + branch: "task/op-api-lane-1-20260422T160000", + repoId: "api", + repoWorktrees: { + api: { path: "/tmp/taskplane-wt-1-api", branch: "task/op-api-lane-1-20260422T160000", laneNumber: 1, repoId: "api" }, + frontend: { path: "/tmp/taskplane-wt-1-frontend", branch: "task/op-api-lane-1-20260422T160000", laneNumber: 1, repoId: "frontend" }, + }, + tasks: [{ taskId: "SEG-001", order: 0, task, estimatedMinutes: 5 }], + strategy: "affinity-first", + estimatedLoad: 1, + estimatedMinutes: 5, + }]; + + const outcomes: LaneTaskOutcome[] = [{ + taskId: "SEG-001", + status: "running", + startTime: Date.now() - 5_000, + endTime: null, + exitReason: "", + sessionName: "orch-op-api-lane-1", + doneFileFound: false, + }]; + + const json = serializeBatchState(state, [["SEG-001"]], allocated, outcomes); + const validated = validatePersistedState(JSON.parse(json)); + const lanes = reconstructAllocatedLanes(validated.lanes, validated.tasks); + const resumedTask = lanes[0].tasks[0].task as ParsedTask; + + expect(resumedTask.explicitSegmentDag).toEqual(task.explicitSegmentDag); + expect(resumedTask.stepSegmentMap).toEqual(task.stepSegmentMap); + expect(resumedTask.segmentIds).toEqual(task.segmentIds); + expect(resumedTask.activeSegmentId).toBe("SEG-001::frontend"); }); it("7.2: v1→v2 upconversion adds mode=repo and preserves fields", () => { diff --git a/extensions/tests/project-config-loader.test.ts b/extensions/tests/project-config-loader.test.ts index e3498467..d222f227 100644 --- a/extensions/tests/project-config-loader.test.ts +++ b/extensions/tests/project-config-loader.test.ts @@ -204,6 +204,22 @@ describe("loadProjectConfig precedence/error matrix", () => { expect(config.orchestrator.orchestrator.maxLanes).toBe(11); }); + it("1.5b: stray taskplane-settings.json is ignored in favor of canonical config resolution", () => { + const dir = makeTestDir("ignore-taskplane-settings-json"); + + writePiFile(dir, "taskplane-settings.json", JSON.stringify({ + configVersion: 999, + taskRunner: { + project: { name: "WrongFile" }, + }, + }, null, 2)); + writeTaskRunnerYaml(dir, "project:\n name: YamlStillWins\n"); + + const config = loadProjectConfig(dir); + expect(config.configVersion).toBe(CONFIG_VERSION); + expect(config.taskRunner.project.name).toBe("YamlStillWins"); + }); + it("1.6: YAML-only fallback works when JSON is absent", () => { const dir = makeTestDir("yaml-only"); @@ -1823,8 +1839,8 @@ describe("workspace section threading (TP-079)", () => { const config = loadProjectConfig(dir); expect(config.workspace).toBeDefined(); - expect(config.workspace!.routing.taskPacketRepo).toBe("api"); - expect(config.workspace!.routing.strict).toBe(true); + expect(config.workspace?.routing?.taskPacketRepo).toBe("api"); + expect(config.workspace?.routing?.strict).toBe(true); }); it("8.2: JSON workspace section missing taskPacketRepo falls back to defaultRepo", () => { @@ -1844,7 +1860,7 @@ describe("workspace section threading (TP-079)", () => { const config = loadProjectConfig(dir); expect(config.workspace).toBeDefined(); - expect(config.workspace!.routing.taskPacketRepo).toBe("docs"); + expect(config.workspace?.routing?.taskPacketRepo).toBe("docs"); }); it("8.3: legacy taskplane-workspace.yaml maps snake_case fields to workspace section", () => { @@ -1863,11 +1879,11 @@ describe("workspace section threading (TP-079)", () => { const config = loadProjectConfig(dir); expect(config.workspace).toBeDefined(); - expect(config.workspace!.repos.api.defaultBranch).toBe("develop"); - expect(config.workspace!.routing.tasksRoot).toBe("api-repo/taskplane-tasks"); - expect(config.workspace!.routing.defaultRepo).toBe("api"); - expect(config.workspace!.routing.taskPacketRepo).toBe("api"); - expect(config.workspace!.routing.strict).toBe(true); + expect(config.workspace?.repos?.api?.defaultBranch).toBe("develop"); + expect(config.workspace?.routing?.tasksRoot).toBe("api-repo/taskplane-tasks"); + expect(config.workspace?.routing?.defaultRepo).toBe("api"); + expect(config.workspace?.routing?.taskPacketRepo).toBe("api"); + expect(config.workspace?.routing?.strict).toBe(true); }); it("8.4: legacy workspace YAML missing task_packet_repo falls back to default_repo", () => { @@ -1883,8 +1899,8 @@ describe("workspace section threading (TP-079)", () => { const config = loadProjectConfig(dir); expect(config.workspace).toBeDefined(); - expect(config.workspace!.routing.defaultRepo).toBe("infra"); - expect(config.workspace!.routing.taskPacketRepo).toBe("infra"); + expect(config.workspace?.routing?.defaultRepo).toBe("infra"); + expect(config.workspace?.routing?.taskPacketRepo).toBe("infra"); }); it("8.5: JSON workspace section takes precedence over legacy workspace YAML", () => { @@ -1914,9 +1930,67 @@ describe("workspace section threading (TP-079)", () => { const config = loadProjectConfig(dir); expect(config.workspace).toBeDefined(); - expect(config.workspace!.routing.defaultRepo).toBe("jsonrepo"); - expect(config.workspace!.repos).toHaveProperty("jsonrepo"); - expect(config.workspace!.repos).not.toHaveProperty("yamlrepo"); + expect(config.workspace?.routing?.defaultRepo).toBe("jsonrepo"); + expect(config.workspace?.repos).toHaveProperty("jsonrepo"); + expect(config.workspace?.repos).not.toHaveProperty("yamlrepo"); + }); + + it("8.6: orchestrator submodule settings load without workspace metadata", () => { + const dir = makeTestDir("orchestrator-submodules-only"); + writeJsonConfig(dir, { + configVersion: CONFIG_VERSION, + orchestrator: { + orchestrator: { + submoduleRepoIdStrategy: "path-basename", + }, + failure: { + submoduleFailureMode: "strict", + onSubmoduleDrift: "recursive-on-drift", + }, + }, + }); + + const config = loadProjectConfig(dir); + expect(config.orchestrator.failure.submoduleFailureMode).toBe("strict"); + expect(config.orchestrator.failure.onSubmoduleDrift).toBe("recursive-on-drift"); + expect(config.orchestrator.orchestrator.submoduleRepoIdStrategy).toBe("path-basename"); + expect(config.workspace).toBeUndefined(); + }); + + it("8.7: global orchestrator submodule defaults merge with project overrides", () => { + const dir = makeTestDir("orchestrator-submodules-merge"); + const agentDir = makeTestDir("orchestrator-submodules-agent"); + const previousAgentDir = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = agentDir; + mkdirSync(join(agentDir, "taskplane"), { recursive: true }); + writeFileSync(join(agentDir, "taskplane", "preferences.json"), JSON.stringify({ + orchestrator: { + failure: { + submoduleFailureMode: "strict", + onSubmoduleDrift: "init-only", + }, + }, + }, null, 2)); + + writeJsonConfig(dir, { + configVersion: CONFIG_VERSION, + orchestrator: { + orchestrator: { + submoduleRepoIdStrategy: "path-basename", + }, + }, + }); + + const config = loadProjectConfig(dir); + expect(config.orchestrator.failure.submoduleFailureMode).toBe("strict"); + expect(config.orchestrator.failure.onSubmoduleDrift).toBe("init-only"); + expect(config.orchestrator.orchestrator.submoduleRepoIdStrategy).toBe("path-basename"); + + if (previousAgentDir === undefined) { + delete process.env.PI_CODING_AGENT_DIR; + } else { + process.env.PI_CODING_AGENT_DIR = previousAgentDir; + } }); }); diff --git a/extensions/tests/resume-merge-safe-stop.integration.test.ts b/extensions/tests/resume-merge-safe-stop.integration.test.ts new file mode 100644 index 00000000..34d768a7 --- /dev/null +++ b/extensions/tests/resume-merge-safe-stop.integration.test.ts @@ -0,0 +1,514 @@ +import { afterEach, beforeEach, describe, it, mock } from "node:test"; +import { expect } from "./expect.ts"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { execFileSync } from "child_process"; +import { join } from "path"; +import { tmpdir } from "os"; + +const mockRunDiscovery = mock.fn(); +const mockExecuteWave = mock.fn(); +const mockExecLog = mock.fn(); +const mockMergeWaveByRepo = mock.fn(); +const mockSelectRuntimeBackend = mock.fn(() => ({ + backend: "v2", + isSingleTask: false, + isRepoMode: true, + isDirectPromptTarget: false, +})); +const mockResolveDisplayWaveNumber = mock.fn((waveIdx: number) => ({ + displayWave: waveIdx + 1, + displayTotal: 1, +})); + +const discoveryModuleUrl = new URL("../taskplane/discovery.ts", import.meta.url).href; +const executionModuleUrl = new URL("../taskplane/execution.ts", import.meta.url).href; +const engineModuleUrl = new URL("../taskplane/engine.ts", import.meta.url).href; +const mergeModuleUrl = new URL("../taskplane/merge.ts", import.meta.url).href; + +const realDiscovery = await import(new URL("../taskplane/discovery.ts?resume-merge-safe-stop-real", import.meta.url).href); +const realExecution = await import(new URL("../taskplane/execution.ts?resume-merge-safe-stop-real", import.meta.url).href); + +mock.module(discoveryModuleUrl, { + namedExports: { + ...realDiscovery, + runDiscovery: mockRunDiscovery, + }, +}); + +mock.module(executionModuleUrl, { + namedExports: { + ...realExecution, + buildReviewerEnv: mock.fn(() => ({})), + buildWorkerExcludeEnv: mock.fn(() => ({})), + computeTransitiveDependents: mock.fn(() => new Set()), + execLog: mockExecLog, + executeLaneV2: mock.fn(async () => { + throw new Error("executeLaneV2 should not run in merge-retry safe-stop test"); + }), + executeWave: mockExecuteWave, + resolveCanonicalTaskPaths: mock.fn(() => ({ + taskFolderResolved: "", + statusPath: "", + donePath: "", + })), + }, +}); + +mock.module(engineModuleUrl, { + namedExports: { + executeOrchBatch: mock.fn(async () => { + throw new Error("executeOrchBatch should not run in resume merge-retry safe-stop test"); + }), + resolveDisplayWaveNumber: mockResolveDisplayWaveNumber, + selectRuntimeBackend: mockSelectRuntimeBackend, + }, +}); + +mock.module(mergeModuleUrl, { + namedExports: { + mergeWaveByRepo: mockMergeWaveByRepo, + }, +}); + +const { resumeOrchBatch } = await import("../taskplane/resume.ts"); +const { + BATCH_STATE_SCHEMA_VERSION, + DEFAULT_ORCHESTRATOR_CONFIG, + DEFAULT_TASK_RUNNER_CONFIG, + defaultBatchDiagnostics, + defaultResilienceState, + freshOrchBatchState, +} = await import("../taskplane/types.ts"); +const { validatePersistedState, loadBatchState } = await import("../taskplane/persistence.ts"); + +type ParsedTask = import("../taskplane/types.ts").ParsedTask; +type PersistedBatchState = import("../taskplane/types.ts").PersistedBatchState; + +let tmpDir = ""; + +function git(cwd: string, args: string[]): string { + return execFileSync("git", args, { + cwd, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); +} + +function initGitRepo(repoDir: string, branchName: string): void { + mkdirSync(repoDir, { recursive: true }); + git(repoDir, ["init", "--initial-branch=main"]); + git(repoDir, ["config", "user.email", "test@example.com"]); + git(repoDir, ["config", "user.name", "Taskplane Test"]); + writeFileSync(join(repoDir, "README.md"), `# ${branchName}\n`, "utf-8"); + git(repoDir, ["add", "."]); + git(repoDir, ["commit", "-m", "initial commit"]); + git(repoDir, ["branch", branchName]); +} + +function buildPersistedState(options?: { + persistedTransactionRecords?: boolean; + persistenceErrors?: string[]; + persistedFailureReason?: string; +}): PersistedBatchState { + const persistedTransactionRecords = options?.persistedTransactionRecords ?? true; + const persistenceErrors = options?.persistenceErrors; + const persistedFailureReason = options?.persistedFailureReason ?? "rollback failures: [repo:default] reset failed"; + const mergeResult = { + waveIndex: 0, + status: "failed" as const, + laneResults: [], + failedLane: 1, + failureReason: persistedFailureReason, + totalDurationMs: 0, + ...(persistedTransactionRecords ? { + transactionRecords: [ + { + opId: "op-test", + batchId: "20260422T150000", + waveTransactionId: "wave-test", + waveIndex: 0, + repoAttemptSequence: 1, + laneNumber: 1, + repoId: null, + baseHEAD: "11111111", + laneHEAD: "22222222", + mergedHEAD: null, + status: "rollback_failed" as const, + rollbackAttempted: true, + rollbackResult: "reset failed: simulated persisted rollback failure", + recoveryCommands: ["git reset --hard 11111111"], + startedAt: new Date(Date.now() - 20_000).toISOString(), + completedAt: new Date(Date.now() - 19_000).toISOString(), + }, + ], + } : {}), + ...(persistenceErrors ? { persistenceErrors } : {}), + }; + + return { + schemaVersion: BATCH_STATE_SCHEMA_VERSION, + phase: "paused", + batchId: "20260422T150000", + baseBranch: "main", + orchBranch: "orch/test-resume-rollback-safe-stop", + mode: "repo", + startedAt: Date.now() - 60_000, + updatedAt: Date.now(), + endedAt: null, + currentWaveIndex: 0, + totalWaves: 1, + wavePlan: [["TP-001"]], + lanes: [ + { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-test-lane-1", + worktreePath: join(tmpDir, "worktrees", "lane-1"), + branch: "task/lane-1", + taskIds: ["TP-001"], + }, + ], + tasks: [ + { + taskId: "TP-001", + laneNumber: 1, + sessionName: "orch-test-lane-1", + status: "succeeded", + taskFolder: join(tmpDir, "tasks", "TP-001"), + startedAt: Date.now() - 30_000, + endedAt: Date.now() - 20_000, + doneFileFound: true, + exitReason: "completed in prior run", + }, + ], + mergeResults: [mergeResult], + totalTasks: 1, + succeededTasks: 1, + failedTasks: 0, + skippedTasks: 0, + blockedTasks: 0, + blockedTaskIds: [], + lastError: null, + errors: [], + resilience: defaultResilienceState(), + diagnostics: defaultBatchDiagnostics(), + segments: [], + }; +} + +function writeStateFixture(options?: { + persistedTransactionRecords?: boolean; + persistenceErrors?: string[]; + persistedFailureReason?: string; +}): void { + mkdirSync(join(tmpDir, ".pi"), { recursive: true }); + mkdirSync(join(tmpDir, "tasks", "TP-001"), { recursive: true }); + const validated = validatePersistedState(buildPersistedState(options)); + writeFileSync(join(tmpDir, ".pi", "batch-state.json"), JSON.stringify(validated, null, 2)); +} + +function makeCompletedTask(taskId: string): ParsedTask { + return { + taskId, + taskName: `Task ${taskId}`, + reviewLevel: 1, + size: "M", + dependencies: [], + fileScope: [], + taskFolder: join(tmpDir, "tasks", taskId), + promptPath: join(tmpDir, "tasks", taskId, "PROMPT.md"), + areaName: "default", + status: "completed", + }; +} + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "tp-resume-merge-safe-stop-")); + mockRunDiscovery.mock.resetCalls(); + mockExecuteWave.mock.resetCalls(); + mockExecLog.mock.resetCalls(); + mockMergeWaveByRepo.mock.resetCalls(); + mockSelectRuntimeBackend.mock.resetCalls(); + mockResolveDisplayWaveNumber.mock.resetCalls(); + + writeStateFixture(); + + mockRunDiscovery.mock.mockImplementation((() => ({ + pending: new Map(), + completed: new Map([["TP-001", makeCompletedTask("TP-001")]]), + })) as any); + + mockExecuteWave.mock.mockImplementation((async () => { + throw new Error("executeWave should not run when resume enters merge-retry safe-stop path"); + }) as any); + + mockMergeWaveByRepo.mock.mockImplementation((async () => ({ + waveIndex: 1, + status: "failed", + laneResults: [], + failedLane: 1, + failureReason: "rollback failures: [repo:default] reset failed during merge retry", + totalDurationMs: 0, + transactionRecords: [ + { + opId: "op-test", + batchId: "20260422T150000", + waveTransactionId: "wave-test-retry", + waveIndex: 0, + repoAttemptSequence: 1, + laneNumber: 1, + repoId: null, + baseHEAD: "11111111", + laneHEAD: "22222222", + mergedHEAD: null, + status: "rollback_failed", + rollbackAttempted: true, + rollbackResult: "reset failed: simulated retry rollback failure", + recoveryCommands: ["git reset --hard 11111111"], + startedAt: new Date(Date.now() - 5_000).toISOString(), + completedAt: new Date(Date.now() - 4_000).toISOString(), + }, + ], + })) as any); +}); + +afterEach(() => { + if (tmpDir) { + rmSync(tmpDir, { recursive: true, force: true }); + } + tmpDir = ""; +}); + +describe("resumeOrchBatch merge-retry rollback safe-stop", () => { + it("forces paused safe-stop from stale rollback metadata even when abort policy is configured", async () => { + const batchState = freshOrchBatchState(); + const notifications: Array<{ message: string; level: string }> = []; + const orchConfig = { + ...DEFAULT_ORCHESTRATOR_CONFIG, + failure: { + ...DEFAULT_ORCHESTRATOR_CONFIG.failure, + on_merge_failure: "abort", + }, + }; + + await resumeOrchBatch( + orchConfig, + DEFAULT_TASK_RUNNER_CONFIG, + tmpDir, + batchState, + (message, level) => { + notifications.push({ message, level }); + }, + undefined, + null, + tmpDir, + undefined, + false, + undefined, + "supervised", + ); + + expect(mockRunDiscovery.mock.calls.length).toBe(1); + expect(mockExecuteWave.mock.calls.length).toBe(0); + expect(mockMergeWaveByRepo.mock.calls.length).toBe(1); + expect(batchState.phase).toBe("paused"); + expect(batchState.errors.some((message) => message.includes("Safe-stop at wave 1: verification rollback failed."))).toBe(true); + expect(notifications.some((entry) => entry.level === "error" && entry.message.includes("🛑 Safe-stop: verification rollback failed at wave 1."))).toBe(true); + expect(notifications.some((entry) => entry.message.includes("Batch aborted due to merge failure"))).toBe(false); + + const persisted = loadBatchState(tmpDir); + expect(persisted).not.toBeNull(); + expect(persisted!.phase).toBe("paused"); + expect(persisted!.errors.some((message) => message.includes("Safe-stop at wave 1: verification rollback failed."))).toBe(true); + }); + + it("includes persistence warning when transaction records are missing but persistenceErrors survived", async () => { + rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = mkdtempSync(join(tmpdir(), "tp-resume-merge-safe-stop-")); + mockRunDiscovery.mock.resetCalls(); + mockExecuteWave.mock.resetCalls(); + mockExecLog.mock.resetCalls(); + mockMergeWaveByRepo.mock.resetCalls(); + mockSelectRuntimeBackend.mock.resetCalls(); + mockResolveDisplayWaveNumber.mock.resetCalls(); + + writeStateFixture({ + persistedTransactionRecords: false, + persistenceErrors: ["lane 1 (repo: default): ENOENT: transaction record missing"], + persistedFailureReason: "rollback failures: recovery files missing after rollback failure", + }); + + mockRunDiscovery.mock.mockImplementation((() => ({ + pending: new Map(), + completed: new Map([["TP-001", makeCompletedTask("TP-001")]]), + })) as any); + + mockExecuteWave.mock.mockImplementation((async () => { + throw new Error("executeWave should not run when resume enters merge-retry safe-stop path"); + }) as any); + + mockMergeWaveByRepo.mock.mockImplementation((async () => ({ + waveIndex: 1, + status: "failed", + laneResults: [], + failedLane: 1, + failureReason: "rollback failures: recovery files missing after retry rollback failure", + totalDurationMs: 0, + persistenceErrors: ["lane 1 (repo: default): ENOENT: transaction record missing"], + })) as any); + + const batchState = freshOrchBatchState(); + const notifications: Array<{ message: string; level: string }> = []; + const orchConfig = { + ...DEFAULT_ORCHESTRATOR_CONFIG, + failure: { + ...DEFAULT_ORCHESTRATOR_CONFIG.failure, + on_merge_failure: "abort", + }, + }; + + await resumeOrchBatch( + orchConfig, + DEFAULT_TASK_RUNNER_CONFIG, + tmpDir, + batchState, + (message, level) => { + notifications.push({ message, level }); + }, + undefined, + null, + tmpDir, + undefined, + false, + undefined, + "supervised", + ); + + expect(mockMergeWaveByRepo.mock.calls.length).toBe(1); + expect(batchState.phase).toBe("paused"); + expect(batchState.errors.some((message) => message.includes("transaction record(s) failed to persist"))).toBe(true); + expect(batchState.errors.some((message) => message.includes("recovery file(s) may be missing"))).toBe(true); + expect(notifications.some((entry) => entry.level === "error" && entry.message.includes("transaction record(s) failed to persist"))).toBe(true); + expect(notifications.some((entry) => entry.level === "error" && entry.message.includes("recovery file(s) may be missing"))).toBe(true); + + const persisted = loadBatchState(tmpDir); + expect(persisted).not.toBeNull(); + expect(persisted!.phase).toBe("paused"); + expect(persisted!.errors.some((message) => message.includes("transaction record(s) failed to persist"))).toBe(true); + }); + + it("workspace mode keeps repo-scoped attribution when repoResults survive but transaction files do not", async () => { + rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = mkdtempSync(join(tmpdir(), "tp-resume-merge-safe-stop-ws-")); + const apiRepo = join(tmpDir, "repos", "api"); + const webRepo = join(tmpDir, "repos", "web"); + initGitRepo(apiRepo, "orch/test-resume-rollback-safe-stop"); + initGitRepo(webRepo, "orch/test-resume-rollback-safe-stop"); + + mockRunDiscovery.mock.resetCalls(); + mockExecuteWave.mock.resetCalls(); + mockExecLog.mock.resetCalls(); + mockMergeWaveByRepo.mock.resetCalls(); + mockSelectRuntimeBackend.mock.resetCalls(); + mockResolveDisplayWaveNumber.mock.resetCalls(); + + writeStateFixture({ + persistedTransactionRecords: false, + persistenceErrors: ["lane 1 (repo: api): ENOENT: transaction record missing"], + persistedFailureReason: "merge retry failed in workspace mode", + }); + const persisted = JSON.parse(readFileSync(join(tmpDir, ".pi", "batch-state.json"), "utf-8")); + persisted.mode = "workspace"; + persisted.lanes[0].repoId = "api"; + persisted.tasks[0].repoId = "api"; + persisted.tasks[0].resolvedRepoId = "api"; + persisted.tasks[0].taskFolder = join(apiRepo, "tasks", "TP-001"); + persisted.lanes[0].worktreePath = join(apiRepo, ".worktrees", "lane-1"); + writeFileSync(join(tmpDir, ".pi", "batch-state.json"), JSON.stringify(validatePersistedState(persisted), null, 2)); + + mockRunDiscovery.mock.mockImplementation((() => ({ + pending: new Map(), + completed: new Map([["TP-001", { + ...makeCompletedTask("TP-001"), + resolvedRepoId: "api", + }]]), + })) as any); + + mockExecuteWave.mock.mockImplementation((async () => { + throw new Error("executeWave should not run when resume enters workspace merge-retry safe-stop path"); + }) as any); + + mockMergeWaveByRepo.mock.mockImplementation((async () => ({ + waveIndex: 1, + status: "failed", + laneResults: [], + failedLane: null, + failureReason: "workspace merge retry failed", + totalDurationMs: 0, + persistenceErrors: ["lane 1 (repo: api): ENOENT: transaction record missing"], + repoResults: [ + { + repoId: "api", + status: "failed", + laneResults: [], + failedLane: null, + failureReason: "cross_repo_atomic_rollback_failed: unable to restore api main", + }, + { + repoId: "web", + status: "failed", + laneResults: [], + failedLane: null, + failureReason: "cross_repo_atomic_rollback: rolled back because another repo in the wave failed", + }, + ], + })) as any); + + const batchState = freshOrchBatchState(); + const notifications: Array<{ message: string; level: string }> = []; + const alerts: Array = []; + const orchConfig = { + ...DEFAULT_ORCHESTRATOR_CONFIG, + failure: { + ...DEFAULT_ORCHESTRATOR_CONFIG.failure, + on_merge_failure: "abort", + }, + }; + const workspaceConfig = { + repos: new Map([ + ["api", { path: apiRepo }], + ["web", { path: webRepo }], + ]), + } as any; + + await resumeOrchBatch( + orchConfig, + DEFAULT_TASK_RUNNER_CONFIG, + apiRepo, + batchState, + (message, level) => { + notifications.push({ message, level }); + }, + undefined, + workspaceConfig, + tmpDir, + undefined, + false, + (alert) => { + alerts.push(alert); + }, + "supervised", + ); + + expect(mockMergeWaveByRepo.mock.calls.length).toBe(1); + expect(batchState.phase).toBe("paused"); + expect(notifications.some((entry) => entry.level === "error" && entry.message.includes("transaction record(s) failed to persist"))).toBe(true); + expect(alerts.length).toBeGreaterThanOrEqual(1); + expect(alerts.some((alert) => alert.category === "merge-failure" && alert.context.repoId === "api")).toBe(true); + + const reloaded = loadBatchState(tmpDir); + expect(reloaded).not.toBeNull(); + expect(reloaded!.phase).toBe("paused"); + expect(reloaded!.errors.some((message) => message.includes("transaction record(s) failed to persist"))).toBe(true); + }); +}); \ No newline at end of file diff --git a/extensions/tests/resume-segment-frontier.test.ts b/extensions/tests/resume-segment-frontier.test.ts index f403e5c4..619902fc 100644 --- a/extensions/tests/resume-segment-frontier.test.ts +++ b/extensions/tests/resume-segment-frontier.test.ts @@ -124,6 +124,61 @@ describe("TP-135 resume segment fallback behavior", () => { } }); + it("finds .DONE in a secondary repoWorktree when the primary lane worktree lacks it", () => { + const root = join(tmpdir(), `tp135-secondary-done-${Date.now()}`); + const primaryWorktree = join(root, "wt-primary"); + const secondaryWorktree = join(root, "wt-secondary"); + const taskFolder = join(root, "tasks", "TP-001"); + mkdirSync(primaryWorktree, { recursive: true }); + mkdirSync(secondaryWorktree, { recursive: true }); + mkdirSync(join(secondaryWorktree, "tasks", "TP-001"), { recursive: true }); + writeFileSync(join(secondaryWorktree, "tasks", "TP-001", ".DONE"), "", "utf8"); + + try { + const state = makeState({ + mode: "workspace", + lanes: [{ + laneNumber: 1, + laneId: "api/lane-1", + laneSessionId: "orch-api-lane-1", + worktreePath: primaryWorktree, + repoWorktrees: { + api: { path: primaryWorktree, branch: "task/api-lane-1", laneNumber: 1, repoId: "api" }, + docs: { path: secondaryWorktree, branch: "task/api-lane-1", laneNumber: 1, repoId: "docs" }, + }, + branch: "task/api-lane-1", + repoId: "api", + taskIds: ["TP-001"], + }], + tasks: [{ + taskId: "TP-001", + laneNumber: 1, + sessionName: "orch-api-lane-1", + status: "running", + taskFolder, + startedAt: Date.now() - 1000, + endedAt: null, + doneFileFound: false, + exitReason: "", + }], + }); + + const doneTaskIds = collectDoneTaskIdsForResume(state, root, { + mode: "workspace", + repos: new Map([ + ["api", { id: "api", path: join(root, "api") }], + ["docs", { id: "docs", path: join(root, "docs") }], + ]), + routing: { tasksRoot: join(root, "tasks"), defaultRepo: "docs" }, + configPath: join(root, ".pi", "taskplane-workspace.yaml"), + } as any); + + expect([...doneTaskIds]).toContain("TP-001"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it("falls back to task-level resume logic when mapped segment record is missing", () => { const state = makeState({ wavePlan: [["TP-010"], ["TP-010"]], @@ -358,6 +413,76 @@ describe("TP-135 resume segment fallback behavior", () => { expect(point.pendingTaskIds).toContain("TP-050"); }); + it("reconstructs inferred continuation rounds from segments when task.segmentIds is missing", () => { + const state = makeState({ + wavePlan: [["TP-080"]], + totalWaves: 1, + mergeResults: [{ waveIndex: 0, status: "succeeded" }] as any, + tasks: [{ + taskId: "TP-080", + laneNumber: 1, + sessionName: "", + status: "pending", + taskFolder: "/tmp/tasks/TP-080", + startedAt: null, + endedAt: null, + doneFileFound: false, + exitReason: "", + activeSegmentId: null, + }], + segments: [ + makeSegment({ taskId: "TP-080", segmentId: "TP-080::api", status: "succeeded", endedAt: Date.now() - 100 }), + makeSegment({ taskId: "TP-080", segmentId: "TP-080::web", repoId: "web", status: "pending", dependsOnSegmentIds: ["TP-080::api"] }), + makeSegment({ taskId: "TP-080", segmentId: "TP-080::docs", repoId: "docs", status: "pending", dependsOnSegmentIds: ["TP-080::web"] }), + ], + }); + + const frontier = reconstructSegmentFrontier(state); + expect(state.tasks[0].segmentIds).toEqual(["TP-080::api", "TP-080::web", "TP-080::docs"]); + expect(state.tasks[0].activeSegmentId).toBe("TP-080::web"); + expect(frontier.get("TP-080")!.pendingSegmentIds).toEqual(["TP-080::web", "TP-080::docs"]); + + const runtimeWavePlan = buildResumeRuntimeWavePlan(state); + expect(runtimeWavePlan).toEqual([["TP-080"], ["TP-080"], ["TP-080"]]); + + const reconciled = reconcileTaskStates(state, new Set(), new Set(), new Set(["TP-080"])); + const point = computeResumePoint(state, reconciled, runtimeWavePlan); + expect(point.resumeWaveIndex).toBe(1); + expect(point.pendingTaskIds).toContain("TP-080"); + }); + + it("computeResumePoint honors degraded persisted segments even before frontier reconstruction", () => { + const state = makeState({ + wavePlan: [["TP-081"], ["TP-081"]], + totalWaves: 2, + mergeResults: [{ waveIndex: 0, status: "succeeded" }] as any, + tasks: [{ + taskId: "TP-081", + laneNumber: 1, + sessionName: "", + status: "succeeded", + taskFolder: "/tmp/tasks/TP-081", + startedAt: Date.now() - 1000, + endedAt: Date.now() - 100, + doneFileFound: false, + exitReason: "", + activeSegmentId: null, + }], + segments: [ + makeSegment({ taskId: "TP-081", segmentId: "TP-081::api", status: "succeeded", endedAt: Date.now() - 200 }), + makeSegment({ taskId: "TP-081", segmentId: "TP-081::web", repoId: "web", status: "pending", dependsOnSegmentIds: ["TP-081::api"] }), + ], + }); + + const reconciled = reconcileTaskStates(state, new Set(), new Set(), new Set(["TP-081"])); + const runtimeWavePlan = buildResumeRuntimeWavePlan(state); + const point = computeResumePoint(state, reconciled, runtimeWavePlan); + + expect(runtimeWavePlan).toEqual([["TP-081"], ["TP-081"]]); + expect(point.resumeWaveIndex).toBe(1); + expect(point.pendingTaskIds).toEqual(["TP-081"]); + }); + it("resume wave-plan expansion groups continuation rounds for multi-task wave parity", () => { const state = makeState({ wavePlan: [["TP-060", "TP-061"], ["TP-062"]], @@ -463,6 +588,7 @@ describe("TP-169 resume after segment expansion — no crash, taskFolder populat endedAt: null, doneFileFound: false, exitReason: "", + resolvedRepoIds: ["api", "web"], segmentIds: ["TP-070::api", "TP-070::web"], activeSegmentId: "TP-070::web", }], @@ -479,6 +605,7 @@ describe("TP-169 resume after segment expansion — no crash, taskFolder populat expect(typeof task.task.taskFolder).toBe("string"); // taskFolder should be "" (the persisted value), NOT undefined expect(task.task.taskFolder).toBe(""); + expect((task.task as any).resolvedRepoIds).toEqual(["api", "web"]); // Segment metadata should be carried forward expect(task.task.segmentIds).toEqual(["TP-070::api", "TP-070::web"]); expect(task.task.activeSegmentId).toBe("TP-070::web"); diff --git a/extensions/tests/resume-supervisor-alert.integration.test.ts b/extensions/tests/resume-supervisor-alert.integration.test.ts new file mode 100644 index 00000000..99e25e38 --- /dev/null +++ b/extensions/tests/resume-supervisor-alert.integration.test.ts @@ -0,0 +1,474 @@ +/** + * Resume Supervisor Alert Acceptance Tests — TP-166 / #51 + * + * Validates that resumeOrchBatch rehydrates persisted multi-repo segment + * metadata into discovered tasks before resumed execution and emits the + * supervisor task-failure alert from the real resume path. + * + * Run: node --experimental-strip-types --experimental-test-module-mocks --no-warnings --import ./tests/loader.mjs --test tests/resume-supervisor-alert.integration.test.ts + */ + +import { afterEach, beforeEach, describe, it, mock } from "node:test"; +import { expect } from "./expect.ts"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { dirname, join } from "path"; +import { tmpdir } from "os"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixturePath = join(__dirname, "fixtures", "batch-state-v2-polyrepo.json"); + +const mockRunDiscovery = mock.fn(); +const mockExecuteWave = mock.fn(); +const mockExecLog = mock.fn(); +const mockComputeTransitiveDependents = mock.fn(() => new Set()); +const mockSelectRuntimeBackend = mock.fn(() => ({ + backend: "v2", + isSingleTask: false, + isRepoMode: true, + isDirectPromptTarget: false, +})); +const mockResolveDisplayWaveNumber = mock.fn((waveIdx: number, _roundToTaskWave?: boolean, taskLevelWaveCount?: number) => ({ + displayWave: waveIdx + 1, + displayTotal: typeof taskLevelWaveCount === "number" ? taskLevelWaveCount : 2, +})); + +const discoveryModuleUrl = new URL("../taskplane/discovery.ts", import.meta.url).href; +const executionModuleUrl = new URL("../taskplane/execution.ts", import.meta.url).href; +const engineModuleUrl = new URL("../taskplane/engine.ts", import.meta.url).href; +const realDiscovery = await import(new URL("../taskplane/discovery.ts?resume-supervisor-alert-real", import.meta.url).href); +const realExecution = await import(new URL("../taskplane/execution.ts?resume-supervisor-alert-real", import.meta.url).href); + +mock.module(discoveryModuleUrl, { + namedExports: { + ...realDiscovery, + runDiscovery: mockRunDiscovery, + }, +}); + +mock.module(executionModuleUrl, { + namedExports: { + ...realExecution, + buildReviewerEnv: mock.fn(() => ({})), + buildWorkerExcludeEnv: mock.fn(() => ({})), + computeTransitiveDependents: mockComputeTransitiveDependents, + execLog: mockExecLog, + executeLaneV2: mock.fn(async () => { + throw new Error("executeLaneV2 should not run in this acceptance test"); + }), + executeWave: mockExecuteWave, + resolveCanonicalTaskPaths: mock.fn(() => ({ + taskFolderResolved: "", + statusPath: "", + donePath: "", + })), + }, +}); + +mock.module(engineModuleUrl, { + namedExports: { + executeOrchBatch: mock.fn(async () => { + throw new Error("executeOrchBatch should not run in resume acceptance tests"); + }), + resolveDisplayWaveNumber: mockResolveDisplayWaveNumber, + selectRuntimeBackend: mockSelectRuntimeBackend, + }, +}); + +const { resumeOrchBatch } = await import("../taskplane/resume.ts"); +const { + DEFAULT_ORCHESTRATOR_CONFIG, + DEFAULT_TASK_RUNNER_CONFIG, + buildSupervisorTaskFailureAlert, + freshOrchBatchState, +} = await import("../taskplane/types.ts"); +const { validatePersistedState } = await import("../taskplane/persistence.ts"); + +type ParsedTask = import("../taskplane/types.ts").ParsedTask; +type SupervisorAlert = import("../taskplane/types.ts").SupervisorAlert; +type PersistedBatchState = import("../taskplane/types.ts").PersistedBatchState; + +let tmpDir = ""; +let capturedPendingTask: ParsedTask | null = null; + +function makeParsedTask(taskId: string, overrides: Partial = {}): ParsedTask { + return { + taskId, + taskName: `Task ${taskId}`, + reviewLevel: 1, + size: "M", + dependencies: [], + fileScope: [], + taskFolder: join(tmpDir, "tasks", taskId), + promptPath: join(tmpDir, "tasks", taskId, "PROMPT.md"), + areaName: "default", + status: "pending", + ...overrides, + }; +} + +function writeResumeFixtureState(): void { + const fixture = JSON.parse(readFileSync(fixturePath, "utf-8")) as PersistedBatchState; + const apiWorktreePath = join(tmpDir, "worktrees", "api"); + const frontendWorktreePath = join(tmpDir, "worktrees", "frontend"); + const docsLaneWorktreePath = join(tmpDir, "worktrees", "docs-lane"); + const apiLaneWorktreePath = join(tmpDir, "worktrees", "api-lane"); + const frontendLaneWorktreePath = join(tmpDir, "worktrees", "frontend-lane"); + + fixture.orchBranch = "orch/test-resume-polyrepo"; + fixture.currentWaveIndex = 0; + fixture.totalWaves = 2; + fixture.wavePlan = [["AP-002"], ["SH-002"]]; + fixture.totalTasks = 2; + fixture.succeededTasks = 0; + fixture.failedTasks = 0; + fixture.skippedTasks = 0; + fixture.blockedTasks = 0; + fixture.blockedTaskIds = []; + fixture.lastError = null; + fixture.errors = []; + fixture.mergeResults = []; + for (const lane of fixture.lanes) { + if (lane.laneId === "docs/lane-1") { + lane.taskIds = ["SH-002"]; + lane.worktreePath = docsLaneWorktreePath; + } else if (lane.laneId === "api/lane-2") { + lane.taskIds = ["AP-002"]; + lane.worktreePath = apiLaneWorktreePath; + } else if (lane.laneId === "frontend/lane-3") { + lane.taskIds = []; + lane.worktreePath = frontendLaneWorktreePath; + } + } + fixture.lanes = fixture.lanes.filter((lane) => lane.laneId === "docs/lane-1" || lane.laneId === "api/lane-2"); + fixture.segments = [ + { + segmentId: "AP-002::frontend", + taskId: "AP-002", + repoId: "frontend", + status: "pending", + laneId: "frontend/lane-2", + sessionName: "orch-op-api-lane-2", + worktreePath: frontendWorktreePath, + branch: "task/op-frontend-segment-20260316T120000", + startedAt: null, + endedAt: null, + retries: 0, + dependsOnSegmentIds: ["AP-002::api"], + exitReason: "", + }, + ]; + + const apiLane = fixture.lanes.find((lane) => lane.laneId === "api/lane-2"); + if (!apiLane) { + throw new Error("api/lane-2 fixture lane missing"); + } + apiLane.repoWorktrees = { + api: { + path: apiWorktreePath, + branch: "task/op-api-lane-2-20260316T120000", + laneNumber: 2, + repoId: "api", + }, + frontend: { + path: frontendWorktreePath, + branch: "task/op-frontend-segment-20260316T120000", + laneNumber: 2, + repoId: "frontend", + }, + }; + + const apiTask = fixture.tasks.find((task) => task.taskId === "AP-002"); + if (!apiTask) { + throw new Error("AP-002 fixture task missing"); + } + Object.assign(apiTask, { + status: "pending", + sessionName: "", + startedAt: null, + endedAt: null, + doneFileFound: false, + exitReason: "", + resolvedRepoIds: ["api", "frontend"], + resolvedRepoId: "api", + participatingRepoIds: ["api", "frontend"], + segmentIds: ["AP-002::frontend"], + activeSegmentId: "AP-002::frontend", + packetRepoId: "frontend", + packetTaskPath: "tasks/api-tasks/AP-002-implement-auth-endpoints", + }); + + const frontendTask = fixture.tasks.find((task) => task.taskId === "UI-002"); + if (!frontendTask) { + throw new Error("UI-002 fixture task missing"); + } + Object.assign(frontendTask, { + status: "pending", + startedAt: null, + endedAt: null, + doneFileFound: false, + exitReason: "", + }); + fixture.tasks = [apiTask, frontendTask]; + + const validated = validatePersistedState(fixture); + const piDir = join(tmpDir, ".pi"); + mkdirSync(piDir, { recursive: true }); + writeFileSync(join(piDir, "batch-state.json"), JSON.stringify(validated, null, 2)); +} + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "tp-resume-alert-")); + capturedPendingTask = null; + mockRunDiscovery.mock.resetCalls(); + mockExecuteWave.mock.resetCalls(); + mockExecLog.mock.resetCalls(); + mockComputeTransitiveDependents.mock.resetCalls(); + mockSelectRuntimeBackend.mock.resetCalls(); + mockResolveDisplayWaveNumber.mock.resetCalls(); + + writeResumeFixtureState(); + + mockRunDiscovery.mock.mockImplementation((() => ({ + pending: new Map([ + ["AP-002", makeParsedTask("AP-002", { resolvedRepoId: "api" })], + ["SH-002", makeParsedTask("SH-002", { dependencies: ["AP-002"], resolvedRepoId: "docs" })], + ]), + completed: new Map([ + ["SH-001", makeParsedTask("SH-001", { resolvedRepoId: "docs" })], + ["AP-001", makeParsedTask("AP-001", { resolvedRepoId: "api" })], + ["UI-001", makeParsedTask("UI-001", { resolvedRepoId: "frontend" })], + ["UI-002", makeParsedTask("UI-002", { resolvedRepoId: "frontend" })], + ]), + })) as any); + + mockExecuteWave.mock.mockImplementation((async ( + waveTasks: string[], + waveIndex: number, + pending: Map, + _orchConfig: unknown, + _repoRoot: string, + _batchId: string, + _pauseSignal: unknown, + _depGraph: unknown, + _orchBranch: string, + _monitorUpdate: unknown, + _onAllocatedLanes: unknown, + _workspaceConfig: unknown, + _resumeBackend: unknown, + emitAlert?: (alert: SupervisorAlert) => void, + ) => { + expect(waveTasks).toEqual(["AP-002"]); + capturedPendingTask = pending.get("AP-002") ?? null; + const apiTask = pending.get("AP-002"); + if (!apiTask) { + throw new Error("AP-002 missing from pending map"); + } + if (emitAlert) { + emitAlert(buildSupervisorTaskFailureAlert({ + taskId: "AP-002", + failurePolicy: "skip-dependents", + exitReason: "Segment compile failed", + partialProgress: false, + laneId: "frontend/lane-2", + laneNumber: 2, + laneRepoId: "frontend", + taskSegmentIds: ["AP-002::frontend"], + taskActiveSegmentId: "AP-002::frontend", + persistedSegments: [ + { + segmentId: "AP-002::api", + taskId: "AP-002", + repoId: "api", + status: "succeeded", + laneId: "api/lane-2", + sessionName: "orch-op-api-lane-2", + worktreePath: join(tmpDir, "worktrees", "api"), + branch: "task/op-api-lane-2-20260316T120000", + startedAt: 1741478600000, + endedAt: 1741478610000, + retries: 0, + dependsOnSegmentIds: [], + exitReason: "Segment completed successfully", + }, + { + segmentId: "AP-002::frontend", + taskId: "AP-002", + repoId: "frontend", + status: "pending", + laneId: "frontend/lane-2", + sessionName: "orch-op-api-lane-2", + worktreePath: join(tmpDir, "worktrees", "frontend"), + branch: "task/op-frontend-segment-20260316T120000", + startedAt: null, + endedAt: null, + retries: 0, + dependsOnSegmentIds: ["AP-002::api"], + exitReason: "", + }, + ], + outcomeSegmentId: "AP-002::frontend", + blockedTaskIds: ["SH-002"], + batchProgress: { + succeededTasks: 0, + failedTasks: 0, + skippedTasks: 0, + blockedTasks: 0, + totalTasks: 2, + currentWave: 1, + totalWaves: 2, + }, + displayWave: 1, + totalDisplayWaves: 2, + })); + } + + const allocatedLane = { + laneNumber: 2, + laneId: "frontend/lane-2", + laneSessionId: "orch-op-api-lane-2", + worktreePath: join(tmpDir, "worktrees", "frontend"), + branch: "task/op-frontend-segment-20260316T120000", + repoId: "frontend", + repoWorktrees: { + api: { + path: join(tmpDir, "worktrees", "api"), + branch: "task/op-api-lane-2-20260316T120000", + laneNumber: 2, + repoId: "api", + }, + frontend: { + path: join(tmpDir, "worktrees", "frontend"), + branch: "task/op-frontend-segment-20260316T120000", + laneNumber: 2, + repoId: "frontend", + }, + }, + tasks: [ + { + taskId: "AP-002", + order: 0, + task: apiTask, + estimatedMinutes: 60, + }, + ], + strategy: "round-robin", + estimatedLoad: 1, + estimatedMinutes: 60, + }; + + return { + waveIndex, + startedAt: 10, + endedAt: 25, + laneResults: [ + { + laneNumber: 2, + laneId: "frontend/lane-2", + tasks: [ + { + taskId: "AP-002", + status: "failed", + startTime: 10, + endTime: 25, + exitReason: "Segment compile failed", + sessionName: "orch-op-api-lane-2", + doneFileFound: false, + laneNumber: 2, + segmentId: "AP-002::frontend", + }, + ], + overallStatus: "failed", + startTime: 10, + endTime: 25, + }, + ], + policyApplied: "skip-dependents", + stoppedEarly: false, + failedTaskIds: ["AP-002"], + skippedTaskIds: [], + succeededTaskIds: [], + blockedTaskIds: ["SH-002"], + laneCount: 1, + overallStatus: "failed", + finalMonitorState: null, + allocatedLanes: [allocatedLane], + }; + }) as any); +}); + +afterEach(() => { + if (tmpDir) { + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // best effort cleanup + } + } + tmpDir = ""; + capturedPendingTask = null; +}); + +describe("resumeOrchBatch multi-repo alert acceptance", () => { + it("rehydrates segment metadata into resumed execution and emits the task-failure alert", async () => { + const batchState = freshOrchBatchState(); + const notifications: Array<{ message: string; level: string }> = []; + const alerts: SupervisorAlert[] = []; + + await resumeOrchBatch( + DEFAULT_ORCHESTRATOR_CONFIG, + DEFAULT_TASK_RUNNER_CONFIG, + tmpDir, + batchState, + (message, level) => { + notifications.push({ message, level }); + }, + undefined, + null, + tmpDir, + undefined, + false, + (alert) => { + alerts.push(alert); + }, + "supervised", + ); + + expect(mockRunDiscovery.mock.calls.length).toBe(1); + expect(mockExecuteWave.mock.calls.length).toBe(1); + expect(capturedPendingTask?.resolvedRepoIds).toEqual(["api", "frontend"]); + expect(capturedPendingTask?.participatingRepoIds).toEqual(["api", "frontend"]); + expect(capturedPendingTask?.segmentIds).toEqual(["AP-002::frontend"]); + expect(capturedPendingTask?.activeSegmentId).toBe("AP-002::frontend"); + expect(capturedPendingTask?.packetRepoId).toBe("frontend"); + expect(capturedPendingTask?.packetTaskPath).toBe("tasks/api-tasks/AP-002-implement-auth-endpoints"); + + expect(alerts.some((entry) => + entry.category === "task-failure" + && entry.context.taskId === "AP-002" + && entry.context.segmentId === "AP-002::frontend", + )).toBe(true); + const alert = alerts.find((entry) => + entry.category === "task-failure" + && entry.context.taskId === "AP-002" + && entry.context.segmentId === "AP-002::frontend", + ); + if (!alert) { + throw new Error("expected a task-failure supervisor alert for AP-002::frontend"); + } + expect(alert.category).toBe("task-failure"); + expect(alert.context.taskId).toBe("AP-002"); + expect(alert.context.segmentId).toBe("AP-002::frontend"); + expect(alert.context.repoId).toBe("frontend"); + expect(alert.context.failurePolicy).toBe("skip-dependents"); + expect(alert.context.blockedTaskIds).toEqual(["SH-002"]); + expect(alert.context.continueUnaffected).toBe(true); + expect(alert.summary).toContain("Segment: AP-002::frontend (repo: frontend)"); + expect(alert.summary).toContain("Newly blocked dependents: SH-002"); + expect(alert.summary).toContain("Unrelated ready tasks continue under skip-dependents."); + + expect(batchState.failedTasks).toBe(1); + expect([...batchState.blockedTaskIds]).toEqual(["SH-002"]); + expect(notifications.some((entry) => entry.level === "warning" && entry.message.includes("Wave 1"))).toBe(true); + }); +}); \ No newline at end of file diff --git a/extensions/tests/retry-matrix.test.ts b/extensions/tests/retry-matrix.test.ts index 28b2de9a..fed3e25e 100644 --- a/extensions/tests/retry-matrix.test.ts +++ b/extensions/tests/retry-matrix.test.ts @@ -29,6 +29,7 @@ import { buildMergeRetryScopeKey, extractFailedRepoId, applyMergeRetryLoop, + mergeRequiresRollbackSafeStop, } from "../taskplane/messages.ts"; import type { MergeRetryCallbacks } from "../taskplane/types.ts"; import { @@ -836,6 +837,49 @@ describe("9.x — Workspace-scoped counters (repoId in scope key)", () => { // All three keys are different expect(new Set([key1, key2, key3]).size).toBe(3); }); + + it("9.6: mergeRequiresRollbackSafeStop infers rollback failure from stale transaction metadata", () => { + const result = makeWaveResult({ + rollbackFailed: undefined, + transactionRecords: [ + { + laneNumber: 1, + waveTransactionId: "wave-test", + opId: "op-test", + repoId: "api", + sourceBranch: "task/lane-1", + targetBranch: "main", + worktreePath: "/tmp/worktree", + preMergeHead: "abc123", + mergeCommit: null, + status: "rollback_failed", + rollbackAttempted: true, + rollbackResult: "reset failed: simulated", + recoveryCommands: ["git reset --hard abc123"], + }, + ] as any, + }); + + expect(mergeRequiresRollbackSafeStop(result)).toBe(true); + }); + + it("9.7: mergeRequiresRollbackSafeStop infers rollback failure from repo-level atomic rollback errors", () => { + const result = makeWaveResult({ + rollbackFailed: undefined, + failedLane: null, + repoResults: [ + { + repoId: "api", + status: "failed", + laneResults: [], + failedLane: null, + failureReason: "cross_repo_atomic_rollback_failed: unable to reset main", + }, + ] as any, + }); + + expect(mergeRequiresRollbackSafeStop(result)).toBe(true); + }); }); // ══════════════════════════════════════════════════════════════════════ @@ -890,7 +934,43 @@ describe("10.x — applyMergeRetryLoop shared loop semantics", () => { } }); - it("10.3: classification changes between retries are handled correctly", async () => { + it("10.3: safe-stop during retry also triggers from stale rollback metadata without rollbackFailed flag", async () => { + const failResult = makeWaveResult({ + laneResults: [ + makeLaneResult({ error: "verification_new_failure: 1 failure" }), + ], + }); + const rollbackFailResult = makeWaveResult({ + status: "failed", + rollbackFailed: undefined, + transactionRecords: [ + { + laneNumber: 1, + waveTransactionId: "wave-test", + opId: "op-test", + repoId: "api", + sourceBranch: "task/lane-1", + targetBranch: "main", + worktreePath: "/tmp/worktree", + preMergeHead: "abc123", + mergeCommit: null, + status: "rollback_failed", + rollbackAttempted: true, + rollbackResult: "reset failed: simulated", + recoveryCommands: ["git reset --hard abc123"], + }, + ] as any, + }); + + const counters: Record = {}; + const mock = makeMockCallbacks({ performMergeResults: [rollbackFailResult] }); + + const outcome = await applyMergeRetryLoop(failResult, 0, counters, mock.callbacks); + + expect(outcome.kind).toBe("safe_stop"); + }); + + it("10.4: classification changes between retries are handled correctly", async () => { // First failure: lock file. Retry returns: cleanup failure. const lockFailResult = makeWaveResult({ failureReason: "lock file error", @@ -913,7 +993,7 @@ describe("10.x — applyMergeRetryLoop shared loop semantics", () => { } }); - it("10.4: retry loop emits notifications for each attempt", async () => { + it("10.5: retry loop emits notifications for each attempt", async () => { const failResult = makeWaveResult({ failureReason: "lock file error", }); @@ -935,7 +1015,7 @@ describe("10.x — applyMergeRetryLoop shared loop semantics", () => { expect(successNotifs.length).toBe(1); }); - it("10.5: retry loop persists state at correct points (increment, start, complete)", async () => { + it("10.6: retry loop persists state at correct points (increment, start, complete)", async () => { const failResult = makeWaveResult({ laneResults: [ makeLaneResult({ error: "verification_new_failure: 1 failure" }), @@ -958,7 +1038,7 @@ describe("10.x — applyMergeRetryLoop shared loop semantics", () => { expect(idx_complete).toBeGreaterThan(idx_start); }); - it("10.6: all five merge failure classifications are covered in matrix", () => { + it("10.7: all five merge failure classifications are covered in matrix", () => { expect(MERGE_FAILURE_CLASSIFICATIONS).toEqual([ "verification_new_failure", "merge_conflict_unresolved", @@ -972,7 +1052,7 @@ describe("10.x — applyMergeRetryLoop shared loop semantics", () => { } }); - it("10.7: policy matrix values match roadmap specification", () => { + it("10.8: policy matrix values match roadmap specification", () => { const m = MERGE_RETRY_POLICY_MATRIX; // verification_new_failure: 1 retry, 0ms cooldown diff --git a/extensions/tests/runtime-v2-contracts.test.ts b/extensions/tests/runtime-v2-contracts.test.ts index 21e04831..0f9f0c74 100644 --- a/extensions/tests/runtime-v2-contracts.test.ts +++ b/extensions/tests/runtime-v2-contracts.test.ts @@ -300,6 +300,9 @@ describe("6.x: ExecutionUnit shape", () => { segmentId: null, executionRepoId: "default", packetHomeRepoId: "default", + repoPaths: { + default: "/dev/taskplane/.worktrees/lane-1", + }, worktreePath: "/dev/taskplane/.worktrees/lane-1", packet: resolvePacketPaths("/dev/taskplane/tasks/TP-102"), task: { @@ -329,6 +332,10 @@ describe("6.x: ExecutionUnit shape", () => { u.segmentId = "TP-102::api"; u.executionRepoId = "api"; u.packetHomeRepoId = "shared-libs"; + u.repoPaths = { + api: "/repos/api-service/.worktrees/lane-1", + "shared-libs": "/repos/shared-libs", + }; expect(u.id).toBe("TP-102::api"); expect(u.executionRepoId).not.toBe(u.packetHomeRepoId); }); @@ -342,7 +349,19 @@ describe("6.x: ExecutionUnit shape", () => { expect(u.worktreePath).not.toContain("shared-libs"); }); - it("6.4: packet paths contain all required fields", () => { + it("6.4: repoPaths carry execution and sibling repo paths", () => { + const u = validUnit(); + u.executionRepoId = "api"; + u.packetHomeRepoId = "shared-libs"; + u.repoPaths = { + api: "/repos/api-service/.worktrees/lane-1", + "shared-libs": "/repos/shared-libs", + }; + expect(u.repoPaths.api).toContain(".worktrees"); + expect(u.repoPaths["shared-libs"]).toBe("/repos/shared-libs"); + }); + + it("6.5: packet paths contain all required fields", () => { const u = validUnit(); expect(validatePacketPaths(u.packet)).toEqual([]); }); @@ -401,6 +420,7 @@ describe("7.x: buildExecutionUnit bridge", () => { expect(unit.segmentId).toBe(null); expect(unit.executionRepoId).toBe("default"); expect(unit.packetHomeRepoId).toBe("default"); + expect(unit.repoPaths.default).toBe(lane.worktreePath); expect(unit.worktreePath).toBe(lane.worktreePath); expect(unit.packet.statusPath).toContain("STATUS.md"); expect(unit.packet.donePath).toContain(".DONE"); @@ -417,7 +437,59 @@ describe("7.x: buildExecutionUnit bridge", () => { expect(unit.packetHomeRepoId).toBe("shared-libs"); }); - it("7.3: uses segment ID when activeSegmentId is set", () => { + it("7.3: maps participating repos to execution worktree and workspace roots", () => { + const lane = makeAllocatedLane({ + repoId: "api", + worktreePath: "/workspace/.worktrees/api-lane-1", + }); + const task = makeAllocatedTask({ + packetRepoId: "shared-libs", + participatingRepoIds: ["api", "shared-libs", "web-client"], + }); + const workspaceConfig = { + mode: "workspace", + repos: new Map([ + ["api", { path: "/workspace/repos/api" }], + ["shared-libs", { path: "/workspace/repos/shared-libs" }], + ["web-client", { path: "/workspace/repos/web-client" }], + ]), + routing: { tasksRoot: "/workspace/tasks", defaultRepo: "api" }, + configPath: "/workspace/.pi/taskplane-workspace.yaml", + } as any; + const unit = buildExecutionUnit(lane, task, "/workspace/repos/api", true, workspaceConfig); + + expect(unit.repoPaths.api).toBe(lane.worktreePath); + expect(unit.repoPaths["shared-libs"]).toBe("/workspace/repos/shared-libs"); + expect(unit.repoPaths["web-client"]).toBe("/workspace/repos/web-client"); + }); + + it("7.3b: maps resolvedRepoIds before segment participation is materialized", () => { + const lane = makeAllocatedLane({ + repoId: "api", + worktreePath: "/workspace/.worktrees/api-lane-1", + }); + const task = makeAllocatedTask({ + resolvedRepoId: "api", + resolvedRepoIds: ["api", "shared-libs", "web-client"], + }); + const workspaceConfig = { + mode: "workspace", + repos: new Map([ + ["api", { path: "/workspace/repos/api" }], + ["shared-libs", { path: "/workspace/repos/shared-libs" }], + ["web-client", { path: "/workspace/repos/web-client" }], + ]), + routing: { tasksRoot: "/workspace/tasks", defaultRepo: "api" }, + configPath: "/workspace/.pi/taskplane-workspace.yaml", + } as any; + const unit = buildExecutionUnit(lane, task, "/workspace/repos/api", true, workspaceConfig); + + expect(unit.repoPaths.api).toBe(lane.worktreePath); + expect(unit.repoPaths["shared-libs"]).toBe("/workspace/repos/shared-libs"); + expect(unit.repoPaths["web-client"]).toBe("/workspace/repos/web-client"); + }); + + it("7.4: uses segment ID when activeSegmentId is set", () => { const task = makeAllocatedTask({ activeSegmentId: "TP-102::api" }); const lane = makeAllocatedLane(); const unit = buildExecutionUnit(lane, task, "/project"); @@ -426,7 +498,41 @@ describe("7.x: buildExecutionUnit bridge", () => { expect(unit.id).toBe("TP-102::api"); }); - it("7.4: packet paths are valid PacketPaths", () => { + it("7.4b: uses repoWorktrees path for the execution repo when available", () => { + const lane = makeAllocatedLane({ + repoId: "shared-libs", + worktreePath: "/workspace/.worktrees/api-lane-1", + repoWorktrees: { + api: { path: "/workspace/.worktrees/api-lane-1", branch: "task/api", laneNumber: 1, repoId: "api" }, + "shared-libs": { path: "/workspace/.worktrees/shared-libs-lane-1", branch: "task/shared", laneNumber: 1, repoId: "shared-libs" }, + }, + }); + const task = makeAllocatedTask({ + activeSegmentId: "TP-102::shared-libs", + packetRepoId: "shared-libs", + participatingRepoIds: ["api", "shared-libs"], + taskFolder: "/workspace/tasks/TP-102", + }); + const workspaceConfig = { + mode: "workspace", + repos: new Map([ + ["api", { path: "/workspace/repos/api" }], + ["shared-libs", { path: "/workspace/repos/shared-libs" }], + ]), + routing: { tasksRoot: "/workspace/tasks", defaultRepo: "api" }, + configPath: "/workspace/.pi/taskplane-workspace.yaml", + } as any; + + const unit = buildExecutionUnit(lane, task, "/workspace/repos/api", true, workspaceConfig); + + expect(unit.executionRepoId).toBe("shared-libs"); + expect(unit.worktreePath).toBe("/workspace/.worktrees/shared-libs-lane-1"); + expect(unit.repoPaths["shared-libs"]).toBe("/workspace/.worktrees/shared-libs-lane-1"); + expect(unit.repoPaths.api).toBe("/workspace/.worktrees/api-lane-1"); + expect(unit.packet.taskFolder).toContain("/workspace/.worktrees/shared-libs-lane-1/"); + }); + + it("7.5: packet paths are valid PacketPaths", () => { const unit = buildExecutionUnit(makeAllocatedLane(), makeAllocatedTask(), "/project"); expect(validatePacketPaths(unit.packet)).toEqual([]); }); diff --git a/extensions/tests/segment-model.test.ts b/extensions/tests/segment-model.test.ts index fc5b34b5..345344c3 100644 --- a/extensions/tests/segment-model.test.ts +++ b/extensions/tests/segment-model.test.ts @@ -84,6 +84,57 @@ describe("task segment plan determinism", () => { expect(plan.segments.map((s) => s.segmentId)).toEqual(["TP-300::default"]); expect(plan.edges).toEqual([]); }); + + it("uses declared promptRepoIds order before fallback inference", () => { + const pending = new Map([ + [ + "TP-350", + makeTask("TP-350", { + promptRepoId: "dashboard", + promptRepoIds: ["dashboard", "administration"], + resolvedRepoId: "dashboard", + fileScope: ["administration/src/view.tsx", "dashboard/src/report.ts"], + }), + ], + ]); + + const plans = buildTaskSegmentPlans(pending, { + workspaceRepoIds: ["dashboard", "administration"], + }); + const plan = plans.get("TP-350")!; + expect(plan.mode).toBe("inferred-sequential"); + expect(plan.segments.map((s) => s.repoId)).toEqual(["dashboard", "administration"]); + expect(plan.edges.map((e) => `${e.fromSegmentId}->${e.toSegmentId}`)).toEqual([ + "TP-350::dashboard->TP-350::administration", + ]); + expect(plan.edges.every((e) => e.reason === "prompt:execution-target-repos")).toBe(true); + }); + + it("uses dependency resolvedRepoIds order when inferring cross-repo segments", () => { + const pending = new Map([ + [ + "TP-360", + makeTask("TP-360", { + resolvedRepoId: "dashboard", + resolvedRepoIds: ["dashboard", "administration"], + }), + ], + [ + "TP-361", + makeTask("TP-361", { + dependencies: ["TP-360"], + }), + ], + ]); + + const plans = buildTaskSegmentPlans(pending); + const plan = plans.get("TP-361")!; + expect(plan.mode).toBe("inferred-sequential"); + expect(plan.segments.map((s) => s.repoId)).toEqual(["dashboard", "administration"]); + expect(plan.edges.map((e) => `${e.fromSegmentId}->${e.toSegmentId}`)).toEqual([ + "TP-361::dashboard->TP-361::administration", + ]); + }); }); describe("computeWaveAssignments segment plan wiring", () => { diff --git a/extensions/tests/segment-scoped-lane-runner.test.ts b/extensions/tests/segment-scoped-lane-runner.test.ts index 94ea459c..370e7144 100644 --- a/extensions/tests/segment-scoped-lane-runner.test.ts +++ b/extensions/tests/segment-scoped-lane-runner.test.ts @@ -440,12 +440,12 @@ describe("8.x: Snapshot segment-scoped progress (emitSnapshot)", () => { }); it("8.3: all emitSnapshot calls pass snapshotSegmentCtx", () => { - const calls = laneRunnerSrc.match(/emitSnapshot\(config,.*snapshotSegmentCtx\)/g); + const calls = laneRunnerSrc.match(/emitSnapshot\(config,[\s\S]*?snapshotSegmentCtx[\s\S]*?\)/g); expect(calls).not.toBe(null); expect(calls!.length).toBeGreaterThanOrEqual(2); }); it("8.4: makeResult passes segmentCtx to emitSnapshot", () => { - expect(laneRunnerSrc).toContain("emitSnapshot(config, taskId, segmentId, terminalStatus, finalTelemetry ?? {}, statusPath, reviewerStatePath, segmentCtx)"); + expect(laneRunnerSrc).toContain("emitSnapshot(config, taskId, segmentId, terminalStatus, finalTelemetry ?? {}, statusPath, reviewerStatePath, segmentCtx, submoduleDiagnostics)"); }); }); diff --git a/extensions/tests/settings-tui.test.ts b/extensions/tests/settings-tui.test.ts index 94f55665..d16fabd1 100644 --- a/extensions/tests/settings-tui.test.ts +++ b/extensions/tests/settings-tui.test.ts @@ -365,6 +365,19 @@ describe("10. getFieldDisplayValue", () => { const field = makeL1Field(); // maxLanes defaults to 3 expect(getFieldDisplayValue(field, config, emptyPrefs)).toBe("3"); }); + + it("10.9 displays submodule drift defaults when no overrides exist", () => { + const config = cloneConfig(); + const field: FieldDef = { + configPath: "orchestrator.failure.onSubmoduleDrift", + label: "On Submodule Drift", + control: "picker", + layer: "L1", + fieldType: "enum", + values: ["manual", "init-only", "recursive-on-drift"], + }; + expect(getFieldDisplayValue(field, config, emptyPrefs)).toBe("manual"); + }); }); @@ -578,6 +591,19 @@ describe("12. SECTIONS schema coverage", () => { expect(mergeThinking!.prefsKey).toBe("mergeThinking"); expect(getDefaultWriteDestination(mergeThinking!)).toBe("prefs"); }); + + it("12.9 keeps submodule controls under Orchestrator and Failure Policy", () => { + expect(SECTIONS.find((section) => section.name === "Submodules")).toBeUndefined(); + + const orchestrator = SECTIONS.find((section) => section.name === "Orchestrator"); + expect(orchestrator).toBeDefined(); + expect(orchestrator!.fields.some((field) => field.configPath === "orchestrator.orchestrator.submoduleRepoIdStrategy")).toBe(true); + + const failure = SECTIONS.find((section) => section.name === "Failure Policy"); + expect(failure).toBeDefined(); + expect(failure!.fields.some((field) => field.configPath === "orchestrator.failure.submoduleFailureMode")).toBe(true); + expect(failure!.fields.some((field) => field.configPath === "orchestrator.failure.onSubmoduleDrift")).toBe(true); + }); }); @@ -735,6 +761,24 @@ describe("14. writeProjectConfigField", () => { expect(result.configVersion).toBe(CONFIG_VERSION); }); + it("14.1b ignores taskplane-settings.json and writes the canonical project config", () => { + const dir = makeWriteTestDir("ignore-taskplane-settings-json"); + writePiFile(dir, "taskplane-settings.json", JSON.stringify({ + configVersion: CONFIG_VERSION, + orchestrator: { orchestrator: { maxLanes: 99 } }, + }, null, 2)); + + writeProjectConfigField(dir, "orchestrator.orchestrator.maxLanes", 5); + + const canonicalPath = join(dir, ".pi", PROJECT_CONFIG_FILENAME); + expect(existsSync(canonicalPath)).toBe(true); + const canonical = readJsonFile(canonicalPath); + expect(canonical.orchestrator.orchestrator.maxLanes).toBe(5); + + const stray = readJsonFile(join(dir, ".pi", "taskplane-settings.json")); + expect(stray.orchestrator.orchestrator.maxLanes).toBe(99); + }); + it("14.2 creates nested path that doesn't exist yet", () => { const dir = makeWriteTestDir("nested-create"); const config = { @@ -928,6 +972,20 @@ routing: expect(result.orchestrator.dependencies.cache).toBe(false); }); + it("14.10b writes submodule policy settings to taskplane-config.json", () => { + const dir = makeWriteTestDir("submodule-policy"); + writeJsonConfig(dir, { configVersion: CONFIG_VERSION }); + + writeProjectConfigField(dir, "orchestrator.orchestrator.submoduleRepoIdStrategy", "path-basename"); + writeProjectConfigField(dir, "orchestrator.failure.submoduleFailureMode", "strict"); + writeProjectConfigField(dir, "orchestrator.failure.onSubmoduleDrift", "recursive-on-drift"); + + const result = readJsonFile(join(dir, ".pi", PROJECT_CONFIG_FILENAME)); + expect(result.orchestrator.orchestrator.submoduleRepoIdStrategy).toBe("path-basename"); + expect(result.orchestrator.failure.submoduleFailureMode).toBe("strict"); + expect(result.orchestrator.failure.onSubmoduleDrift).toBe("recursive-on-drift"); + }); + it("14.11 writes to pointer-resolved flat layout (no .pi subdir)", () => { const workspaceRoot = makeWriteTestDir("pointer-flat-workspace"); const pointerRoot = join(workspaceRoot, "config-repo", ".taskplane"); diff --git a/extensions/tests/submodule-dirty-state-filtering.test.ts b/extensions/tests/submodule-dirty-state-filtering.test.ts new file mode 100644 index 00000000..3f400846 --- /dev/null +++ b/extensions/tests/submodule-dirty-state-filtering.test.ts @@ -0,0 +1,249 @@ +const { detectUnsafeSubmoduleStates } = await import("../taskplane/git.ts"); + +/** + * Unit tests for submodule dirty-state filtering fixes. + * + * Validates that `filterArtifactStatusLines` uses segment matching to catch + * nested artifacts like scripts/__pycache__/ and that `filterGitIgnoredStatusLines` + * respects .gitignore rules at every level in the tree (recursive). + * + * These tests cover both scenarios: + * - Root-level ignores (.gitignore at repo root) + * - Recursive/nested ignores (.gitignore inside subdirectories of submodules) + */ + +import { afterEach, beforeEach, describe, it } from "node:test"; +import { expect } from "./expect.ts"; +import { execFileSync } from "child_process"; +import { mkdirSync, mkdtempSync, writeFileSync, rmSync, existsSync, readFileSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +function git(cwd: string, args: string[]): string { + return execFileSync("git", args, { + cwd, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); +} + +function initRepo(repoDir: string): void { + mkdirSync(repoDir, { recursive: true }); + git(repoDir, ["init", "--initial-branch=main"]); + git(repoDir, ["config", "user.email", "test@example.com"]); + git(repoDir, ["config", "user.name", "Taskplane Test"]); +} + +function commitAll(repoDir: string, message: string): void { + git(repoDir, ["add", "."]); + git(repoDir, ["commit", "-m", message]); +} + +describe("filterArtifactStatusLines — segment matching for nested artifacts", () => { + // We test through detectUnsafeSubmoduleStates since filterArtifactStatusLines is private. + // The key assertion: submodules with __pycache__/ in NESTED paths should NOT be flagged dirty. + + it("filters root-level __pycache__ artifact", () => { + const superDir = mkdtempSync(join(tmpdir(), "tp-super-")); + const subDir = join(superDir, "my_sub"); + + // Create submodule with __pycache__ at root + initRepo(subDir); + mkdirSync(join(subDir, "__pycache__"), { recursive: true }); + writeFileSync(join(subDir, "__pycache__/test.pyc"), "# cache", "utf-8"); + git(subDir, ["add", "."]); + git(subDir, ["commit", "-m", "initial"]); + + // Create superproject with gitlink to submodule (no .gitignore for __pycache__) + initRepo(superDir); + execFileSync("git", ["-c", "protocol.file.allow=always", "submodule", "add", subDir, "my_sub"], { cwd: superDir }); + commitAll(superDir, "add my_sub"); + + // Now dirty the submodule's __pycache__ (simulating worker creating it) + writeFileSync(join(subDir, "__pycache__/new.pyc"), "# new cache", "utf-8"); + + const findings = detectUnsafeSubmoduleStates(superDir); + + // __pycache__ should be filtered → no dirty submodules found + expect(findings).toHaveLength(0); + + rmSync(superDir, { recursive: true, force: true }); + }); + + it("filters nested scripts/__pycache__/ artifact (the bof3-disk pattern)", () => { + const superDir = mkdtempSync(join(tmpdir(), "tp-super-nested-")); + const subDir = join(superDir, "tools_repo"); + + // Create submodule with __pycache__ in NESTED directory + initRepo(subDir); + mkdirSync(join(subDir, "scripts/__pycache__"), { recursive: true }); + writeFileSync(join(subDir, "scripts/__pycache__/helper.pyc"), "# cache", { recursive: true }); + git(subDir, ["add", "."]); + git(subDir, ["commit", "-m", "initial"]); + + // Add to superproject + initRepo(superDir); + execFileSync("git", ["-c", "protocol.file.allow=always", "submodule", "add", subDir, "tools_repo"], { cwd: superDir }); + commitAll(superDir, "add tools_repo"); + + // Dirty the nested __pycache__ (this is exactly what bof3-disk does) + writeFileSync(join(subDir, "scripts/__pycache__/new.pyc"), "# new", { recursive: true }); + + const findings = detectUnsafeSubmoduleStates(superDir); + + // nested __pycache__ should be filtered via segment matching → no dirty submodules + expect(findings).toHaveLength(0); + + rmSync(superDir, { recursive: true, force: true }); + }); + + it("filters tests/__pycache__/ alongside scripts/__pycache__", () => { + const superDir = mkdtempSync(join(tmpdir(), "tp-super-test-")); + const subDir = join(superDir, "multi_sub"); + + initRepo(subDir); + mkdirSync(join(subDir, "tests/__pycache__/"), { recursive: true }); + writeFileSync(join(subDir, "tests/__pycache__/test_helper.pyc"), "# cache", { recursive: true }); + git(subDir, ["add", "."]); + git(subDir, ["commit", "-m", "initial"]); + + initRepo(superDir); + execFileSync("git", ["-c", "protocol.file.allow=always", "submodule", "add", subDir, "multi_sub"], { cwd: superDir }); + commitAll(superDir, "add multi_sub"); + + // Dirty both nested locations (mimics bof3-disk's scripts/__pycache__ and tests/__pycache__) + writeFileSync(join(subDir, "tests/__pycache__/new.pyc"), "# new", { recursive: true }); + + const findings = detectUnsafeSubmoduleStates(superDir); + + expect(findings).toHaveLength(0); // Both nested paths filtered + + rmSync(superDir, { recursive: true, force: true }); + }); + + it("still detects non-filtered real changes", () => { + const superDir = mkdtempSync(join(tmpdir(), "tp-super-real-")); + const subDir = join(superDir, "real_sub"); + + initRepo(subDir); + mkdirSync(join(subDir, "src"), { recursive: true }); + writeFileSync(join(subDir, "src/main.c"), "#include ", "utf-8"); + git(subDir, ["add", "."]); + git(subDir, ["commit", "-m", "initial"]); + + initRepo(superDir); + execFileSync("git", ["-c", "protocol.file.allow=always", "submodule", "add", subDir, "real_sub"], { cwd: superDir }); + commitAll(superDir, "add real_sub"); + + // Actually modify a source file (should be detected as dirty) + writeFileSync(join(subDir, "src/main.c"), "#include \nint main() {}", { recursive: true }); + + const findings = detectUnsafeSubmoduleStates(superDir); + + expect(findings).toHaveLength(1); // Real change should be detected + expect(findings[0].kind).toBe("dirty-worktree"); + + rmSync(superDir, { recursive: true, force: true }); + }); +}); + +describe("filterGitIgnoredStatusLines — respects recursive .gitignore rules", () => { + it("respects root-level .gitignore in submodule", () => { + const superDir = mkdtempSync(join(tmpdir(), "tp-gitroot-")); + const subDir = join(superDir, "sub_with_root_gitignore"); + + initRepo(subDir); + writeFileSync(join(subDir, ".gitignore"), "__pycache__/\n*.pyc\n", { recursive: true }); + mkdirSync(join(subDir, "__pycache__/"), { recursive: true }); + writeFileSync(join(subDir, "__pycache__/cached.pyc"), "# cache", { recursive: true }); + git(subDir, ["add", "."]); + git(subDir, ["commit", "-m", "initial with gitignore"]); + + initRepo(superDir); + execFileSync("git", ["-c", "protocol.file.allow=always", "submodule", "add", subDir, "sub_with_root_gitignore"], { cwd: superDir }); + commitAll(superDir, "add sub"); + + const findings = detectUnsafeSubmoduleStates(superDir); + + expect(findings).toHaveLength(0); // __pycache__ is gitignored → not dirty + + rmSync(superDir, { recursive: true, force: true }); + }); + + it("respects NESTED .gitignore inside submodule subdirectory", () => { + const superDir = mkdtempSync(join(tmpdir(), "tp-gitnested-")); + const subDir = join(superDir, "sub_with_nested_gitignore"); + + initRepo(subDir); + writeFileSync(join(subDir, ".gitignore"), "# root gitignore\nbuild/", { recursive: true }); + mkdirSync(join(subDir, "scripts/__pycache__/"), { recursive: true }); + writeFileSync(join(subDir, "scripts/.gitignore"), "__pycache__/\n", { recursive: true }); // Nested .gitignore! + writeFileSync(join(subDir, "scripts/__pycache__/helper.pyc"), "# cache", { recursive: true }); + git(subDir, ["add", "."]); + git(subDir, ["commit", "-m", "initial with nested gitignore"]); + + initRepo(superDir); + execFileSync("git", ["-c", "protocol.file.allow=always", "submodule", "add", subDir, "sub_with_nested_gitignore"], { cwd: superDir }); + commitAll(superDir, "add nested_sub"); + + const findings = detectUnsafeSubmoduleStates(superDir); + + expect(findings).toHaveLength(0); // scripts/__pycache__ is gitignored via nested .gitignore → not dirty + + rmSync(superDir, { recursive: true, force: true }); + }); + + it("detects changes NOT covered by any .gitignore", () => { + const superDir = mkdtempSync(join(tmpdir(), "tp-gitnot-")); + const subDir = join(superDir, "sub_partial_ignore"); + + initRepo(subDir); + writeFileSync(join(subDir, ".gitignore"), "__pycache__/\n", { recursive: true }); // Only ignores __pycache__, not src/ + mkdirSync(join(subDir, "__pycache__/"), { recursive: true }); + writeFileSync(join(subDir, "__pycache__/cached.pyc"), "# cache", { recursive: true }); + mkdirSync(join(subDir, "src"), { recursive: true }); + writeFileSync(join(subDir, "src/main.c"), "// source\n", "utf-8"); // Not gitignored! + git(subDir, ["add", "."]); + git(subDir, ["commit", "-m", "initial partial ignore"]); + + initRepo(superDir); + execFileSync("git", ["-c", "protocol.file.allow=always", "submodule", "add", subDir, "sub_partial_ignore"], { cwd: superDir }); + commitAll(superDir, "add partial"); + + // Modify a tracked source file after the submodule is added so the worktree is actually dirty. + writeFileSync(join(subDir, "src/main.c"), "// source\nint main(void) { return 0; }\n", "utf-8"); + + const findings = detectUnsafeSubmoduleStates(superDir); + + expect(findings).toHaveLength(1); // src/main.c is NOT gitignored → dirty detected + expect(findings[0].kind).toBe("dirty-worktree"); + + rmSync(superDir, { recursive: true, force: true }); + }); + + it("respects deeply-nested .gitignore (3 levels deep)", () => { + const superDir = mkdtempSync(join(tmpdir(), "tp-gitdeep-")); + const subDir = join(superDir, "sub_deep"); + + initRepo(subDir); + writeFileSync(join(subDir, ".gitignore"), "# Root\n", { recursive: true }); + mkdirSync(join(subDir, "a/b/c/"), { recursive: true }); + writeFileSync(join(subDir, "a/.gitignore"), "*~\n", { recursive: true }); // Level 1 + writeFileSync(join(subDir, "a/b/.gitignore"), "*.swp\n", { recursive: true }); // Level 2 + writeFileSync(join(subDir, "a/b/c/.gitignore"), "__pycache__/\n", { recursive: true }); // Level 3! + mkdirSync(join(subDir, "a/b/c/__pycache__/"), { recursive: true }); + writeFileSync(join(subDir, "a/b/c/__pycache__/deep.pyc"), "# deep cache", { recursive: true }); + git(subDir, ["add", "."]); + git(subDir, ["commit", "-m", "initial with 3-level gitignore"]); + + initRepo(superDir); + execFileSync("git", ["-c", "protocol.file.allow=always", "submodule", "add", subDir, "sub_deep"], { cwd: superDir }); + commitAll(superDir, "add deep"); + + const findings = detectUnsafeSubmoduleStates(superDir); + + expect(findings).toHaveLength(0); // a/b/c/__pycache__ is gitignored at level 3 → not dirty + + rmSync(superDir, { recursive: true, force: true }); + }); +}); diff --git a/extensions/tests/submodule-post-execution.integration.test.ts b/extensions/tests/submodule-post-execution.integration.test.ts new file mode 100644 index 00000000..0f17bcc8 --- /dev/null +++ b/extensions/tests/submodule-post-execution.integration.test.ts @@ -0,0 +1,286 @@ +/** + * Post-execution submodule safety tests + * + * Validates that Runtime V2 refuses to checkpoint task artifacts when a task + * leaves a submodule pointing at a local-only commit. + */ + +import { afterEach, beforeEach, describe, it, mock } from "node:test"; +import { expect } from "./expect.ts"; +import { execFileSync } from "child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +const mockExecuteTaskV2 = mock.fn(); +const laneRunnerModuleUrl = new URL("../taskplane/lane-runner.ts", import.meta.url).href; + +mock.module(laneRunnerModuleUrl, { + namedExports: { + executeTaskV2: mockExecuteTaskV2, + }, +}); + +const { detectUnsafeSubmoduleStates, detectUnreachableGitlinks } = await import("../taskplane/git.ts"); +const { executeLaneV2 } = await import("../taskplane/execution.ts"); +const { DEFAULT_ORCHESTRATOR_CONFIG, runtimeLaneSnapshotPath } = await import("../taskplane/types.ts"); + +type AllocatedLane = import("../taskplane/types.ts").AllocatedLane; +type AllocatedTask = import("../taskplane/types.ts").AllocatedTask; +type ParsedTask = import("../taskplane/types.ts").ParsedTask; + +function git(cwd: string, args: string[]): string { + return execFileSync("git", args, { + cwd, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, + }).trim(); +} + +function initRepo(repoDir: string): void { + mkdirSync(repoDir, { recursive: true }); + git(repoDir, ["init", "--initial-branch=main"]); + git(repoDir, ["config", "user.email", "test@example.com"]); + git(repoDir, ["config", "user.name", "Taskplane Test"]); +} + +function commitAll(repoDir: string, message: string): void { + git(repoDir, ["add", "."]); + git(repoDir, ["commit", "-m", message]); +} + +function addSubmodule(superRepo: string, subRepo: string, submodulePath: string): void { + git(superRepo, ["-c", "protocol.file.allow=always", "submodule", "add", subRepo, submodulePath]); + commitAll(superRepo, `add ${submodulePath}`); +} + +function cloneRepo(sourceRepo: string, targetRepo: string): void { + git(process.cwd(), ["clone", sourceRepo, targetRepo]); + git(targetRepo, ["config", "user.email", "test@example.com"]); + git(targetRepo, ["config", "user.name", "Taskplane Test"]); + git(targetRepo, ["-c", "protocol.file.allow=always", "submodule", "update", "--init", "--recursive"]); +} + +function publishLaneSubmoduleCommit(): string { + const submoduleDir = join(laneRepo, "libs", "my_lib"); + const publishedCommit = git(submoduleDir, ["rev-parse", "HEAD"]); + git(submoduleDir, ["push", "origin", "HEAD:main"]); + return publishedCommit; +} + +function makeParsedTask(taskFolder: string): ParsedTask { + return { + taskId: "TP-001", + taskName: "Task TP-001", + reviewLevel: 1, + size: "M", + dependencies: [], + fileScope: [], + taskFolder, + promptPath: join(taskFolder, "PROMPT.md"), + areaName: "default", + status: "pending", + resolvedRepoId: "default", + }; +} + +function makeAllocatedTask(taskFolder: string): AllocatedTask { + return { + taskId: "TP-001", + order: 0, + task: makeParsedTask(taskFolder), + estimatedMinutes: 10, + }; +} + +function makeAllocatedLane(worktreePath: string, taskFolder: string): AllocatedLane { + return { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-op-lane-1", + worktreePath, + branch: "task/op-lane-1-20260422T000000", + tasks: [makeAllocatedTask(taskFolder)], + strategy: "affinity-first", + estimatedLoad: 1, + estimatedMinutes: 10, + }; +} + +let testRoot = ""; +let superRepo = ""; +let subRepo = ""; +let laneRepo = ""; + +beforeEach(() => { + testRoot = mkdtempSync(join(tmpdir(), "tp-submodule-post-exec-")); + superRepo = join(testRoot, "super"); + subRepo = join(testRoot, "submodule-origin"); + laneRepo = join(testRoot, "lane-clone"); + mockExecuteTaskV2.mock.resetCalls(); + + initRepo(subRepo); + git(subRepo, ["config", "receive.denyCurrentBranch", "updateInstead"]); + writeFileSync(join(subRepo, "lib.txt"), "base\n", "utf-8"); + commitAll(subRepo, "initial submodule commit"); + + initRepo(superRepo); + mkdirSync(join(superRepo, "tasks", "TP-001"), { recursive: true }); + writeFileSync(join(superRepo, "tasks", "TP-001", "PROMPT.md"), "# TP-001\n", "utf-8"); + writeFileSync(join(superRepo, "tasks", "TP-001", "STATUS.md"), "status\n", "utf-8"); + commitAll(superRepo, "initial super commit"); + addSubmodule(superRepo, subRepo, "libs/my_lib"); + + cloneRepo(superRepo, laneRepo); + git(join(laneRepo, "libs", "my_lib"), ["config", "user.email", "test@example.com"]); + git(join(laneRepo, "libs", "my_lib"), ["config", "user.name", "Taskplane Test"]); + writeFileSync(join(laneRepo, "libs", "my_lib", "lib.txt"), "base\nlocal change\n", "utf-8"); + git(join(laneRepo, "libs", "my_lib"), ["add", "lib.txt"]); + git(join(laneRepo, "libs", "my_lib"), ["commit", "-m", "local only submodule commit"]); + + mockExecuteTaskV2.mock.mockImplementation(async () => ({ + outcome: { + taskId: "TP-001", + status: "succeeded", + segmentId: null, + startTime: 100, + endTime: 200, + exitReason: "done", + sessionName: "orch-op-lane-1-worker", + doneFileFound: true, + laneNumber: 1, + }, + iterations: 1, + costUsd: 0, + totalTokens: 0, + })); +}); + +afterEach(() => { + rmSync(testRoot, { recursive: true, force: true }); +}); + +describe("post-execution submodule safety", () => { + it("detects unpublished submodule commits in a worktree", () => { + const findings = detectUnsafeSubmoduleStates(laneRepo); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe("libs/my_lib"); + expect(findings[0].kind).toBe("unpublished-commit"); + }); + + it("marks an otherwise successful task failed before checkpointing an unsafe submodule gitlink", async () => { + const batchId = "20260422T043635-test"; + const lane = makeAllocatedLane(laneRepo, join(laneRepo, "tasks", "TP-001")); + const result = await executeLaneV2( + lane, + DEFAULT_ORCHESTRATOR_CONFIG, + laneRepo, + { paused: false }, + undefined, + undefined, + { ORCH_BATCH_ID: batchId }, + ); + + expect(mockExecuteTaskV2.mock.calls.length).toBe(1); + expect(result.overallStatus).toBe("failed"); + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0].status).toBe("failed"); + expect(result.tasks[0].exitReason).toContain("Unsafe submodule state after task success"); + expect(result.tasks[0].exitReason).toContain("libs/my_lib"); + + const checkpointLog = git(laneRepo, ["log", "--oneline", "--grep", "checkpoint: TP-001 task artifacts"]); + expect(checkpointLog).toBe(""); + + const laneSnapshot = JSON.parse( + readFileSync(runtimeLaneSnapshotPath(laneRepo, batchId, 1), "utf-8"), + ) as { + submoduleDiagnostics?: { + preTask?: { taskId: string; phase: string; entries: Array<{ path: string }> }; + postTask?: { taskId: string; phase: string; entries: Array<{ path: string }> }; + unsafeCheckpoint?: { + taskId: string; + findings: Array<{ path: string; kind: string; summary: string }>; + }; + }; + }; + expect(laneSnapshot.submoduleDiagnostics?.postTask?.taskId).toBe("TP-001"); + expect(laneSnapshot.submoduleDiagnostics?.postTask?.phase).toBe("post-task"); + expect(laneSnapshot.submoduleDiagnostics?.unsafeCheckpoint?.taskId).toBe("TP-001"); + expect(laneSnapshot.submoduleDiagnostics?.unsafeCheckpoint?.findings[0]?.path).toBe("libs/my_lib"); + expect(laneSnapshot.submoduleDiagnostics?.unsafeCheckpoint?.findings[0]?.kind).toBe("unpublished-commit"); + expect(laneSnapshot.submoduleDiagnostics?.postTask?.entries.some((entry) => entry.path === "libs/my_lib")).toBe(true); + }); + + it("records modified file previews for dirty submodule worktrees before checkpoint failure", async () => { + const batchId = "20260422T043635-dirty"; + writeFileSync(join(laneRepo, "libs", "my_lib", "lib.txt"), "base\nlocal change\ndirty change\n", "utf-8"); + + const lane = makeAllocatedLane(laneRepo, join(laneRepo, "tasks", "TP-001")); + const result = await executeLaneV2( + lane, + DEFAULT_ORCHESTRATOR_CONFIG, + laneRepo, + { paused: false }, + undefined, + undefined, + { ORCH_BATCH_ID: batchId }, + ); + + expect(result.overallStatus).toBe("failed"); + expect(result.tasks[0].exitReason).toContain("Unsafe submodule state after task success"); + + const laneSnapshot = JSON.parse( + readFileSync(runtimeLaneSnapshotPath(laneRepo, batchId, 1), "utf-8"), + ) as { + submoduleDiagnostics?: { + unsafeCheckpoint?: { + findings: Array<{ + path: string; + kind: string; + statusLines: string[]; + }>; + }; + }; + }; + const dirtyFinding = laneSnapshot.submoduleDiagnostics?.unsafeCheckpoint?.findings.find( + (finding) => finding.path === "libs/my_lib", + ); + expect(dirtyFinding?.kind).toBe("dirty-worktree"); + expect(dirtyFinding?.statusLines.some((line) => line.includes("lib.txt"))).toBe(true); + }); + + it("allows a published submodule commit to checkpoint cleanly", async () => { + const publishedCommit = publishLaneSubmoduleCommit(); + git(laneRepo, ["add", "libs/my_lib"]); + + expect(detectUnsafeSubmoduleStates(laneRepo)).toEqual([]); + expect(detectUnreachableGitlinks(laneRepo)).toEqual([]); + + const lane = makeAllocatedLane(laneRepo, join(laneRepo, "tasks", "TP-001")); + const result = await executeLaneV2( + lane, + DEFAULT_ORCHESTRATOR_CONFIG, + laneRepo, + { paused: false }, + ); + + expect(result.overallStatus).toBe("succeeded"); + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0].status).toBe("succeeded"); + + const checkpointLog = git(laneRepo, ["log", "--oneline", "--grep", "checkpoint: TP-001 task artifacts"]); + expect(checkpointLog).not.toBe(""); + + const stagedGitlink = git(laneRepo, ["rev-parse", "HEAD:libs/my_lib"]); + expect(stagedGitlink).toBe(publishedCommit); + }); + + it("detects unreachable staged gitlinks for merge-time validation", () => { + git(laneRepo, ["add", "libs/my_lib"]); + const findings = detectUnreachableGitlinks(laneRepo); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe("libs/my_lib"); + expect(findings[0].gitlinkCommit.length).toBeGreaterThanOrEqual(8); + }); +}); \ No newline at end of file diff --git a/extensions/tests/submodule-preflight.test.ts b/extensions/tests/submodule-preflight.test.ts new file mode 100644 index 00000000..986a053f --- /dev/null +++ b/extensions/tests/submodule-preflight.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, it } from "node:test"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { expect } from "./expect.ts"; +import { DEFAULT_ORCHESTRATOR_CONFIG } from "../taskplane/types.ts"; +import { runPreflight } from "../taskplane/worktree.ts"; + +let testRoot: string; + +beforeEach(() => { + testRoot = mkdtempSync(join(tmpdir(), "tp-submodule-preflight-")); +}); + +afterEach(() => { + rmSync(testRoot, { recursive: true, force: true }); +}); + +function runGit(cwd: string, args: string[]): string { + return execFileSync("git", args, { + cwd, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); +} + +function initRepo(dir: string): void { + mkdirSync(dir, { recursive: true }); + runGit(dir, ["init", "--initial-branch=main"]); + runGit(dir, ["config", "user.name", "test"]); + runGit(dir, ["config", "user.email", "test@test.local"]); + writeFileSync(join(dir, "README.md"), "# test\n", "utf-8"); + runGit(dir, ["add", "README.md"]); + runGit(dir, ["commit", "-m", "init"]); +} + +function addSubmodule(superRepo: string, subRepo: string, submodulePath: string): void { + runGit(superRepo, ["-c", "protocol.file.allow=always", "submodule", "add", subRepo, submodulePath]); + runGit(superRepo, ["add", "."]); + runGit(superRepo, ["commit", "-m", `add ${submodulePath}`]); +} + +function findCheck(result: ReturnType, predicate: (check: (typeof result.checks)[number]) => boolean) { + return result.checks.find(predicate); +} + +describe("runPreflight submodule diagnostics", () => { + it("warns for undeclared submodules whose basename import would derive an invalid repo ID", () => { + const superRepo = join(testRoot, "workspace-main"); + const subRepo = join(testRoot, "docs-site-src"); + initRepo(superRepo); + initRepo(subRepo); + addSubmodule(superRepo, subRepo, "third_party/docs.site"); + + const workspaceConfig = { + mode: "workspace", + repos: new Map([["main", { path: superRepo }]]), + routing: { + tasksRoot: join(superRepo, "taskplane-tasks"), + defaultRepo: "main", + taskPacketRepo: "main", + }, + configPath: join(superRepo, ".pi", "taskplane-workspace.yaml"), + } as any; + + const result = runPreflight(DEFAULT_ORCHESTRATOR_CONFIG, superRepo, { + workspaceRoot: superRepo, + workspaceConfig, + }); + + const invalidRepoIdCheck = findCheck( + result, + (check) => check.name === "submodule-import:main:third_party/docs.site", + ); + expect(invalidRepoIdCheck).toBeDefined(); + expect(invalidRepoIdCheck?.status).toBe("warn"); + expect(invalidRepoIdCheck?.message).toContain("invalid repo ID 'docs.site'"); + }); + + it("warns when a cloned repository has uninitialized submodules", () => { + const superRepo = join(testRoot, "repo-with-submodule"); + const subRepo = join(testRoot, "docs-src"); + const cloneRepo = join(testRoot, "repo-clone"); + initRepo(superRepo); + initRepo(subRepo); + addSubmodule(superRepo, subRepo, "vendor/docs"); + runGit(testRoot, ["clone", superRepo, cloneRepo]); + + const result = runPreflight(DEFAULT_ORCHESTRATOR_CONFIG, cloneRepo, { + workspaceRoot: cloneRepo, + }); + + const initCheck = findCheck( + result, + (check) => check.message.includes("vendor/docs") && check.message.includes("not initialized"), + ); + expect(initCheck).toBeDefined(); + expect(initCheck?.status).toBe("warn"); + }); + + it("fails drifted submodules when orchestrator.failure.submoduleFailureMode is strict", () => { + const superRepo = join(testRoot, "strict-repo"); + const subRepo = join(testRoot, "strict-submodule"); + initRepo(superRepo); + initRepo(subRepo); + addSubmodule(superRepo, subRepo, "vendor/docs"); + + mkdirSync(join(superRepo, ".pi"), { recursive: true }); + writeFileSync( + join(superRepo, ".pi", "taskplane-config.json"), + JSON.stringify({ + configVersion: 1, + orchestrator: { + failure: { + submoduleFailureMode: "strict", + onSubmoduleDrift: "recursive-on-drift", + }, + }, + }, null, 2), + "utf-8", + ); + + writeFileSync(join(subRepo, "CHANGELOG.md"), "drift\n", "utf-8"); + runGit(subRepo, ["add", "CHANGELOG.md"]); + runGit(subRepo, ["commit", "-m", "drift"]); + const driftSha = runGit(subRepo, ["rev-parse", "HEAD"]); + + const checkedOutSubmodule = join(superRepo, "vendor", "docs"); + runGit(checkedOutSubmodule, ["fetch", "origin"]); + runGit(checkedOutSubmodule, ["checkout", driftSha]); + + const result = runPreflight(DEFAULT_ORCHESTRATOR_CONFIG, superRepo, { + workspaceRoot: superRepo, + }); + + const driftCheck = findCheck( + result, + (check) => check.message.includes("vendor/docs") && check.message.includes("drifted"), + ); + expect(driftCheck).toBeDefined(); + expect(driftCheck?.status).toBe("fail"); + }); +}); \ No newline at end of file diff --git a/extensions/tests/supervisor-alerts.test.ts b/extensions/tests/supervisor-alerts.test.ts index e687782f..c63fd622 100644 --- a/extensions/tests/supervisor-alerts.test.ts +++ b/extensions/tests/supervisor-alerts.test.ts @@ -17,7 +17,7 @@ import { expect } from "./expect.ts"; import { readFileSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; -import { buildBatchProgressSnapshot, buildSupervisorSegmentFrontierSnapshot, freshOrchBatchState } from "../taskplane/types.ts"; +import { buildBatchProgressSnapshot, buildSupervisorSegmentFrontierSnapshot, buildSupervisorTaskFailureAlert, freshOrchBatchState } from "../taskplane/types.ts"; import type { SupervisorAlert, SupervisorAlertCategory, @@ -44,6 +44,7 @@ describe("1.x — SupervisorAlert type structure", () => { summary: "⚠️ Task failure: TP-001", context: { taskId: "TP-001", + failurePolicy: "skip-dependents", segmentId: "TP-001::api", repoId: "api", laneId: "lane-1", @@ -62,11 +63,13 @@ describe("1.x — SupervisorAlert type structure", () => { ], }, partialProgress: false, + blockedTaskIds: ["TP-002", "TP-003"], + continueUnaffected: true, batchProgress: { succeededTasks: 2, failedTasks: 1, skippedTasks: 0, - blockedTasks: 0, + blockedTasks: 2, totalTasks: 3, currentWave: 1, totalWaves: 2, @@ -76,6 +79,7 @@ describe("1.x — SupervisorAlert type structure", () => { expect(alert.category).toBe("task-failure"); expect(alert.summary).toContain("TP-001"); expect(alert.context.taskId).toBe("TP-001"); + expect(alert.context.failurePolicy).toBe("skip-dependents"); expect(alert.context.segmentId).toBe("TP-001::api"); expect(alert.context.repoId).toBe("api"); expect(alert.context.laneId).toBe("lane-1"); @@ -84,6 +88,8 @@ describe("1.x — SupervisorAlert type structure", () => { expect(alert.context.segmentFrontier).toBeDefined(); expect(alert.context.segmentFrontier!.totalSegments).toBe(3); expect(alert.context.partialProgress).toBe(false); + expect(alert.context.blockedTaskIds).toEqual(["TP-002", "TP-003"]); + expect(alert.context.continueUnaffected).toBe(true); expect(alert.context.batchProgress).toBeDefined(); expect(alert.context.batchProgress!.totalTasks).toBe(3); }); @@ -284,6 +290,75 @@ describe("2.x — buildBatchProgressSnapshot", () => { expect(snapshot!.terminalSegments).toBe(2); expect(snapshot!.segments[2].status).toBe("pending"); }); + + it("2.6 — buildSupervisorTaskFailureAlert builds structured skip-dependents alert data", () => { + const state = freshOrchBatchState(); + state.succeededTasks = 2; + state.failedTasks = 1; + state.blockedTasks = 2; + state.totalTasks = 5; + state.currentWaveIndex = 0; + state.totalWaves = 3; + + const alert = buildSupervisorTaskFailureAlert({ + taskId: "TP-005", + failurePolicy: "skip-dependents", + exitReason: "TMUX session exited without .DONE", + partialProgress: true, + laneId: "lane-2", + laneNumber: 2, + laneRepoId: "api", + taskSegmentIds: ["TP-005::api", "TP-005::web", "TP-005::docs"], + taskActiveSegmentId: "TP-005::api", + persistedSegments: [ + { + segmentId: "TP-005::api", + taskId: "TP-005", + repoId: "api", + status: "failed", + laneId: "lane-2", + sessionName: "orch-op-api-lane-2", + worktreePath: "/tmp/lane-2-api", + branch: "task/op-api-lane-2", + startedAt: 1, + endedAt: 2, + retries: 0, + dependsOnSegmentIds: [], + exitReason: "TMUX session exited without .DONE", + }, + { + segmentId: "TP-005::web", + taskId: "TP-005", + repoId: "web", + status: "pending", + laneId: "", + sessionName: "", + worktreePath: "", + branch: "", + startedAt: null, + endedAt: null, + retries: 0, + dependsOnSegmentIds: ["TP-005::api"], + exitReason: "", + }, + ], + outcomeSegmentId: "TP-005::api", + blockedTaskIds: ["TP-006", "TP-007"], + batchProgress: buildBatchProgressSnapshot(state), + displayWave: 1, + totalDisplayWaves: 3, + }); + + expect(alert.category).toBe("task-failure"); + expect(alert.context.failurePolicy).toBe("skip-dependents"); + expect(alert.context.segmentId).toBe("TP-005::api"); + expect(alert.context.repoId).toBe("api"); + expect(alert.context.blockedTaskIds).toEqual(["TP-006", "TP-007"]); + expect(alert.context.continueUnaffected).toBe(true); + expect(alert.summary).toContain("Failure policy: skip-dependents"); + expect(alert.summary).toContain("Newly blocked dependents: TP-006, TP-007"); + expect(alert.summary).toContain("Unrelated ready tasks continue under skip-dependents"); + }); }); // ══════════════════════════════════════════════════════════════════════ @@ -299,6 +374,9 @@ describe("3.x — Alert message formatting", () => { ` Segment frontier: 1/3 terminal\n` + ` Lane: lane-2 (lane 2)\n` + ` Partial progress preserved: yes\n` + + ` Failure policy: skip-dependents\n` + + ` Newly blocked dependents: TP-006, TP-007\n` + + ` Unrelated ready tasks continue under skip-dependents.\n` + ` Batch: wave 1/3, 2 succeeded, 1 failed\n\n` + `Available actions:\n` + ` - orch_status() to inspect current state\n` + @@ -309,6 +387,9 @@ describe("3.x — Alert message formatting", () => { expect(summary).toContain("TMUX session exited without .DONE"); expect(summary).toContain("Segment: TP-005::api"); expect(summary).toContain("Segment frontier: 1/3 terminal"); + expect(summary).toContain("Failure policy: skip-dependents"); + expect(summary).toContain("Newly blocked dependents: TP-006, TP-007"); + expect(summary).toContain("Unrelated ready tasks continue under skip-dependents"); expect(summary).toContain("lane-2"); expect(summary).toContain("orch_status()"); expect(summary).toContain("orch_resume"); @@ -397,7 +478,7 @@ describe("4.x — Source-based verification of IPC wiring", () => { it("4.7 — engine.ts emits task-failure alerts", () => { const src = readSource("engine.ts"); - expect(src).toContain('category: "task-failure"'); + expect(src).toContain("buildSupervisorTaskFailureAlert("); expect(src).toContain("emitAlert("); }); @@ -413,19 +494,28 @@ describe("4.x — Source-based verification of IPC wiring", () => { it("4.10 — resume.ts emits task-failure alerts", () => { const src = readSource("resume.ts"); - expect(src).toContain('category: "task-failure"'); + expect(src).toContain("buildSupervisorTaskFailureAlert("); expect(src).toContain("emitAlert("); }); - it("4.11 — task-failure alerts include segment fields + frontier snapshot", () => { + it("4.10b — resume.ts derives failed-task alert metadata from persistedState.tasks", () => { + const src = readSource("resume.ts"); + expect(src).toContain("persistedState.tasks.find((task) => task.taskId === taskId)"); + expect(src).not.toContain("batchState.tasks.find((task) => task.taskId === taskId)"); + }); + + it("4.11 — task-failure alert details are built in shared helper and used by engine/resume", () => { const engineSrc = readSource("engine.ts"); const resumeSrc = readSource("resume.ts"); - expect(engineSrc).toContain("segmentFrontier"); - expect(engineSrc).toContain("segmentId"); - expect(engineSrc).toContain("repoId"); - expect(resumeSrc).toContain("segmentFrontier"); - expect(resumeSrc).toContain("segmentId"); - expect(resumeSrc).toContain("repoId"); + const typesSrc = readSource("types.ts"); + expect(engineSrc).toContain("buildSupervisorTaskFailureAlert("); + expect(resumeSrc).toContain("buildSupervisorTaskFailureAlert("); + expect(typesSrc).toContain('category: "task-failure"'); + expect(typesSrc).toContain("segmentFrontier"); + expect(typesSrc).toContain("segmentId"); + expect(typesSrc).toContain("repoId"); + expect(typesSrc).toContain("blockedTaskIds"); + expect(typesSrc).toContain("continueUnaffected"); }); it("4.12 — resume.ts emits merge-failure alerts", () => { diff --git a/extensions/tests/transactional-merge.test.ts b/extensions/tests/transactional-merge.test.ts index 6d70779b..38d8df45 100644 --- a/extensions/tests/transactional-merge.test.ts +++ b/extensions/tests/transactional-merge.test.ts @@ -227,6 +227,22 @@ describe("2.x — Rollback: verification_new_failure triggers rollback", () => { expect(successRollbackSection).not.toContain("blockAdvancement = true"); }); + it("2.3b: merge.ts validates unreachable submodule gitlinks before branch advancement", () => { + const mergeSource = readSource("merge.ts"); + + expect(mergeSource).toContain("detectUnreachableGitlinks(mergeWorkDir)"); + expect(mergeSource).toContain("post-merge submodule gitlink validation failed"); + expect(mergeSource).toContain("Post-merge submodule gitlink validation failed in lane"); + }); + + it("2.3c: unreachable submodule gitlink validation reuses rollback-to-preLaneHead", () => { + const mergeSource = readSource("merge.ts"); + + expect(mergeSource).toContain("rolling back temp branch after submodule gitlink validation failure"); + expect(mergeSource).toContain('git", ["reset", "--hard", preLaneHead]'); + expect(mergeSource).toContain('txnStatus = "rolled_back"'); + }); + it("2.4: lane error annotation includes verification_new_failure details", () => { const mergeSource = readSource("merge.ts"); @@ -296,11 +312,14 @@ describe("3.x — Safe-stop: rollback failure handling", () => { expect(afterSafeStop).toContain("break"); }); - it("3.6: resume.ts forces paused on rollbackFailed (parity with engine.ts)", () => { + it("3.6: resume.ts routes rollback safe-stop through the shared helper", () => { const resumeSource = readSource("resume.ts"); // Resume must have the same safe-stop handling - expect(resumeSource).toContain("mergeResult?.rollbackFailed"); + expect(resumeSource).toContain("const applyRollbackSafeStop = (waveIdx: number, mergeResult: MergeWaveResult)"); + expect(resumeSource).toContain("mergeResult.rollbackFailed"); + expect(resumeSource).toContain("mergeRequiresRollbackSafeStop(mergeResult)"); + expect(resumeSource).toContain("applyRollbackSafeStop(waveIdx, mergeResult)"); expect(resumeSource).toContain("SAFE-STOP: verification rollback failed"); expect(resumeSource).toContain('batchState.phase = "paused"'); expect(resumeSource).toContain("Check transaction records in .pi/verification/"); @@ -347,13 +366,12 @@ describe("4.x — Transaction record persistence", () => { it("4.1: persistTransactionRecord writes to correct path pattern", () => { const mergeSource = readSource("merge.ts"); - // Path: .pi/verification/{opId}/txn-b{batchId}-repo-{repoSlug}-wave-{n}-lane-{k}.json + // Path: .pi/verification/{opId}/txn-{waveTransactionId}-repo-{repoSlug}-lane-{k}.json expect(mergeSource).toContain(".pi"); expect(mergeSource).toContain("verification"); expect(mergeSource).toContain("record.opId"); - expect(mergeSource).toContain("txn-b${record.batchId}"); + expect(mergeSource).toContain("txn-${record.waveTransactionId}"); expect(mergeSource).toContain("repo-${repoSlug}"); - expect(mergeSource).toContain("wave-${record.waveIndex}"); expect(mergeSource).toContain("lane-${record.laneNumber}"); }); @@ -422,6 +440,14 @@ describe("5.x — Persistence warning propagation (R004-2)", () => { expect(mergeSource).toContain("groupResult.persistenceErrors"); }); + it("5.2b: atomic rollback transaction rewrite errors also flow into aggregate persistence warnings", () => { + const mergeSource = readSource("merge.ts"); + + expect(mergeSource).toContain("rewriteCommittedTransactionsAfterAtomicRollback"); + expect(mergeSource).toContain("allPersistenceErrors.push(...rewriteCommittedTransactionsAfterAtomicRollback("); + expect(mergeSource).toContain("aggregateResult.persistenceErrors = allPersistenceErrors"); + }); + it("5.3: engine.ts includes persistence warning in safe-stop notification", () => { const engineSource = readSource("engine.ts"); @@ -445,7 +471,7 @@ describe("5.x — Persistence warning propagation (R004-2)", () => { // ══════════════════════════════════════════════════════════════════════ describe("6.x — Engine/resume parity for safe-stop", () => { - it("6.1: both engine.ts and resume.ts check rollbackFailed before merge failure handling", () => { + it("6.1: engine.ts and resume.ts route rollback safe-stop before generic merge failure handling", () => { const engineSource = readSource("engine.ts"); const resumeSource = readSource("resume.ts"); @@ -453,10 +479,12 @@ describe("6.x — Engine/resume parity for safe-stop", () => { const engineRollbackIdx = engineSource.indexOf("mergeResult?.rollbackFailed"); const engineMergeFailIdx = engineSource.indexOf('mergeResult.status === "failed"'); expect(engineRollbackIdx).toBeLessThan(engineMergeFailIdx); + expect(engineSource).toContain("mergeRequiresRollbackSafeStop(mergeResult)"); - const resumeRollbackIdx = resumeSource.indexOf("mergeResult?.rollbackFailed"); + const resumeRollbackIdx = resumeSource.indexOf("applyRollbackSafeStop(waveIdx, mergeResult)"); const resumeMergeFailIdx = resumeSource.indexOf('mergeResult.status === "failed"'); expect(resumeRollbackIdx).toBeLessThan(resumeMergeFailIdx); + expect(resumeSource).toContain("applyRollbackSafeStop(waveIdx, mergeRetryResult)"); }); it("6.2: both files persist with trigger merge-rollback-safe-stop", () => { @@ -494,4 +522,51 @@ describe("6.x — Engine/resume parity for safe-stop", () => { // And is set on the aggregate result expect(mergeSource).toContain("aggregateResult.rollbackFailed = true"); }); + + it("6.6: multi-repo failures capture each repo target head before mergeWave runs", () => { + const mergeSource = readSource("merge.ts"); + + expect(mergeSource).toContain("const groupInitialTargetHead = readBranchHead(groupRepoRoot, groupBaseBranch)"); + expect(mergeSource).toContain('initialTargetHead: groupInitialTargetHead?.slice(0, 8) ?? "unknown"'); + }); + + it("6.7: cross-repo failure triggers atomic rollback of advanced repo refs", () => { + const mergeSource = readSource("merge.ts"); + + expect(mergeSource).toContain("cross-repo atomic merge failure detected"); + expect(mergeSource).toContain("rollbackRepoBranchToHead"); + expect(mergeSource).toContain("Cross-repo atomic merge rolled back"); + }); + + it("6.8: atomic rollback rewrites committed transaction records for affected repos", () => { + const mergeSource = readSource("merge.ts"); + + expect(mergeSource).toContain("rewriteCommittedTransactionsAfterAtomicRollback"); + expect(mergeSource).toContain('record.status = rollbackSucceeded ? "rolled_back" : "rollback_failed"'); + expect(mergeSource).toContain("record.rollbackAttempted = true"); + }); + + it("6.9: multi-repo aggregate status becomes failed instead of partial", () => { + const mergeSource = readSource("merge.ts"); + + expect(mergeSource).toContain("const strictAtomicCrossRepo = repoContexts.length > 1"); + expect(mergeSource).toContain("} else if (strictAtomicCrossRepo) {"); + expect(mergeSource).toContain('status = "failed"'); + }); + + it("6.10: engine.ts emits atomic repo failure summaries for failed multi-repo merges", () => { + const engineSource = readSource("engine.ts"); + + expect(engineSource).toContain("formatRepoAtomicFailureSummary"); + expect(engineSource).toContain("const atomicRepoSummary = formatRepoAtomicFailureSummary(mergeResult)"); + expect(engineSource).toContain("onNotify(atomicRepoSummary, \"warning\")"); + }); + + it("6.11: resume.ts emits atomic repo failure summaries for failed multi-repo merges", () => { + const resumeSource = readSource("resume.ts"); + + expect(resumeSource).toContain("formatRepoAtomicFailureSummary"); + expect(resumeSource).toContain("const atomicRepoSummary = formatRepoAtomicFailureSummary(mergeResult)"); + expect(resumeSource).toContain("onNotify(atomicRepoSummary, \"warning\")"); + }); }); diff --git a/extensions/tests/waves-repo-scoped.test.ts b/extensions/tests/waves-repo-scoped.test.ts index dcbdf6a2..db69f399 100644 --- a/extensions/tests/waves-repo-scoped.test.ts +++ b/extensions/tests/waves-repo-scoped.test.ts @@ -17,10 +17,15 @@ // Import the functions under test directly from waves.ts import { describe, it } from "node:test"; import { expect } from "./expect.ts"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; import { resolveRepoRoot, resolveBaseBranch, groupTasksByRepo, + allocateLanes, generateLaneId, generateLaneSessionId, buildDependencyGraph, @@ -50,6 +55,7 @@ function makeWorkspaceConfig(repos: Record { expect(groups[1].taskIds).toEqual(["T-002"]); }); + it("keeps unsplit multi-repo tasks on a dedicated lane-planning contract", () => { + const pending = new Map([ + [ + "T-001", + makeParsedTask("T-001", { + resolvedRepoId: "api", + resolvedRepoIds: ["api", "web"], + }), + ], + ["T-002", makeParsedTask("T-002", { resolvedRepoId: "api" })], + ["T-003", makeParsedTask("T-003", { resolvedRepoId: "web" })], + ]); + + const groups = groupTasksByRepo(["T-001", "T-002", "T-003"], pending); + expect(groups).toHaveLength(3); + expect(groups.map((group) => `${group.repoId ?? "default"}:${group.taskIds.join(",")}`)).toEqual([ + "api:T-002", + "api:T-001", + "web:T-003", + ]); + expect(groups[1].repoIds).toEqual(["api", "web"]); + }); + + it("uses the active segment repo as the grouping key for segment-frontier rounds", () => { + const pending = new Map([ + [ + "T-001", + makeParsedTask("T-001", { + resolvedRepoId: "web", + resolvedRepoIds: ["api", "web", "docs"], + participatingRepoIds: ["api", "web", "docs"], + activeSegmentId: "T-001::web", + }), + ], + ["T-002", makeParsedTask("T-002", { resolvedRepoId: "web" })], + ]); + + const groups = groupTasksByRepo(["T-001", "T-002"], pending); + expect(groups).toHaveLength(1); + expect(groups[0].repoId).toBe("web"); + expect(groups[0].repoIds).toEqual(["web"]); + expect(groups[0].taskIds).toEqual(["T-001", "T-002"]); + }); + it("sorts tasks within each group alphabetically", () => { const pending = new Map([ ["Z-001", makeParsedTask("Z-001", { resolvedRepoId: "api" })], @@ -213,6 +300,71 @@ describe("groupTasksByRepo", () => { }); }); +describe("allocateLanes", () => { + it("keeps a multi-repo task on one lane contract while singleton repo tasks parallelize separately", () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "taskplane-waves-")); + const initRepo = (repoId: string): string => { + const repoRoot = join(workspaceRoot, repoId); + mkdirSync(repoRoot, { recursive: true }); + writeFileSync(join(repoRoot, "README.md"), `# ${repoId}\n`, "utf-8"); + spawnSync("git", ["init", "--initial-branch=main"], { cwd: repoRoot, encoding: "utf-8" }); + spawnSync("git", ["add", "README.md"], { cwd: repoRoot, encoding: "utf-8" }); + spawnSync( + "git", + ["-c", "user.name=Taskplane Test", "-c", "user.email=taskplane@example.com", "commit", "-m", "init"], + { cwd: repoRoot, encoding: "utf-8" }, + ); + return repoRoot; + }; + + const apiRoot = initRepo("api"); + const webRoot = initRepo("web"); + + try { + const pending = new Map([ + [ + "T-001", + makeParsedTask("T-001", { + resolvedRepoId: "api", + resolvedRepoIds: ["api", "web"], + }), + ], + ["T-002", makeParsedTask("T-002", { resolvedRepoId: "api" })], + ["T-003", makeParsedTask("T-003", { resolvedRepoId: "web" })], + ]); + + const result = allocateLanes( + ["T-001", "T-002", "T-003"], + pending, + makeConfig(), + workspaceRoot, + "batch-001", + "main", + makeWorkspaceConfig({ + api: { path: apiRoot, defaultBranch: "main" }, + web: { path: webRoot, defaultBranch: "main" }, + }), + ); + + expect(result.success).toBe(true); + expect(result.lanes).toHaveLength(3); + expect(result.lanes.map((lane) => ({ repoId: lane.repoId, taskIds: lane.tasks.map((task) => task.taskId) }))).toEqual([ + { repoId: "api", taskIds: ["T-002"] }, + { repoId: "api", taskIds: ["T-001"] }, + { repoId: "web", taskIds: ["T-003"] }, + ]); + + const multiRepoLane = result.lanes.find((lane) => lane.tasks.some((task) => task.taskId === "T-001")); + expect(multiRepoLane).toBeDefined(); + expect(Object.keys(multiRepoLane!.repoWorktrees || {}).sort()).toEqual(["api", "web"]); + expect(multiRepoLane!.repoWorktrees?.api?.path).toBe(multiRepoLane!.worktreePath); + expect(multiRepoLane!.repoWorktrees?.web?.path.includes("lane-2")).toBe(true); + } finally { + rmSync(workspaceRoot, { recursive: true, force: true }); + } + }); +}); + // ── 4. generateLaneId() ────────────────────────────────────────────── describe("generateLaneId", () => { diff --git a/extensions/tests/workspace-sync-blocking.test.ts b/extensions/tests/workspace-sync-blocking.test.ts new file mode 100644 index 00000000..1b2b748c --- /dev/null +++ b/extensions/tests/workspace-sync-blocking.test.ts @@ -0,0 +1,63 @@ +import { describe, it } from "node:test"; + +import { expect } from "./expect.ts"; +import { + getBlockingWorkspaceSyncFindings, + hasBlockingWorkspaceSyncFindings, +} from "../taskplane/messages.ts"; + +describe("workspace sync blocking policy", () => { + it("does not treat permissive findings as blocking", () => { + const summary = { + trackedSubmodules: 1, + importCandidates: [], + findings: [ + { + name: "submodule-state:repo:vendor/private-assets", + kind: "uninitialized-submodule", + status: "warn", + repoLabel: "repo", + repoRoot: "/tmp/repo", + submodulePath: "vendor/private-assets", + message: "repo: submodule 'vendor/private-assets' is not initialized.", + }, + ], + } as const; + + expect(hasBlockingWorkspaceSyncFindings(summary)).toBe(false); + expect(getBlockingWorkspaceSyncFindings(summary)).toHaveLength(0); + }); + + it("treats strict findings as blocking", () => { + const summary = { + trackedSubmodules: 2, + importCandidates: [], + findings: [ + { + name: "submodule-state:repo:vendor/private-assets", + kind: "uninitialized-submodule", + status: "warn", + repoLabel: "repo", + repoRoot: "/tmp/repo", + submodulePath: "vendor/private-assets", + message: "repo: submodule 'vendor/private-assets' is not initialized.", + }, + { + name: "submodule-state:repo:vendor/core-assets", + kind: "drifted-submodule", + status: "fail", + repoLabel: "repo", + repoRoot: "/tmp/repo", + submodulePath: "vendor/core-assets", + message: "repo: submodule 'vendor/core-assets' is drifted from the recorded gitlink commit.", + }, + ], + } as const; + + expect(hasBlockingWorkspaceSyncFindings(summary)).toBe(true); + expect(getBlockingWorkspaceSyncFindings(summary)).toHaveLength(1); + expect(getBlockingWorkspaceSyncFindings(summary)[0].name).toBe( + "submodule-state:repo:vendor/core-assets", + ); + }); +}); \ No newline at end of file diff --git a/extensions/tests/workspace-sync-ui.test.ts b/extensions/tests/workspace-sync-ui.test.ts new file mode 100644 index 00000000..92313a89 --- /dev/null +++ b/extensions/tests/workspace-sync-ui.test.ts @@ -0,0 +1,78 @@ +import { describe, it } from "node:test"; + +import { expect } from "./expect.ts"; +import { formatWorkspaceSyncPresentation } from "../taskplane/messages.ts"; + +describe("workspace sync UI presentation", () => { + it("treats execution warnings as a failure", () => { + const presentation = formatWorkspaceSyncPresentation({ + importedRepoIds: [], + initializedPaths: [], + updatedPaths: [], + warnings: ["Failed to synchronize submodules in '/repo': error: pathspec did not match any file(s) known to git"], + changed: false, + }, { + trackedSubmodules: 1, + findings: [], + importCandidates: [], + }); + + expect(presentation.status).toBe("failure"); + expect(presentation.notificationLevel).toBe("error"); + expect(presentation.message).toContain("❌ Workspace sync failed."); + }); + + it("keeps a no-op sync successful when only permissive warnings remain", () => { + const presentation = formatWorkspaceSyncPresentation({ + importedRepoIds: [], + initializedPaths: [], + updatedPaths: [], + warnings: [], + changed: false, + }, { + trackedSubmodules: 1, + findings: [{ + name: "submodule-state:main:vendor/docs", + kind: "uninitialized-submodule", + status: "warn", + repoLabel: "main", + repoRoot: "/repo", + submodulePath: "vendor/docs", + absolutePath: "/repo/vendor/docs", + message: "main: submodule 'vendor/docs' is not initialized.", + }], + importCandidates: [], + }); + + expect(presentation.status).toBe("success"); + expect(presentation.notificationLevel).toBe("info"); + expect(presentation.message).toContain("ℹ️ Workspace sync made no changes."); + }); + + it("treats remaining blocking findings as a failure even if git reported no warnings", () => { + const presentation = formatWorkspaceSyncPresentation({ + importedRepoIds: [], + initializedPaths: [], + updatedPaths: [], + warnings: [], + changed: false, + }, { + trackedSubmodules: 1, + findings: [{ + name: "submodule-state:main:vendor/docs", + kind: "uninitialized-submodule", + status: "fail", + repoLabel: "main", + repoRoot: "/repo", + submodulePath: "vendor/docs", + absolutePath: "/repo/vendor/docs", + message: "main: submodule 'vendor/docs' is not initialized.", + }], + importCandidates: [], + }); + + expect(presentation.status).toBe("failure"); + expect(presentation.notificationLevel).toBe("error"); + expect(presentation.message).toContain("❌ Workspace sync is still incomplete."); + }); +}); \ No newline at end of file diff --git a/extensions/tests/workspace-sync.test.ts b/extensions/tests/workspace-sync.test.ts new file mode 100644 index 00000000..e16bf338 --- /dev/null +++ b/extensions/tests/workspace-sync.test.ts @@ -0,0 +1,172 @@ +import { afterEach, beforeEach, describe, it } from "node:test"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { expect } from "./expect.ts"; +import { applyWorkspaceSync, collectWorkspaceSyncSummary, DEFAULT_SUBMODULE_POLICY } from "../taskplane/workspace.ts"; + +let testRoot: string; + +beforeEach(() => { + testRoot = mkdtempSync(join(tmpdir(), "tp-workspace-sync-")); +}); + +afterEach(() => { + rmSync(testRoot, { recursive: true, force: true }); +}); + +function runGit(cwd: string, args: string[]): string { + return execFileSync("git", args, { + cwd, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); +} + +function initRepo(dir: string): void { + mkdirSync(dir, { recursive: true }); + runGit(dir, ["init", "--initial-branch=main"]); + runGit(dir, ["config", "user.name", "test"]); + runGit(dir, ["config", "user.email", "test@test.local"]); + writeFileSync(join(dir, "README.md"), "# test\n", "utf-8"); + runGit(dir, ["add", "README.md"]); + runGit(dir, ["commit", "-m", "init"]); +} + +function addSubmodule(superRepo: string, subRepo: string, submodulePath: string): void { + runGit(superRepo, ["-c", "protocol.file.allow=always", "submodule", "add", subRepo, submodulePath]); + runGit(superRepo, ["add", "."]); + runGit(superRepo, ["commit", "-m", `add ${submodulePath}`]); +} + +describe("workspace sync helper", () => { + it("imports undeclared workspace repos for valid submodule basenames", () => { + const superRepo = join(testRoot, "workspace-main"); + const subRepo = join(testRoot, "docs-src"); + initRepo(superRepo); + initRepo(subRepo); + addSubmodule(superRepo, subRepo, "vendor/docs"); + + mkdirSync(join(superRepo, ".pi"), { recursive: true }); + const workspaceYamlPath = join(superRepo, ".pi", "taskplane-workspace.yaml"); + writeFileSync(workspaceYamlPath, [ + "repos:", + " main:", + " path: .", + "routing:", + " tasks_root: taskplane-tasks", + " default_repo: main", + " task_packet_repo: main", + ].join("\n") + "\n", "utf-8"); + + const workspaceConfig = { + mode: "workspace", + repos: new Map([["main", { id: "main", path: superRepo }]]), + routing: { + tasksRoot: join(superRepo, "taskplane-tasks"), + defaultRepo: "main", + taskPacketRepo: "main", + }, + configPath: workspaceYamlPath, + } as any; + + const summary = collectWorkspaceSyncSummary(superRepo, workspaceConfig, DEFAULT_SUBMODULE_POLICY, "all"); + expect(summary.importCandidates.map((candidate) => candidate.derivedRepoId)).toEqual(["docs"]); + + const result = applyWorkspaceSync(superRepo, superRepo, workspaceConfig, DEFAULT_SUBMODULE_POLICY, summary); + expect(result.importedRepoIds).toEqual(["docs"]); + expect(workspaceConfig.repos.has("docs")).toBe(true); + expect(readFileSync(workspaceYamlPath, "utf-8")).toContain("docs:"); + expect(readFileSync(workspaceYamlPath, "utf-8")).toContain("path: vendor/docs"); + }); + + it("initializes missing submodules when the drift policy is init-only", () => { + const originalGitAllowProtocol = process.env.GIT_ALLOW_PROTOCOL; + process.env.GIT_ALLOW_PROTOCOL = "file"; + try { + const superRepo = join(testRoot, "repo-with-submodule"); + const subRepo = join(testRoot, "docs-src"); + const cloneRepo = join(testRoot, "repo-clone"); + initRepo(superRepo); + initRepo(subRepo); + addSubmodule(superRepo, subRepo, "vendor/docs"); + runGit(testRoot, ["clone", superRepo, cloneRepo]); + runGit(cloneRepo, ["config", "protocol.file.allow", "always"]); + + const policy = { + failureMode: "strict", + onSubmoduleDrift: "init-only", + repoIdStrategy: "path-basename", + } as const; + + const before = collectWorkspaceSyncSummary(cloneRepo, null, policy, "all"); + expect(before.findings.some((finding) => finding.kind === "uninitialized-submodule")).toBe(true); + + const result = applyWorkspaceSync(cloneRepo, cloneRepo, null, policy, before); + expect(result.warnings).toEqual([]); + expect(result.initializedPaths).toEqual(["repo-clone:vendor/docs"]); + + const after = collectWorkspaceSyncSummary(cloneRepo, null, policy, "all"); + expect(after.findings.some((finding) => finding.kind === "uninitialized-submodule")).toBe(false); + } finally { + if (originalGitAllowProtocol === undefined) { + delete process.env.GIT_ALLOW_PROTOCOL; + } else { + process.env.GIT_ALLOW_PROTOCOL = originalGitAllowProtocol; + } + } + }); + + it("syncs nested submodule findings through the nearest tracked parent path", () => { + const originalGitAllowProtocol = process.env.GIT_ALLOW_PROTOCOL; + process.env.GIT_ALLOW_PROTOCOL = "file"; + try { + const superRepo = join(testRoot, "repo-with-nested-submodule"); + const childRepo = join(testRoot, "rebof3-simple"); + const nestedRepo = join(testRoot, "private-assets"); + const cloneRepo = join(testRoot, "repo-clone"); + + initRepo(superRepo); + initRepo(childRepo); + initRepo(nestedRepo); + addSubmodule(childRepo, nestedRepo, "external/private-assets"); + addSubmodule(superRepo, childRepo, "third_party/references/rebof3-simple"); + + runGit(testRoot, ["clone", superRepo, cloneRepo]); + runGit(cloneRepo, ["config", "protocol.file.allow", "always"]); + runGit(cloneRepo, ["submodule", "update", "--init", "--", "third_party/references/rebof3-simple"]); + + const policy = { + failureMode: "strict", + onSubmoduleDrift: "recursive-on-drift", + repoIdStrategy: "path-basename", + } as const; + + const before = collectWorkspaceSyncSummary(cloneRepo, null, policy, "all"); + expect(before.findings.some((finding) => + finding.kind === "uninitialized-submodule" && + finding.submodulePath === "third_party/references/rebof3-simple/external/private-assets" + )).toBe(true); + + const result = applyWorkspaceSync(cloneRepo, cloneRepo, null, policy, before); + expect(result.warnings).toEqual([]); + expect(result.initializedPaths).toContain( + "repo-clone:third_party/references/rebof3-simple/external/private-assets", + ); + + const after = collectWorkspaceSyncSummary(cloneRepo, null, policy, "all"); + expect(after.findings.some((finding) => + finding.kind === "uninitialized-submodule" && + finding.submodulePath === "third_party/references/rebof3-simple/external/private-assets" + )).toBe(false); + } finally { + if (originalGitAllowProtocol === undefined) { + delete process.env.GIT_ALLOW_PROTOCOL; + } else { + process.env.GIT_ALLOW_PROTOCOL = originalGitAllowProtocol; + } + } + }); +}); diff --git a/extensions/tsconfig.json b/extensions/tsconfig.json index 981f08e2..6101a108 100644 --- a/extensions/tsconfig.json +++ b/extensions/tsconfig.json @@ -10,7 +10,7 @@ "allowImportingTsExtensions": true, "noImplicitAny": false, "typeRoots": ["../web/node_modules/@types"], - "baseUrl": "." + "baseUrl": ".", }, "include": ["task-orchestrator.ts"], "exclude": ["tests"] diff --git a/package-lock.json b/package-lock.json index d27b27c0..af9ad634 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "jiti": "^2.6.1", - "yaml": "^2.4.0" + "yaml": "^2.8.3" }, "bin": { "taskplane": "bin/taskplane.mjs" @@ -4143,9 +4143,9 @@ } }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/package.json b/package.json index 3f0e0cf2..0dc5e810 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ }, "dependencies": { "jiti": "^2.6.1", - "yaml": "^2.4.0" + "yaml": "^2.8.3" }, "license": "MIT", "repository": { diff --git a/skills/batch-reset/SKILL.md b/skills/batch-reset/SKILL.md new file mode 100644 index 00000000..99dbeb6e --- /dev/null +++ b/skills/batch-reset/SKILL.md @@ -0,0 +1,99 @@ +--- +name: batch-reset +version: 1.0.0 +description: Fully resets a Taskplane workspace for a fresh batch run. Backs up existing tasks, creates a clean .pi/tasks/ with only specified tasks, and clears residual state including batch-state.json, runtime/, supervisor/, diagnostics/, mailbox/, telemetry/, and verification/. Use when restarting from a clean slate after failed batches, re-running a selected subset of tasks, or testing a new task configuration from scratch. +--- + +# Batch Reset Skill + +Fully resets the Taskplane workspace for fresh batch execution. This handles: +1. Backing up existing tasks to `.pi/tasks.bak` +2. Creating a fresh `.pi/tasks/` with only selected tasks +3. Cleaning residual orchestration state + +## Prerequisites + +- Project must have `.pi/taskplane-config.json` configured +- Existing batch must be stopped or paused, not actively running +- Git working tree should be clean aside from expected `.pi/` task state +- If batch is in "executing" phase with running tasks, wait for completion before resuming + +## Usage + +When the operator says "reset for fresh batch" or wants to run a specific set of tasks from scratch: + +1. **Backup current tasks**: Move `.pi/tasks/` to `.pi/tasks.bak/` +2. **Select target tasks**: Identify which task folders to include in the clean slate +3. **Copy selected tasks**: Replicate only those into fresh `.pi/tasks/` +4. **Clean residual state**: Remove `batch-state.json`, clear `runtime/`, `supervisor/`, `diagnostics/`, `mailbox/`, `telemetry/`, `verification/` +5. **Verify**: Ensure there are no stale `.DONE` markers, worktrees, or residual orchestration branches + +## Steps + +### Step 1: Check current batch state + +Before resetting, check if the batch is in a valid state: + +```bash +cat .pi/batch-state.json | python3 -c "import json,sys; d=json.load(sys.stdin); print(f'Phase: {d[\"phase\"]}, Wave: {d[\"currentWaveIndex\"]+1}/{d[\"totalWaves\"]}, Tasks: {d[\"succeededTasks\"]}s/{d[\"failedTasks\"]}f/{d[\"skippedTasks\"]}k/{d[\"totalTasks\"]}')" +``` + +If phase is "executing" and tasks are running, wait for completion before proceeding. + +### Step 2: Backup existing tasks + +```bash +mv .pi/tasks .pi/tasks.bak +``` + +### Step 3: Create fresh task directory structure + +Identify the target areas and copy only those task folders: + +```bash +mkdir -p .pi/tasks/{area1,area2,...} +cp -r .pi/tasks.bak/area1/TASK-XXX-task-name .pi/tasks/area1/ +# Repeat for each selected task folder +``` + +### Step 4: Clean residual orchestration state + +```bash +rm -rf .pi/runtime/* .pi/supervisor/* .pi/diagnostics/* .pi/mailbox/* .pi/telemetry/* .pi/verification/* +rm -f .pi/batch-state.json .pi/batch-history.json +``` + +### Step 5: Verify clean state + +- `find .pi/tasks -name ".DONE" | wc -l` should be `0` +- `git worktree list --porcelain | grep "^worktree"` should show only the main repo worktree +- No stale task, orch, or saved branches should remain + +### Step 6: Resume batch with correct state logic + +After reset, check the batch state before calling `orch_resume()`: + +```bash +# Decision matrix for when to resume: +# - phase == "stopped" AND currentWaveIndex < totalWaves-1 → resume +# - phase == "executing" AND has running tasks → wait (don't resume) +# - phase == "stopped" AND all tasks terminal → done, integrate or skip +``` + +Only call `orch_resume()` when: +- Phase is stopped AND wave is incomplete +- OR force=true to retry failed tasks + +After resume, poll until: +- All tasks reach terminal status (succeeded/failed/skipped) +- Phase transitions from "executing" to "stopped" or "idle" + +## Notes + +- The backup at `.pi/tasks.bak` preserves the original tasks for restoration if needed +- After reset, run `/orch-plan --sync` to create a fresh batch plan +- This skill is idempotent; repeated runs produce the same clean taskplane state +- **Key fix**: After batch reset, check phase and wave state before resuming. Do not call `orch_resume()` repeatedly if the batch is already in a valid executing state with running tasks. +- **Submodule gitlink**: When submodules are present, ensure they are initialized and their commits are reachable from origin before starting the batch. Use `git submodule status` to verify. +- **Worktree cleanup**: Always remove worktrees and temporary branches during reset. Stale worktrees can cause merge conflicts. +- **TASK-037**: For bugfix loops, use `reset_strategy: full` for the first iteration (clean slate) and `reset_strategy: light` for subsequent iterations (faster reset). \ No newline at end of file diff --git a/skills/bugfix-loop/SKILL.md b/skills/bugfix-loop/SKILL.md new file mode 100644 index 00000000..7b93dafd --- /dev/null +++ b/skills/bugfix-loop/SKILL.md @@ -0,0 +1,193 @@ +--- +name: bugfix-loop +version: 1.0.0 +description: A structured loop for diagnosing and fixing taskplane bugs. Selects a small task slice, resets the repo to clean state, runs a batch while monitoring against a skill condition, and iterates until the fix is confirmed. Use when investigating recurring failures (e.g., submodule gitlink validation) or validating fixes in the taskplane fork. +--- + +# Bugfix Loop Skill + +A structured loop for diagnosing and fixing taskplane bugs through iterative batch runs. Each iteration: +1. Selects a focused task slice (subset of tasks relevant to the bug) +2. Resets the repo to a known clean state +3. Runs a batch while monitoring for the specific issue +4. Evaluates whether the fix condition is met +5. Iterates until confirmed or exhausted + +## Prerequisites + +- Taskplane fork is available at `.pi/git/github.com/loopyd/taskplane` +- Project has `.pi/taskplane-config.json` configured +- At least one task exists in `.pi/tasks/` +- Git working tree is clean (or can be made clean) + +## Configuration + +The skill accepts these parameters: + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `task_slice` | Comma-separated list of task IDs or prefixes to include | All tasks | +| `max_iterations` | Maximum number of batch iterations | 5 | +| `fix_condition` | Description of what constitutes a "fixed" state | Merge completes without submodule gitlink errors | +| `reset_strategy` | How to reset: `full` (backup+restore) or `light` (status reset only) | full | +| `monitor_interval` | Seconds between status checks during batch | 5 | + +## Usage + +When the operator says "start the bugfix loop" or "loop on this issue": + +1. **Select task slice**: Identify tasks relevant to the bug (e.g., tasks that touch submodules) +2. **Reset to clean state**: Use `batch-reset` skill or light reset +3. **Run batch**: Start a fresh batch with `/orch all --sync` +4. **Monitor**: Watch for the specific issue (submodule gitlink, merge failure, etc.) +5. **Evaluate**: Check if the fix condition is met +6. **Iterate**: If not fixed, apply changes to the taskplane fork and retry + +## Reset Strategies + +### Full Reset (`reset_strategy: full`) +- Backup `.pi/tasks/` to `.pi/tasks.bak/` +- Remove all worktrees, branches, and transient state +- Restore tasks from backup +- Clear `.DONE` markers, batch-state.json, telemetry +- Result: Complete clean slate + +### Light Reset (`reset_strategy: light`) +- Keep `.pi/tasks/` as-is +- Remove only worktrees and temporary branches +- Clear transient state (runtime, supervisor, telemetry) +- Reset STATUS.md files to Pending +- Result: Faster reset, preserves task configuration + +## Monitoring the Fix Condition + +The skill monitors for the specific issue by checking: + +1. **Batch result**: Did all tasks succeed? +2. **Merge phase**: Did merge complete without errors? +3. **Submodule validation**: Are gitlinks reachable after merge? +4. **Lane outcomes**: Which lanes succeeded/failed? +5. **Error messages**: Does the error match the known pattern? + +### Error Pattern Matching + +For the submodule gitlink issue, the skill checks for: +``` +"Post-merge submodule gitlink validation failed in lane N: @ on origin" +``` + +If this pattern appears consistently across iterations, it confirms a logic error. If it appears intermittently, it may be a transient race condition. + +## Iteration Flow + +``` +┌─────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Select │────▶│ Reset to │────▶│ Run Batch │ +│ Task Slice │ │ Clean State │ │ + Monitor │ +└─────────────┘ └──────────────┘ └──────────────┘ + │ + ▼ +┌─────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Iterate │◀────│ Evaluate │◀────│ Check Fix │ +│ (if not │ │ Result │ │ Condition │ +│ fixed) │ └──────────────┘ └──────────────┘ +└─────────────┘ │ + ▼ + ┌──────────────┐ + │ Fix Confirmed│ + │ or Exhausted │ + └──────────────┘ +``` + +## Step-by-Step Procedure + +### Step 1: Select Task Slice + +Identify the tasks most relevant to the bug. For submodule gitlink issues: +- Tasks that modify files in submodules +- Tasks in lanes that consistently fail merge +- Tasks with dependencies on other tasks + +Example: `task_slice = "INV-001,DISK-001,TEST-001"` (tasks in lane-2) + +### Step 2: Reset to Clean State + +Choose the appropriate reset strategy: +- **Full reset**: When starting from scratch or after multiple failed iterations +- **Light reset**: When testing a specific fix without losing task configuration + +### Step 3: Run Batch + +Start a fresh batch with monitoring: +```bash +/orch all --sync +``` + +Monitor for: +- Wave execution progress +- Merge phase completion +- Submodule gitlink validation +- Error messages in batch summary + +### Step 4: Evaluate Result + +Check the batch summary for: +- **Success**: All tasks succeeded, merge completed without errors → Fix confirmed +- **Partial**: Some tasks failed but merge completed → Continue iterating +- **Failure**: Merge failed with same error pattern → Apply fix to taskplane fork + +### Step 5: Iterate + +If not fixed: +1. Apply changes to the taskplane fork (commit and push) +2. Reset to clean state +3. Run batch again +4. Repeat until max_iterations or fix confirmed + +## Skill Condition for Fix + +The fix condition is met when: +1. **Merge completes** without submodule gitlink validation errors +2. **All tasks in the slice succeed** (not just "succeeded" but properly committed) +3. **No recurring error pattern** across iterations + +For the submodule gitlink issue specifically: +- The error message should change from "Post-merge submodule gitlink validation failed" to "Merge completed successfully" +- Or the same error should appear but with different submodules (indicating progress) +- After 3 consecutive successful merges, the fix is confirmed + +## Integration with Other Skills + +This skill works alongside: +- **batch-reset**: Provides the reset functionality for each iteration +- **create-taskplane-task**: For creating test tasks during diagnosis +- **taskplane-fork**: For applying fixes to the taskplane extension + +## Notes + +- The loop is idempotent — repeated runs produce consistent results +- Each iteration should document what changed (fix applied, config updated, etc.) +- The skill can be interrupted at any time and resumed from the last iteration +- **Key insight**: Consistent error patterns across clean-state iterations confirm a logic error; intermittent patterns suggest transient issues + +## Example: Submodule Gitlink Bugfix Loop + +``` +Iteration 1: + - Task slice: INV-001, DISK-001, TEST-001 + - Reset: full + - Batch: 3/4 succeeded, merge failed (lane-2 gitlink) + - Error: BoF3-Data-Doc@c700f9b5 not reachable on origin + +Iteration 2: + - Fix: Updated checkSubmoduleCommitReachable in git.ts + - Reset: light + - Batch: 3/4 succeeded, merge failed (same error) + - Confirmed: Logic error, not transient + +Iteration 3: + - Fix: Pushed to taskplane fork + - Reset: full + - Batch: 4/4 succeeded, merge completed + - Result: Fix confirmed +``` diff --git a/taskplane-tasks/CONTEXT.md b/taskplane-tasks/CONTEXT.md index a4ba04fc..520d1ef0 100644 --- a/taskplane-tasks/CONTEXT.md +++ b/taskplane-tasks/CONTEXT.md @@ -1,6 +1,6 @@ # General — Context -**Last Updated:** 2026-04-02 +**Last Updated:** 2026-04-21 **Status:** Active **Next Task ID:** TP-181 @@ -39,6 +39,43 @@ Taskplane is an AI agent orchestration system built as a pi package. It provides --- +## Submodule Policy + +This task area lives inside the **taskplane** submodule of the bof3-decomp project. +All tasks in this folder must declare their execution target to prevent conflicts +when the orchestrator runs across multiple submodules concurrently. + +### Submodule identity + +| Field | Value | +|-------|-------| +| Repo ID | `taskplane` | +| Git path (relative) | `.pi/git/github.com/loopyd/taskplane` | +| Absolute path | `/mnt/PROJECTS/repos/bof3-decomp/.pi/git/github.com/loopyd/taskplane` | +| Upstream URL | `https://github.com/loopyd/taskplane.git` | + +### Task declaration requirement + +Every `PROMPT.md` must include an **Execution Target** section declaring the repo ID: + +```markdown +## Execution Target + +- **Repo:** taskplane +``` + +This is enforced by the orchestrator's workspace submodule policy. Tasks without a +declared execution target will be flagged during preflight and blocked until fixed. + +### Conflict avoidance rules + +1. Tasks targeting different submodules run on separate lanes (parallel-safe). +2. Tasks within the same submodule run serially unless explicitly lane-allocated by batch planning. +3. File scope declarations in `## File Scope` are validated against the declared repo root. +4. Git operations must use the submodule's working tree, not the bof3-decomp parent repo. + +--- + ## Technical Debt / Future Work _Items discovered during task execution are logged here by agents._ diff --git a/taskplane-tasks/TP-001-workspace-config-and-execution-context/PROMPT.md b/taskplane-tasks/TP-001-workspace-config-and-execution-context/PROMPT.md index e8a1bfb8..30514827 100644 --- a/taskplane-tasks/TP-001-workspace-config-and-execution-context/PROMPT.md +++ b/taskplane-tasks/TP-001-workspace-config-and-execution-context/PROMPT.md @@ -43,6 +43,15 @@ Add workspace-mode foundations so Taskplane can run from a non-git workspace roo - **Workspace:** Taskplane extension and dashboard codebase - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope > The orchestrator uses this to avoid merge conflicts: tasks with overlapping diff --git a/taskplane-tasks/TP-003-external-task-folder-path-resolution/PROMPT.md b/taskplane-tasks/TP-003-external-task-folder-path-resolution/PROMPT.md index 5492a83a..c35259ff 100644 --- a/taskplane-tasks/TP-003-external-task-folder-path-resolution/PROMPT.md +++ b/taskplane-tasks/TP-003-external-task-folder-path-resolution/PROMPT.md @@ -43,6 +43,15 @@ Make orchestrator monitoring and completion detection robust when canonical task - **Workspace:** Taskplane extension and dashboard codebase - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope > The orchestrator uses this to avoid merge conflicts: tasks with overlapping diff --git a/taskplane-tasks/TP-004-repo-scoped-lane-allocation-and-worktrees/PROMPT.md b/taskplane-tasks/TP-004-repo-scoped-lane-allocation-and-worktrees/PROMPT.md index 4beaf437..cd06db3c 100644 --- a/taskplane-tasks/TP-004-repo-scoped-lane-allocation-and-worktrees/PROMPT.md +++ b/taskplane-tasks/TP-004-repo-scoped-lane-allocation-and-worktrees/PROMPT.md @@ -45,6 +45,15 @@ Refactor wave execution to allocate and manage lanes per target repo so a single - **Workspace:** Taskplane extension and dashboard codebase - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope > The orchestrator uses this to avoid merge conflicts: tasks with overlapping diff --git a/taskplane-tasks/TP-005-repo-scoped-merge-orchestration/PROMPT.md b/taskplane-tasks/TP-005-repo-scoped-merge-orchestration/PROMPT.md index c8b26c3b..f6bb0faa 100644 --- a/taskplane-tasks/TP-005-repo-scoped-merge-orchestration/PROMPT.md +++ b/taskplane-tasks/TP-005-repo-scoped-merge-orchestration/PROMPT.md @@ -43,6 +43,15 @@ Implement repo-scoped merge sequencing so completed lanes are merged in their ow - **Workspace:** Taskplane extension and dashboard codebase - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope > The orchestrator uses this to avoid merge conflicts: tasks with overlapping diff --git a/taskplane-tasks/TP-006-persisted-state-schema-v2-repo-aware/PROMPT.md b/taskplane-tasks/TP-006-persisted-state-schema-v2-repo-aware/PROMPT.md index 28a61119..1a49c396 100644 --- a/taskplane-tasks/TP-006-persisted-state-schema-v2-repo-aware/PROMPT.md +++ b/taskplane-tasks/TP-006-persisted-state-schema-v2-repo-aware/PROMPT.md @@ -43,6 +43,15 @@ Add repo identity to persisted orchestrator state and implement schema-v1 compat - **Workspace:** Taskplane extension and dashboard codebase - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope > The orchestrator uses this to avoid merge conflicts: tasks with overlapping diff --git a/taskplane-tasks/TP-007-resume-reconciliation-across-repos/PROMPT.md b/taskplane-tasks/TP-007-resume-reconciliation-across-repos/PROMPT.md index 55e2199e..684dbb5e 100644 --- a/taskplane-tasks/TP-007-resume-reconciliation-across-repos/PROMPT.md +++ b/taskplane-tasks/TP-007-resume-reconciliation-across-repos/PROMPT.md @@ -44,6 +44,15 @@ Extend /orch-resume to reconstruct and continue polyrepo batches using repo-awar - **Workspace:** Taskplane extension and dashboard codebase - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope > The orchestrator uses this to avoid merge conflicts: tasks with overlapping diff --git a/taskplane-tasks/TP-008-workspace-aware-doctor-diagnostics/PROMPT.md b/taskplane-tasks/TP-008-workspace-aware-doctor-diagnostics/PROMPT.md index b4d6f455..909fa0cc 100644 --- a/taskplane-tasks/TP-008-workspace-aware-doctor-diagnostics/PROMPT.md +++ b/taskplane-tasks/TP-008-workspace-aware-doctor-diagnostics/PROMPT.md @@ -43,6 +43,15 @@ Upgrade taskplane doctor to validate workspace-mode topology (non-git root, mapp - **Workspace:** Taskplane extension and dashboard codebase - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope > The orchestrator uses this to avoid merge conflicts: tasks with overlapping diff --git a/taskplane-tasks/TP-009-dashboard-repo-aware-observability/PROMPT.md b/taskplane-tasks/TP-009-dashboard-repo-aware-observability/PROMPT.md index 21d4b733..f30db5df 100644 --- a/taskplane-tasks/TP-009-dashboard-repo-aware-observability/PROMPT.md +++ b/taskplane-tasks/TP-009-dashboard-repo-aware-observability/PROMPT.md @@ -43,6 +43,15 @@ Make orchestrator observability repo-aware so operators in large teams can quick - **Workspace:** Taskplane extension and dashboard codebase - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope > The orchestrator uses this to avoid merge conflicts: tasks with overlapping diff --git a/taskplane-tasks/TP-010-team-scale-session-and-worktree-naming/PROMPT.md b/taskplane-tasks/TP-010-team-scale-session-and-worktree-naming/PROMPT.md index 253ca9c8..661109de 100644 --- a/taskplane-tasks/TP-010-team-scale-session-and-worktree-naming/PROMPT.md +++ b/taskplane-tasks/TP-010-team-scale-session-and-worktree-naming/PROMPT.md @@ -43,6 +43,15 @@ Implement collision-resistant lane/session/worktree naming that remains determin - **Workspace:** Taskplane extension and dashboard codebase - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope > The orchestrator uses this to avoid merge conflicts: tasks with overlapping diff --git a/taskplane-tasks/TP-011-routing-ownership-enforcement/PROMPT.md b/taskplane-tasks/TP-011-routing-ownership-enforcement/PROMPT.md index c8d2d62f..4b208276 100644 --- a/taskplane-tasks/TP-011-routing-ownership-enforcement/PROMPT.md +++ b/taskplane-tasks/TP-011-routing-ownership-enforcement/PROMPT.md @@ -43,6 +43,15 @@ Add policy controls to enforce task ownership clarity in workspace mode, reducin - **Workspace:** Taskplane extension and dashboard codebase - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope > The orchestrator uses this to avoid merge conflicts: tasks with overlapping diff --git a/taskplane-tasks/TP-012-polyrepo-fixtures-and-regression-suite/PROMPT.md b/taskplane-tasks/TP-012-polyrepo-fixtures-and-regression-suite/PROMPT.md index 50bb2882..518c5374 100644 --- a/taskplane-tasks/TP-012-polyrepo-fixtures-and-regression-suite/PROMPT.md +++ b/taskplane-tasks/TP-012-polyrepo-fixtures-and-regression-suite/PROMPT.md @@ -47,6 +47,15 @@ Build an integration-grade polyrepo fixture and automated regression suite that - **Workspace:** Taskplane extension and dashboard codebase - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope > The orchestrator uses this to avoid merge conflicts: tasks with overlapping diff --git a/taskplane-tasks/TP-013-dashboard-eye-icon-contrast/PROMPT.md b/taskplane-tasks/TP-013-dashboard-eye-icon-contrast/PROMPT.md index 2617805f..9f676276 100644 --- a/taskplane-tasks/TP-013-dashboard-eye-icon-contrast/PROMPT.md +++ b/taskplane-tasks/TP-013-dashboard-eye-icon-contrast/PROMPT.md @@ -41,6 +41,15 @@ Closes [#16](https://github.com/HenryLach/taskplane/issues/16) - **Workspace:** Dashboard frontend - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `dashboard/public/style.css` diff --git a/taskplane-tasks/TP-014-json-config-schema-and-loader/PROMPT.md b/taskplane-tasks/TP-014-json-config-schema-and-loader/PROMPT.md index 06a0197d..c053d6ca 100644 --- a/taskplane-tasks/TP-014-json-config-schema-and-loader/PROMPT.md +++ b/taskplane-tasks/TP-014-json-config-schema-and-loader/PROMPT.md @@ -47,6 +47,15 @@ See spec: `.pi/local/docs/settings-and-onboarding-spec.md` — Layer 1 (Project - **Workspace:** `extensions/taskplane/`, `extensions/task-runner.ts` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/types.ts` diff --git a/taskplane-tasks/TP-015-init-v2-mode-detection-and-gitignore/PROMPT.md b/taskplane-tasks/TP-015-init-v2-mode-detection-and-gitignore/PROMPT.md index 2ba9fcd2..d92d4515 100644 --- a/taskplane-tasks/TP-015-init-v2-mode-detection-and-gitignore/PROMPT.md +++ b/taskplane-tasks/TP-015-init-v2-mode-detection-and-gitignore/PROMPT.md @@ -41,6 +41,15 @@ See spec: `.pi/local/docs/settings-and-onboarding-spec.md` — Mode auto-detecti - **Workspace:** `bin/taskplane.mjs` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `bin/taskplane.mjs` diff --git a/taskplane-tasks/TP-016-pointer-file-resolution-chain/PROMPT.md b/taskplane-tasks/TP-016-pointer-file-resolution-chain/PROMPT.md index 30d87191..15af11b5 100644 --- a/taskplane-tasks/TP-016-pointer-file-resolution-chain/PROMPT.md +++ b/taskplane-tasks/TP-016-pointer-file-resolution-chain/PROMPT.md @@ -42,6 +42,15 @@ See spec: `.pi/local/docs/settings-and-onboarding-spec.md` — Resolved Decision - **Workspace:** `extensions/taskplane/`, `extensions/task-runner.ts`, `dashboard/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/workspace.ts` diff --git a/taskplane-tasks/TP-017-user-preferences-layer/PROMPT.md b/taskplane-tasks/TP-017-user-preferences-layer/PROMPT.md index 423846a2..11be6c6f 100644 --- a/taskplane-tasks/TP-017-user-preferences-layer/PROMPT.md +++ b/taskplane-tasks/TP-017-user-preferences-layer/PROMPT.md @@ -41,6 +41,15 @@ See spec: `.pi/local/docs/settings-and-onboarding-spec.md` — Layer 2 (User con - **Workspace:** `extensions/taskplane/`, `extensions/task-runner.ts` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/config.ts` diff --git a/taskplane-tasks/TP-018-settings-tui-command/PROMPT.md b/taskplane-tasks/TP-018-settings-tui-command/PROMPT.md index 32efca47..ce41edc9 100644 --- a/taskplane-tasks/TP-018-settings-tui-command/PROMPT.md +++ b/taskplane-tasks/TP-018-settings-tui-command/PROMPT.md @@ -45,6 +45,15 @@ See spec: `.pi/local/docs/settings-and-onboarding-spec.md` — Core principle #4 - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/extension.ts` diff --git a/taskplane-tasks/TP-019-doctor-enhancements-gitignore-and-workspace/PROMPT.md b/taskplane-tasks/TP-019-doctor-enhancements-gitignore-and-workspace/PROMPT.md index 46efe560..4a89d1f6 100644 --- a/taskplane-tasks/TP-019-doctor-enhancements-gitignore-and-workspace/PROMPT.md +++ b/taskplane-tasks/TP-019-doctor-enhancements-gitignore-and-workspace/PROMPT.md @@ -42,6 +42,15 @@ See spec: `.pi/local/docs/settings-and-onboarding-spec.md` — Git tracking rule - **Workspace:** `bin/taskplane.mjs` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `bin/taskplane.mjs` diff --git a/taskplane-tasks/TP-020-orch-managed-branch-schema/PROMPT.md b/taskplane-tasks/TP-020-orch-managed-branch-schema/PROMPT.md index 29a26a24..515f8690 100644 --- a/taskplane-tasks/TP-020-orch-managed-branch-schema/PROMPT.md +++ b/taskplane-tasks/TP-020-orch-managed-branch-schema/PROMPT.md @@ -45,6 +45,15 @@ The managed branch model will have the orchestrator create an ephemeral `orch/{o - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/types.ts` diff --git a/taskplane-tasks/TP-021-batch-scoped-worktree-containers/PROMPT.md b/taskplane-tasks/TP-021-batch-scoped-worktree-containers/PROMPT.md index 23dc49bb..304fbd62 100644 --- a/taskplane-tasks/TP-021-batch-scoped-worktree-containers/PROMPT.md +++ b/taskplane-tasks/TP-021-batch-scoped-worktree-containers/PROMPT.md @@ -45,6 +45,15 @@ New scheme: `{basePath}/{opId}-{batchId}/lane-{N}` with a merge worktree at `{ba - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/worktree.ts` diff --git a/taskplane-tasks/TP-022-orch-branch-lifecycle-merge-redirect/PROMPT.md b/taskplane-tasks/TP-022-orch-branch-lifecycle-merge-redirect/PROMPT.md index f9eef4f4..ad3d34e9 100644 --- a/taskplane-tasks/TP-022-orch-branch-lifecycle-merge-redirect/PROMPT.md +++ b/taskplane-tasks/TP-022-orch-branch-lifecycle-merge-redirect/PROMPT.md @@ -46,6 +46,15 @@ After this task, the merge step no longer fast-forwards the user's branch. Inste - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/engine.ts` diff --git a/taskplane-tasks/TP-023-orch-integrate-command/PROMPT.md b/taskplane-tasks/TP-023-orch-integrate-command/PROMPT.md index 0f21c26d..b4fedc7e 100644 --- a/taskplane-tasks/TP-023-orch-integrate-command/PROMPT.md +++ b/taskplane-tasks/TP-023-orch-integrate-command/PROMPT.md @@ -45,6 +45,15 @@ Three modes: fast-forward (default), real merge (`--merge`), and PR (`--pr`). A - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/extension.ts` diff --git a/taskplane-tasks/TP-024-orch-managed-branch-docs/PROMPT.md b/taskplane-tasks/TP-024-orch-managed-branch-docs/PROMPT.md index 349a8d33..12a950fa 100644 --- a/taskplane-tasks/TP-024-orch-managed-branch-docs/PROMPT.md +++ b/taskplane-tasks/TP-024-orch-managed-branch-docs/PROMPT.md @@ -42,6 +42,15 @@ Update all user-facing documentation to reflect the orchestrator-managed branch - **Workspace:** `docs/`, root - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `docs/reference/commands.md` diff --git a/taskplane-tasks/TP-025-rpc-wrapper-and-exit-classification/PROMPT.md b/taskplane-tasks/TP-025-rpc-wrapper-and-exit-classification/PROMPT.md index 45103372..14300df9 100644 --- a/taskplane-tasks/TP-025-rpc-wrapper-and-exit-classification/PROMPT.md +++ b/taskplane-tasks/TP-025-rpc-wrapper-and-exit-classification/PROMPT.md @@ -51,6 +51,15 @@ exit summary JSON on process exit. It also displays minimal progress in stdout - **Workspace:** `bin/` (wrapper script), `extensions/taskplane/` (types) - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `bin/rpc-wrapper.mjs` (new) diff --git a/taskplane-tasks/TP-026-task-runner-rpc-integration/PROMPT.md b/taskplane-tasks/TP-026-task-runner-rpc-integration/PROMPT.md index 89949251..1c1e3abe 100644 --- a/taskplane-tasks/TP-026-task-runner-rpc-integration/PROMPT.md +++ b/taskplane-tasks/TP-026-task-runner-rpc-integration/PROMPT.md @@ -50,6 +50,15 @@ exit classification. - **Workspace:** `extensions/` - **Services required:** tmux + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/task-runner.ts` diff --git a/taskplane-tasks/TP-027-dashboard-telemetry/PROMPT.md b/taskplane-tasks/TP-027-dashboard-telemetry/PROMPT.md index 24ae3170..9bf94c39 100644 --- a/taskplane-tasks/TP-027-dashboard-telemetry/PROMPT.md +++ b/taskplane-tasks/TP-027-dashboard-telemetry/PROMPT.md @@ -45,6 +45,15 @@ flows from sidecar files through the dashboard server to the frontend. - **Workspace:** `dashboard/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `dashboard/server.cjs` diff --git a/taskplane-tasks/TP-028-partial-progress-preservation/PROMPT.md b/taskplane-tasks/TP-028-partial-progress-preservation/PROMPT.md index 3c79bc90..a6c7e963 100644 --- a/taskplane-tasks/TP-028-partial-progress-preservation/PROMPT.md +++ b/taskplane-tasks/TP-028-partial-progress-preservation/PROMPT.md @@ -46,6 +46,15 @@ in both repo mode and workspace mode. - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/worktree.ts` diff --git a/taskplane-tasks/TP-029-cleanup-resilience-and-gate/PROMPT.md b/taskplane-tasks/TP-029-cleanup-resilience-and-gate/PROMPT.md index 6c4b9d75..0d141c46 100644 --- a/taskplane-tasks/TP-029-cleanup-resilience-and-gate/PROMPT.md +++ b/taskplane-tasks/TP-029-cleanup-resilience-and-gate/PROMPT.md @@ -47,6 +47,15 @@ worktrees, lane branches, or stale autostashes remain in any workspace repo. - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/worktree.ts` diff --git a/taskplane-tasks/TP-030-state-schema-v3-migration/PROMPT.md b/taskplane-tasks/TP-030-state-schema-v3-migration/PROMPT.md index 6ec6ffe2..44e231f7 100644 --- a/taskplane-tasks/TP-030-state-schema-v3-migration/PROMPT.md +++ b/taskplane-tasks/TP-030-state-schema-v3-migration/PROMPT.md @@ -44,6 +44,15 @@ Ensure new runtime resumes v1/v2 states. Handle corrupt state by entering - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/types.ts` diff --git a/taskplane-tasks/TP-031-force-resume-and-diagnostics/PROMPT.md b/taskplane-tasks/TP-031-force-resume-and-diagnostics/PROMPT.md index 8a77f06f..f7d2a92d 100644 --- a/taskplane-tasks/TP-031-force-resume-and-diagnostics/PROMPT.md +++ b/taskplane-tasks/TP-031-force-resume-and-diagnostics/PROMPT.md @@ -42,6 +42,15 @@ on batch completion/failure. - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/resume.ts` diff --git a/taskplane-tasks/TP-032-verification-baseline-fingerprinting/PROMPT.md b/taskplane-tasks/TP-032-verification-baseline-fingerprinting/PROMPT.md index 9ffd8363..4a9876f1 100644 --- a/taskplane-tasks/TP-032-verification-baseline-fingerprinting/PROMPT.md +++ b/taskplane-tasks/TP-032-verification-baseline-fingerprinting/PROMPT.md @@ -42,6 +42,15 @@ strict/permissive modes, and per-repo baselines in workspace mode. - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/verification.ts` (new) diff --git a/taskplane-tasks/TP-033-transactional-merge-and-retry/PROMPT.md b/taskplane-tasks/TP-033-transactional-merge-and-retry/PROMPT.md index 452eb972..0d189df7 100644 --- a/taskplane-tasks/TP-033-transactional-merge-and-retry/PROMPT.md +++ b/taskplane-tasks/TP-033-transactional-merge-and-retry/PROMPT.md @@ -43,6 +43,15 @@ Add wave gate: `cleanup_post_merge_failed` blocks next wave. - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/merge.ts` diff --git a/taskplane-tasks/TP-034-quality-gate-structured-review/PROMPT.md b/taskplane-tasks/TP-034-quality-gate-structured-review/PROMPT.md index 4989b0cd..dca83224 100644 --- a/taskplane-tasks/TP-034-quality-gate-structured-review/PROMPT.md +++ b/taskplane-tasks/TP-034-quality-gate-structured-review/PROMPT.md @@ -42,6 +42,15 @@ behavior is unchanged. - **Workspace:** `extensions/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/task-runner.ts` diff --git a/taskplane-tasks/TP-035-status-reconciliation-remediation/PROMPT.md b/taskplane-tasks/TP-035-status-reconciliation-remediation/PROMPT.md index acb72610..2cfd4045 100644 --- a/taskplane-tasks/TP-035-status-reconciliation-remediation/PROMPT.md +++ b/taskplane-tasks/TP-035-status-reconciliation-remediation/PROMPT.md @@ -44,6 +44,15 @@ asked to check but don't own. - **Workspace:** `extensions/`, `templates/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/quality-gate.ts` diff --git a/taskplane-tasks/TP-036-skip-reviews-low-risk-steps/PROMPT.md b/taskplane-tasks/TP-036-skip-reviews-low-risk-steps/PROMPT.md index 4cbd4e4a..f70ed532 100644 --- a/taskplane-tasks/TP-036-skip-reviews-low-risk-steps/PROMPT.md +++ b/taskplane-tasks/TP-036-skip-reviews-low-risk-steps/PROMPT.md @@ -48,6 +48,15 @@ at current review durations). - **Workspace:** `extensions/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/task-runner.ts` diff --git a/taskplane-tasks/TP-037-resume-bug-fixes/PROMPT.md b/taskplane-tasks/TP-037-resume-bug-fixes/PROMPT.md index fb99027f..1c398902 100644 --- a/taskplane-tasks/TP-037-resume-bug-fixes/PROMPT.md +++ b/taskplane-tasks/TP-037-resume-bug-fixes/PROMPT.md @@ -54,6 +54,15 @@ aligns with `currentWaveIndex` before advancing. - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/resume.ts` diff --git a/taskplane-tasks/TP-038-merge-timeout-resilience/PROMPT.md b/taskplane-tasks/TP-038-merge-timeout-resilience/PROMPT.md index 389923e7..42f76bb4 100644 --- a/taskplane-tasks/TP-038-merge-timeout-resilience/PROMPT.md +++ b/taskplane-tasks/TP-038-merge-timeout-resilience/PROMPT.md @@ -52,6 +52,15 @@ a configured maximum of 2 retries). - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/merge.ts` diff --git a/taskplane-tasks/TP-039-tier0-watchdog-integration/PROMPT.md b/taskplane-tasks/TP-039-tier0-watchdog-integration/PROMPT.md index 1ec73e1d..d6211baf 100644 --- a/taskplane-tasks/TP-039-tier0-watchdog-integration/PROMPT.md +++ b/taskplane-tasks/TP-039-tier0-watchdog-integration/PROMPT.md @@ -48,6 +48,15 @@ supervisor agent. - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/engine.ts` diff --git a/taskplane-tasks/TP-040-non-blocking-engine/PROMPT.md b/taskplane-tasks/TP-040-non-blocking-engine/PROMPT.md index 84cabb70..4353cf8a 100644 --- a/taskplane-tasks/TP-040-non-blocking-engine/PROMPT.md +++ b/taskplane-tasks/TP-040-non-blocking-engine/PROMPT.md @@ -50,6 +50,15 @@ flow changes. - **Workspace:** `extensions/taskplane/` - **Services required:** tmux + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/engine.ts` diff --git a/taskplane-tasks/TP-041-supervisor-agent/PROMPT.md b/taskplane-tasks/TP-041-supervisor-agent/PROMPT.md index d0cfe445..183af392 100644 --- a/taskplane-tasks/TP-041-supervisor-agent/PROMPT.md +++ b/taskplane-tasks/TP-041-supervisor-agent/PROMPT.md @@ -53,6 +53,15 @@ Key components: - **Workspace:** `extensions/taskplane/` - **Services required:** tmux + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/extension.ts` diff --git a/taskplane-tasks/TP-042-supervisor-onboarding/PROMPT.md b/taskplane-tasks/TP-042-supervisor-onboarding/PROMPT.md index 54f2c63d..00f7ce94 100644 --- a/taskplane-tasks/TP-042-supervisor-onboarding/PROMPT.md +++ b/taskplane-tasks/TP-042-supervisor-onboarding/PROMPT.md @@ -52,6 +52,15 @@ guides that the supervisor follows during first-time setup. - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/extension.ts` diff --git a/taskplane-tasks/TP-043-auto-integration-batch-summary/PROMPT.md b/taskplane-tasks/TP-043-auto-integration-batch-summary/PROMPT.md index 689ef075..814a774f 100644 --- a/taskplane-tasks/TP-043-auto-integration-batch-summary/PROMPT.md +++ b/taskplane-tasks/TP-043-auto-integration-batch-summary/PROMPT.md @@ -50,6 +50,15 @@ Three integration modes configured during onboarding or via settings: - **Workspace:** `extensions/taskplane/` - **Services required:** git, gh CLI (for PR creation) + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/supervisor.ts` diff --git a/taskplane-tasks/TP-044-dashboard-supervisor-panel/PROMPT.md b/taskplane-tasks/TP-044-dashboard-supervisor-panel/PROMPT.md index eceb894a..580fc978 100644 --- a/taskplane-tasks/TP-044-dashboard-supervisor-panel/PROMPT.md +++ b/taskplane-tasks/TP-044-dashboard-supervisor-panel/PROMPT.md @@ -51,6 +51,15 @@ supervisor panel adds: - **Workspace:** `dashboard/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `dashboard/server.cjs` diff --git a/taskplane-tasks/TP-045-dashboard-wave-bar-color/PROMPT.md b/taskplane-tasks/TP-045-dashboard-wave-bar-color/PROMPT.md index 3ffdc177..7ded83c1 100644 --- a/taskplane-tasks/TP-045-dashboard-wave-bar-color/PROMPT.md +++ b/taskplane-tasks/TP-045-dashboard-wave-bar-color/PROMPT.md @@ -45,6 +45,15 @@ None. - **Workspace:** `dashboard/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `dashboard/public/app.js` diff --git a/taskplane-tasks/TP-046-async-merge-polling/PROMPT.md b/taskplane-tasks/TP-046-async-merge-polling/PROMPT.md index 0f2f2f58..46047094 100644 --- a/taskplane-tasks/TP-046-async-merge-polling/PROMPT.md +++ b/taskplane-tasks/TP-046-async-merge-polling/PROMPT.md @@ -51,6 +51,15 @@ None. - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/merge.ts` diff --git a/taskplane-tasks/TP-047-context-window-auto-detect/PROMPT.md b/taskplane-tasks/TP-047-context-window-auto-detect/PROMPT.md index a0e6ee01..3b31be33 100644 --- a/taskplane-tasks/TP-047-context-window-auto-detect/PROMPT.md +++ b/taskplane-tasks/TP-047-context-window-auto-detect/PROMPT.md @@ -49,6 +49,15 @@ window. - **Workspace:** `extensions/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/task-runner.ts` diff --git a/taskplane-tasks/TP-048-persistent-worker-context/PROMPT.md b/taskplane-tasks/TP-048-persistent-worker-context/PROMPT.md index 4bdc62b8..2e7195e4 100644 --- a/taskplane-tasks/TP-048-persistent-worker-context/PROMPT.md +++ b/taskplane-tasks/TP-048-persistent-worker-context/PROMPT.md @@ -52,6 +52,15 @@ STATUS.md — same recovery mechanism as today, just triggered far less often. - **Workspace:** `extensions/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/task-runner.ts` diff --git a/taskplane-tasks/TP-049-orch-rpc-telemetry/PROMPT.md b/taskplane-tasks/TP-049-orch-rpc-telemetry/PROMPT.md index fb5bc5e6..118dd15b 100644 --- a/taskplane-tasks/TP-049-orch-rpc-telemetry/PROMPT.md +++ b/taskplane-tasks/TP-049-orch-rpc-telemetry/PROMPT.md @@ -50,6 +50,15 @@ telemetry files. The dashboard already has infrastructure to consume - **Workspace:** `extensions/`, `dashboard/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/execution.ts` diff --git a/taskplane-tasks/TP-050-worker-driven-inline-reviews/PROMPT.md b/taskplane-tasks/TP-050-worker-driven-inline-reviews/PROMPT.md index 76f22ed0..efa3551f 100644 --- a/taskplane-tasks/TP-050-worker-driven-inline-reviews/PROMPT.md +++ b/taskplane-tasks/TP-050-worker-driven-inline-reviews/PROMPT.md @@ -54,6 +54,15 @@ live reviewer activity so the UI doesn't appear frozen during reviews. - **Workspace:** `extensions/`, `dashboard/`, `templates/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/task-runner.ts` diff --git a/taskplane-tasks/TP-051-bug-stale-branches-and-timestamps/PROMPT.md b/taskplane-tasks/TP-051-bug-stale-branches-and-timestamps/PROMPT.md index d20dad56..7f2e3208 100644 --- a/taskplane-tasks/TP-051-bug-stale-branches-and-timestamps/PROMPT.md +++ b/taskplane-tasks/TP-051-bug-stale-branches-and-timestamps/PROMPT.md @@ -53,6 +53,15 @@ Fix two bugs that degrade the operator experience after every batch: - **Workspace:** `extensions/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/extension.ts` (integrate cleanup) diff --git a/taskplane-tasks/TP-052-ux-integrate-visibility-and-prompt/PROMPT.md b/taskplane-tasks/TP-052-ux-integrate-visibility-and-prompt/PROMPT.md index 80891ec2..93dcc7e8 100644 --- a/taskplane-tasks/TP-052-ux-integrate-visibility-and-prompt/PROMPT.md +++ b/taskplane-tasks/TP-052-ux-integrate-visibility-and-prompt/PROMPT.md @@ -53,6 +53,15 @@ Fix three UX issues that confuse operators after batch completion: - **Workspace:** `extensions/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/extension.ts` diff --git a/taskplane-tasks/TP-053-supervisor-orch-tools/PROMPT.md b/taskplane-tasks/TP-053-supervisor-orch-tools/PROMPT.md index 8a4756b7..555ded0f 100644 --- a/taskplane-tasks/TP-053-supervisor-orch-tools/PROMPT.md +++ b/taskplane-tasks/TP-053-supervisor-orch-tools/PROMPT.md @@ -54,6 +54,15 @@ This follows the pattern established by `review_step` in TP-050. - **Workspace:** `extensions/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/extension.ts` diff --git a/taskplane-tasks/TP-054-deprecate-task-command/PROMPT.md b/taskplane-tasks/TP-054-deprecate-task-command/PROMPT.md index 8847860f..a9eeabd5 100644 --- a/taskplane-tasks/TP-054-deprecate-task-command/PROMPT.md +++ b/taskplane-tasks/TP-054-deprecate-task-command/PROMPT.md @@ -41,6 +41,15 @@ Deprecate the `/task`, `/task-status`, `/task-pause`, and `/task-resume` command - **Workspace:** `extensions/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/task-runner.ts` diff --git a/taskplane-tasks/TP-055-runtime-model-fallback/PROMPT.md b/taskplane-tasks/TP-055-runtime-model-fallback/PROMPT.md index 4f703fd0..b47c74fc 100644 --- a/taskplane-tasks/TP-055-runtime-model-fallback/PROMPT.md +++ b/taskplane-tasks/TP-055-runtime-model-fallback/PROMPT.md @@ -43,6 +43,15 @@ When a configured agent model becomes unavailable mid-batch (API key expired, ra - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/execution.ts` diff --git a/taskplane-tasks/TP-056-supervisor-merge-monitoring/PROMPT.md b/taskplane-tasks/TP-056-supervisor-merge-monitoring/PROMPT.md index 4c34db1b..6ea79478 100644 --- a/taskplane-tasks/TP-056-supervisor-merge-monitoring/PROMPT.md +++ b/taskplane-tasks/TP-056-supervisor-merge-monitoring/PROMPT.md @@ -57,6 +57,15 @@ Implement active merge monitoring in the supervisor so stalled merge agents are - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/supervisor.ts` diff --git a/taskplane-tasks/TP-057-persistent-reviewer-context/PROMPT.md b/taskplane-tasks/TP-057-persistent-reviewer-context/PROMPT.md index bfc812bb..816d619a 100644 --- a/taskplane-tasks/TP-057-persistent-reviewer-context/PROMPT.md +++ b/taskplane-tasks/TP-057-persistent-reviewer-context/PROMPT.md @@ -47,6 +47,15 @@ This mirrors the persistent worker context model (TP-048) but for the reviewer s - **Workspace:** `extensions/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/task-runner.ts` diff --git a/taskplane-tasks/TP-058-supervisor-template-pattern/PROMPT.md b/taskplane-tasks/TP-058-supervisor-template-pattern/PROMPT.md index 973f104f..d6fc27eb 100644 --- a/taskplane-tasks/TP-058-supervisor-template-pattern/PROMPT.md +++ b/taskplane-tasks/TP-058-supervisor-template-pattern/PROMPT.md @@ -50,6 +50,15 @@ Dynamic data (batch metadata, autonomy level, wave counts, file paths) is still - **Workspace:** `extensions/taskplane/`, `templates/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `templates/agents/supervisor.md` (new) diff --git a/taskplane-tasks/TP-059-dashboard-bug-fixes/PROMPT.md b/taskplane-tasks/TP-059-dashboard-bug-fixes/PROMPT.md index 77ccd201..807fc1ea 100644 --- a/taskplane-tasks/TP-059-dashboard-bug-fixes/PROMPT.md +++ b/taskplane-tasks/TP-059-dashboard-bug-fixes/PROMPT.md @@ -45,6 +45,15 @@ Fix three small dashboard/formatting bugs discovered during the v0.12.0–v0.14. - **Workspace:** `extensions/`, `dashboard/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/formatting.ts` diff --git a/taskplane-tasks/TP-060-targeted-test-execution/PROMPT.md b/taskplane-tasks/TP-060-targeted-test-execution/PROMPT.md index 94782507..ee979a83 100644 --- a/taskplane-tasks/TP-060-targeted-test-execution/PROMPT.md +++ b/taskplane-tasks/TP-060-targeted-test-execution/PROMPT.md @@ -68,6 +68,15 @@ This gives workers fast feedback loops without sacrificing the full-suite safety - **Workspace:** `templates/`, `skills/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `templates/agents/task-worker.md` diff --git a/taskplane-tasks/TP-061-orch-start-tool/PROMPT.md b/taskplane-tasks/TP-061-orch-start-tool/PROMPT.md index 4f602819..4b9ca159 100644 --- a/taskplane-tasks/TP-061-orch-start-tool/PROMPT.md +++ b/taskplane-tasks/TP-061-orch-start-tool/PROMPT.md @@ -40,6 +40,15 @@ The supervisor has tools for managing batches (`orch_status`, `orch_resume`, `or - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/extension.ts` diff --git a/taskplane-tasks/TP-062-status-step-display-fix/PROMPT.md b/taskplane-tasks/TP-062-status-step-display-fix/PROMPT.md index 0d1977a0..6c80bad6 100644 --- a/taskplane-tasks/TP-062-status-step-display-fix/PROMPT.md +++ b/taskplane-tasks/TP-062-status-step-display-fix/PROMPT.md @@ -39,6 +39,15 @@ Fix a bug where STATUS.md shows all incomplete steps as "🟨 In Progress" inste - **Workspace:** `extensions/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/task-runner.ts` diff --git a/taskplane-tasks/TP-063-upgrade-migrations-on-orch/PROMPT.md b/taskplane-tasks/TP-063-upgrade-migrations-on-orch/PROMPT.md index efa5fea6..f3a3cf48 100644 --- a/taskplane-tasks/TP-063-upgrade-migrations-on-orch/PROMPT.md +++ b/taskplane-tasks/TP-063-upgrade-migrations-on-orch/PROMPT.md @@ -51,6 +51,15 @@ Implement an additive migration mechanism that runs automatically when extension - **Workspace:** `extensions/taskplane/`, `templates/agents/local/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/extension.ts` diff --git a/taskplane-tasks/TP-064-dashboard-telemetry-crash/PROMPT.md b/taskplane-tasks/TP-064-dashboard-telemetry-crash/PROMPT.md index 5219a0d0..aa087c60 100644 --- a/taskplane-tasks/TP-064-dashboard-telemetry-crash/PROMPT.md +++ b/taskplane-tasks/TP-064-dashboard-telemetry-crash/PROMPT.md @@ -47,6 +47,15 @@ const chunk = tailState.partial + buf.toString('utf-8'); // 💥 ERR_STRING_TOO - **Workspace:** `dashboard/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `dashboard/server.cjs` diff --git a/taskplane-tasks/TP-065-artifact-cleanup-and-rotation/PROMPT.md b/taskplane-tasks/TP-065-artifact-cleanup-and-rotation/PROMPT.md index 930bb828..7cf79b88 100644 --- a/taskplane-tasks/TP-065-artifact-cleanup-and-rotation/PROMPT.md +++ b/taskplane-tasks/TP-065-artifact-cleanup-and-rotation/PROMPT.md @@ -52,6 +52,15 @@ Implement a multi-layer cleanup strategy so no single failure path leads to unbo - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/extension.ts` diff --git a/taskplane-tasks/TP-066-context-pressure-fix/PROMPT.md b/taskplane-tasks/TP-066-context-pressure-fix/PROMPT.md index 305d08e6..a8082fbd 100644 --- a/taskplane-tasks/TP-066-context-pressure-fix/PROMPT.md +++ b/taskplane-tasks/TP-066-context-pressure-fix/PROMPT.md @@ -56,6 +56,15 @@ Dashboard showed ~13% context throughout — wildly inaccurate - **Workspace:** `extensions/`, `templates/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/task-runner.ts` diff --git a/taskplane-tasks/TP-067-merge-telemetry-key-fix/PROMPT.md b/taskplane-tasks/TP-067-merge-telemetry-key-fix/PROMPT.md index 2367636c..8f6d510d 100644 --- a/taskplane-tasks/TP-067-merge-telemetry-key-fix/PROMPT.md +++ b/taskplane-tasks/TP-067-merge-telemetry-key-fix/PROMPT.md @@ -45,6 +45,15 @@ The server's `parseTelemetryFilename()` builds merge telemetry keys as `orch-mer - **Workspace:** `dashboard/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `dashboard/server.cjs` diff --git a/taskplane-tasks/TP-068-persistent-reviewer-reliability/PROMPT.md b/taskplane-tasks/TP-068-persistent-reviewer-reliability/PROMPT.md index ece8af77..ad006e63 100644 --- a/taskplane-tasks/TP-068-persistent-reviewer-reliability/PROMPT.md +++ b/taskplane-tasks/TP-068-persistent-reviewer-reliability/PROMPT.md @@ -56,6 +56,15 @@ Implement three layers of defense: - **Workspace:** `extensions/`, `templates/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `templates/agents/task-reviewer.md` diff --git a/taskplane-tasks/TP-069-verdict-extraction-cleanup/PROMPT.md b/taskplane-tasks/TP-069-verdict-extraction-cleanup/PROMPT.md index e17cdaa8..3d6e8892 100644 --- a/taskplane-tasks/TP-069-verdict-extraction-cleanup/PROMPT.md +++ b/taskplane-tasks/TP-069-verdict-extraction-cleanup/PROMPT.md @@ -39,6 +39,15 @@ The `review_step` tool handler in `task-runner.ts` has two nearly identical verd - **Workspace:** `extensions/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/task-runner.ts` diff --git a/taskplane-tasks/TP-070-async-io-and-dashboard-fork/PROMPT.md b/taskplane-tasks/TP-070-async-io-and-dashboard-fork/PROMPT.md index cb2c5123..a08e9818 100644 --- a/taskplane-tasks/TP-070-async-io-and-dashboard-fork/PROMPT.md +++ b/taskplane-tasks/TP-070-async-io-and-dashboard-fork/PROMPT.md @@ -57,6 +57,15 @@ Convert all polling-path I/O to async, and move the dashboard server to a child - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/execution.ts` diff --git a/taskplane-tasks/TP-071-engine-worker-thread/PROMPT.md b/taskplane-tasks/TP-071-engine-worker-thread/PROMPT.md index cdc00139..6911be7d 100644 --- a/taskplane-tasks/TP-071-engine-worker-thread/PROMPT.md +++ b/taskplane-tasks/TP-071-engine-worker-thread/PROMPT.md @@ -70,6 +70,15 @@ Worker thread (engine): - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/engine-worker.ts` (new — worker thread entry point) diff --git a/taskplane-tasks/TP-072-dashboard-light-mode/PROMPT.md b/taskplane-tasks/TP-072-dashboard-light-mode/PROMPT.md index c491cdcc..dd83ffa1 100644 --- a/taskplane-tasks/TP-072-dashboard-light-mode/PROMPT.md +++ b/taskplane-tasks/TP-072-dashboard-light-mode/PROMPT.md @@ -42,6 +42,15 @@ Create a light-mode theme for the dashboard and add a sun/moon toggle in the hea - **Workspace:** `dashboard/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `dashboard/public/style.css` diff --git a/taskplane-tasks/TP-073-worker-incomplete-exit-nudge/PROMPT.md b/taskplane-tasks/TP-073-worker-incomplete-exit-nudge/PROMPT.md index 7c38374e..194091eb 100644 --- a/taskplane-tasks/TP-073-worker-incomplete-exit-nudge/PROMPT.md +++ b/taskplane-tasks/TP-073-worker-incomplete-exit-nudge/PROMPT.md @@ -47,6 +47,15 @@ This complements the worker template fix (PR #243) by providing iteration-specif - **Workspace:** `extensions/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/task-runner.ts` diff --git a/taskplane-tasks/TP-074-node-test-bulk-migration/PROMPT.md b/taskplane-tasks/TP-074-node-test-bulk-migration/PROMPT.md index 2e308fa1..a21072d8 100644 --- a/taskplane-tasks/TP-074-node-test-bulk-migration/PROMPT.md +++ b/taskplane-tasks/TP-074-node-test-bulk-migration/PROMPT.md @@ -45,6 +45,15 @@ Migrate the 52 non-mock test files from vitest to Node.js native test runner (`n - **Workspace:** `extensions/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/tests/expect.ts` (new — expect() compatibility wrapper) diff --git a/taskplane-tasks/TP-075-node-test-mocks-cleanup/PROMPT.md b/taskplane-tasks/TP-075-node-test-mocks-cleanup/PROMPT.md index a3b8e3d0..cf22ee57 100644 --- a/taskplane-tasks/TP-075-node-test-mocks-cleanup/PROMPT.md +++ b/taskplane-tasks/TP-075-node-test-mocks-cleanup/PROMPT.md @@ -46,6 +46,15 @@ Complete the vitest → node:test migration by: - **Workspace:** `extensions/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/tests/diagnostic-reports.test.ts` diff --git a/taskplane-tasks/TP-076-autonomous-supervisor-alerts/PROMPT.md b/taskplane-tasks/TP-076-autonomous-supervisor-alerts/PROMPT.md index 10915693..e32b3290 100644 --- a/taskplane-tasks/TP-076-autonomous-supervisor-alerts/PROMPT.md +++ b/taskplane-tasks/TP-076-autonomous-supervisor-alerts/PROMPT.md @@ -48,6 +48,15 @@ This is Phase 1 of the autonomous supervisor spec (`docs/specifications/taskplan - **Workspace:** extensions/taskplane - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/engine-worker.ts` diff --git a/taskplane-tasks/TP-077-supervisor-retry-skip-tools/PROMPT.md b/taskplane-tasks/TP-077-supervisor-retry-skip-tools/PROMPT.md index d6be2f6a..c64bbafa 100644 --- a/taskplane-tasks/TP-077-supervisor-retry-skip-tools/PROMPT.md +++ b/taskplane-tasks/TP-077-supervisor-retry-skip-tools/PROMPT.md @@ -50,6 +50,15 @@ These are Phase 2 tools from the autonomous supervisor spec (`docs/specification - **Workspace:** extensions/taskplane - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/extension.ts` diff --git a/taskplane-tasks/TP-078-force-merge-and-recovery-playbooks/PROMPT.md b/taskplane-tasks/TP-078-force-merge-and-recovery-playbooks/PROMPT.md index 01b865d9..251eb22f 100644 --- a/taskplane-tasks/TP-078-force-merge-and-recovery-playbooks/PROMPT.md +++ b/taskplane-tasks/TP-078-force-merge-and-recovery-playbooks/PROMPT.md @@ -44,6 +44,15 @@ Complete Phase 2 of the autonomous supervisor by adding `orch_force_merge` (unbl - **Workspace:** extensions/taskplane - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/extension.ts` diff --git a/taskplane-tasks/TP-079-workspace-packet-home-contract-and-mode-enforcement/PROMPT.md b/taskplane-tasks/TP-079-workspace-packet-home-contract-and-mode-enforcement/PROMPT.md index 5f6417e8..09f68d0c 100644 --- a/taskplane-tasks/TP-079-workspace-packet-home-contract-and-mode-enforcement/PROMPT.md +++ b/taskplane-tasks/TP-079-workspace-packet-home-contract-and-mode-enforcement/PROMPT.md @@ -45,6 +45,15 @@ Implement the foundational workspace routing contract for multi-repo task execut - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/config-schema.ts` diff --git a/taskplane-tasks/TP-080-segment-model-and-explicit-dag-syntax/PROMPT.md b/taskplane-tasks/TP-080-segment-model-and-explicit-dag-syntax/PROMPT.md index d78c2de3..88441ae7 100644 --- a/taskplane-tasks/TP-080-segment-model-and-explicit-dag-syntax/PROMPT.md +++ b/taskplane-tasks/TP-080-segment-model-and-explicit-dag-syntax/PROMPT.md @@ -42,6 +42,15 @@ Introduce the v1 segment planning model for multi-repo task execution. Each task - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/types.ts` diff --git a/taskplane-tasks/TP-081-segment-graph-scheduler-and-state-schema-v4/PROMPT.md b/taskplane-tasks/TP-081-segment-graph-scheduler-and-state-schema-v4/PROMPT.md index 962f25b9..53e1f6df 100644 --- a/taskplane-tasks/TP-081-segment-graph-scheduler-and-state-schema-v4/PROMPT.md +++ b/taskplane-tasks/TP-081-segment-graph-scheduler-and-state-schema-v4/PROMPT.md @@ -42,6 +42,15 @@ Implement **schema v4** persisted-state contracts for segment execution and migr - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/types.ts` diff --git a/taskplane-tasks/TP-082-dual-context-segment-execution-and-packet-path-contract/PROMPT.md b/taskplane-tasks/TP-082-dual-context-segment-execution-and-packet-path-contract/PROMPT.md index cc2b04d0..8997a886 100644 --- a/taskplane-tasks/TP-082-dual-context-segment-execution-and-packet-path-contract/PROMPT.md +++ b/taskplane-tasks/TP-082-dual-context-segment-execution-and-packet-path-contract/PROMPT.md @@ -41,6 +41,15 @@ Implement the packet-path environment contract used by segment execution and mak - **Workspace:** `extensions/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/execution.ts` diff --git a/taskplane-tasks/TP-083-supervisor-segment-recovery-and-reordering/PROMPT.md b/taskplane-tasks/TP-083-supervisor-segment-recovery-and-reordering/PROMPT.md index bec579cd..d3a70995 100644 --- a/taskplane-tasks/TP-083-supervisor-segment-recovery-and-reordering/PROMPT.md +++ b/taskplane-tasks/TP-083-supervisor-segment-recovery-and-reordering/PROMPT.md @@ -45,6 +45,15 @@ Integrate segment-aware autonomous recovery with supervisor-controlled reorderin - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/types.ts` diff --git a/taskplane-tasks/TP-084-segment-observability-docs-and-polyrepo-acceptance/PROMPT.md b/taskplane-tasks/TP-084-segment-observability-docs-and-polyrepo-acceptance/PROMPT.md index 12b357e2..71a005f5 100644 --- a/taskplane-tasks/TP-084-segment-observability-docs-and-polyrepo-acceptance/PROMPT.md +++ b/taskplane-tasks/TP-084-segment-observability-docs-and-polyrepo-acceptance/PROMPT.md @@ -42,6 +42,15 @@ Complete the first implementation tranche for #51 by shipping segment-aware obse - **Workspace:** `dashboard/`, `extensions/taskplane/`, `docs/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `dashboard/server.cjs` diff --git a/taskplane-tasks/TP-085-segment-frontier-scheduler-and-resume/PROMPT.md b/taskplane-tasks/TP-085-segment-frontier-scheduler-and-resume/PROMPT.md index afd51715..dad8bd47 100644 --- a/taskplane-tasks/TP-085-segment-frontier-scheduler-and-resume/PROMPT.md +++ b/taskplane-tasks/TP-085-segment-frontier-scheduler-and-resume/PROMPT.md @@ -43,6 +43,15 @@ Implement segment-level frontier scheduling and resume reconstruction using sche - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/engine.ts` diff --git a/taskplane-tasks/TP-086-dynamic-segment-expansion-runtime/PROMPT.md b/taskplane-tasks/TP-086-dynamic-segment-expansion-runtime/PROMPT.md index bfa8a47c..50f21c21 100644 --- a/taskplane-tasks/TP-086-dynamic-segment-expansion-runtime/PROMPT.md +++ b/taskplane-tasks/TP-086-dynamic-segment-expansion-runtime/PROMPT.md @@ -45,6 +45,15 @@ Implement the runtime protocol for dynamic segment expansion requests and superv - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/types.ts` diff --git a/taskplane-tasks/TP-087-dynamic-segment-expansion-graph-mutation-and-resume/PROMPT.md b/taskplane-tasks/TP-087-dynamic-segment-expansion-graph-mutation-and-resume/PROMPT.md index 49437be0..7c94e503 100644 --- a/taskplane-tasks/TP-087-dynamic-segment-expansion-graph-mutation-and-resume/PROMPT.md +++ b/taskplane-tasks/TP-087-dynamic-segment-expansion-graph-mutation-and-resume/PROMPT.md @@ -45,6 +45,15 @@ Implement deterministic application of approved dynamic segment expansion decisi - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/engine.ts` diff --git a/taskplane-tasks/TP-088-engine-resume-packet-path-threading/PROMPT.md b/taskplane-tasks/TP-088-engine-resume-packet-path-threading/PROMPT.md index 9be33682..d93a1f90 100644 --- a/taskplane-tasks/TP-088-engine-resume-packet-path-threading/PROMPT.md +++ b/taskplane-tasks/TP-088-engine-resume-packet-path-threading/PROMPT.md @@ -43,6 +43,15 @@ Thread packet-path contract through orchestrator runtime and resume flows so com - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/engine.ts` diff --git a/taskplane-tasks/TP-089-mailbox-core-and-rpc-injection/PROMPT.md b/taskplane-tasks/TP-089-mailbox-core-and-rpc-injection/PROMPT.md index cae55dc7..f3c21325 100644 --- a/taskplane-tasks/TP-089-mailbox-core-and-rpc-injection/PROMPT.md +++ b/taskplane-tasks/TP-089-mailbox-core-and-rpc-injection/PROMPT.md @@ -43,6 +43,15 @@ Implement the core agent mailbox system (Phase 1 of the agent-mailbox-steering s - **Workspace:** `bin/`, `extensions/taskplane/`, `extensions/task-runner.ts` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `bin/rpc-wrapper.mjs` diff --git a/taskplane-tasks/TP-090-mailbox-worker-status-annotation/PROMPT.md b/taskplane-tasks/TP-090-mailbox-worker-status-annotation/PROMPT.md index 67d1ff37..1b3fa5ac 100644 --- a/taskplane-tasks/TP-090-mailbox-worker-status-annotation/PROMPT.md +++ b/taskplane-tasks/TP-090-mailbox-worker-status-annotation/PROMPT.md @@ -40,6 +40,15 @@ Add STATUS.md execution log annotation for delivered steering messages (Phase 2 - **Workspace:** `extensions/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/task-runner.ts` diff --git a/taskplane-tasks/TP-091-mailbox-agent-to-supervisor-replies/PROMPT.md b/taskplane-tasks/TP-091-mailbox-agent-to-supervisor-replies/PROMPT.md index ea256a47..0fba390b 100644 --- a/taskplane-tasks/TP-091-mailbox-agent-to-supervisor-replies/PROMPT.md +++ b/taskplane-tasks/TP-091-mailbox-agent-to-supervisor-replies/PROMPT.md @@ -41,6 +41,15 @@ Implement the agent→supervisor reply channel (Phase 3 of the agent-mailbox-ste - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/engine.ts` diff --git a/taskplane-tasks/TP-092-mailbox-broadcast-and-rate-limiting/PROMPT.md b/taskplane-tasks/TP-092-mailbox-broadcast-and-rate-limiting/PROMPT.md index 639e1c43..175ec1fe 100644 --- a/taskplane-tasks/TP-092-mailbox-broadcast-and-rate-limiting/PROMPT.md +++ b/taskplane-tasks/TP-092-mailbox-broadcast-and-rate-limiting/PROMPT.md @@ -40,6 +40,15 @@ Implement broadcast messaging and rate limiting (Phase 4 of the agent-mailbox-st - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/supervisor.ts` diff --git a/taskplane-tasks/TP-093-dashboard-mailbox-panel/PROMPT.md b/taskplane-tasks/TP-093-dashboard-mailbox-panel/PROMPT.md index e5a0a24b..66743f97 100644 --- a/taskplane-tasks/TP-093-dashboard-mailbox-panel/PROMPT.md +++ b/taskplane-tasks/TP-093-dashboard-mailbox-panel/PROMPT.md @@ -43,6 +43,15 @@ Add a "Messages" section to the dashboard showing per-agent mailbox activity (Ph - **Workspace:** `dashboard/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `dashboard/server.cjs` diff --git a/taskplane-tasks/TP-094-context-pressure-telemetry-fix/PROMPT.md b/taskplane-tasks/TP-094-context-pressure-telemetry-fix/PROMPT.md index 59d634b1..dd68c860 100644 --- a/taskplane-tasks/TP-094-context-pressure-telemetry-fix/PROMPT.md +++ b/taskplane-tasks/TP-094-context-pressure-telemetry-fix/PROMPT.md @@ -40,6 +40,15 @@ Fix the critical field name mismatch (#338) where pi sends `contextUsage.percent - **Workspace:** `extensions/`, `bin/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/task-runner.ts` diff --git a/taskplane-tasks/TP-095-crash-recovery-and-spawn-reliability/PROMPT.md b/taskplane-tasks/TP-095-crash-recovery-and-spawn-reliability/PROMPT.md index 86f75d4f..5fc149f6 100644 --- a/taskplane-tasks/TP-095-crash-recovery-and-spawn-reliability/PROMPT.md +++ b/taskplane-tasks/TP-095-crash-recovery-and-spawn-reliability/PROMPT.md @@ -49,6 +49,15 @@ Fix four related lifecycle bugs: - **Workspace:** `extensions/`, `bin/` - **Services required:** tmux (for manual verification) + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/task-runner.ts` diff --git a/taskplane-tasks/TP-096-dashboard-telemetry-and-supervisor-tools/PROMPT.md b/taskplane-tasks/TP-096-dashboard-telemetry-and-supervisor-tools/PROMPT.md index 545be7c8..2d8d536d 100644 --- a/taskplane-tasks/TP-096-dashboard-telemetry-and-supervisor-tools/PROMPT.md +++ b/taskplane-tasks/TP-096-dashboard-telemetry-and-supervisor-tools/PROMPT.md @@ -49,6 +49,15 @@ Two goals: - **Workspace:** `dashboard/`, `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `dashboard/server.cjs` diff --git a/taskplane-tasks/TP-097-stable-sidecar-and-tmux-lifecycle/PROMPT.md b/taskplane-tasks/TP-097-stable-sidecar-and-tmux-lifecycle/PROMPT.md index 00a71262..1d2de972 100644 --- a/taskplane-tasks/TP-097-stable-sidecar-and-tmux-lifecycle/PROMPT.md +++ b/taskplane-tasks/TP-097-stable-sidecar-and-tmux-lifecycle/PROMPT.md @@ -46,6 +46,15 @@ Fix three related tmux/telemetry bugs that share a root cause — the sidecar pa - **Workspace:** `extensions/`, `bin/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/task-runner.ts` diff --git a/taskplane-tasks/TP-098-dashboard-duplicate-log-fix/PROMPT.md b/taskplane-tasks/TP-098-dashboard-duplicate-log-fix/PROMPT.md index 723b72a7..9bf94fe5 100644 --- a/taskplane-tasks/TP-098-dashboard-duplicate-log-fix/PROMPT.md +++ b/taskplane-tasks/TP-098-dashboard-duplicate-log-fix/PROMPT.md @@ -46,6 +46,15 @@ Also fix the wiggum legacy cleanup (#251) since it's trivial and touches related - **Workspace:** `dashboard/`, `extensions/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `dashboard/public/app.js` diff --git a/taskplane-tasks/TP-099-integration-status-preservation/PROMPT.md b/taskplane-tasks/TP-099-integration-status-preservation/PROMPT.md index 91081be9..a4d910e3 100644 --- a/taskplane-tasks/TP-099-integration-status-preservation/PROMPT.md +++ b/taskplane-tasks/TP-099-integration-status-preservation/PROMPT.md @@ -45,6 +45,15 @@ Fix the integration flow so STATUS.md execution state (checked items, hydrated c - **Workspace:** `extensions/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/engine.ts` diff --git a/taskplane-tasks/TP-100-runtime-v2-planning-suite-and-backlog/PROMPT.md b/taskplane-tasks/TP-100-runtime-v2-planning-suite-and-backlog/PROMPT.md index 8ef23fbe..da329b1b 100644 --- a/taskplane-tasks/TP-100-runtime-v2-planning-suite-and-backlog/PROMPT.md +++ b/taskplane-tasks/TP-100-runtime-v2-planning-suite-and-backlog/PROMPT.md @@ -41,6 +41,15 @@ Create the authoritative Runtime V2 architecture suite under `docs/specification - **Workspace:** `docs/specifications/`, `taskplane-tasks/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `docs/specifications/framework/taskplane-runtime-v2/*` diff --git a/taskplane-tasks/TP-101-refresh-create-taskplane-task-skill-for-runtime-v2/PROMPT.md b/taskplane-tasks/TP-101-refresh-create-taskplane-task-skill-for-runtime-v2/PROMPT.md index c681d7de..3d8eba77 100644 --- a/taskplane-tasks/TP-101-refresh-create-taskplane-task-skill-for-runtime-v2/PROMPT.md +++ b/taskplane-tasks/TP-101-refresh-create-taskplane-task-skill-for-runtime-v2/PROMPT.md @@ -42,6 +42,15 @@ Update the bundled `create-taskplane-task` skill and its templates so task stagi - **Workspace:** `skills/`, `docs/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `skills/create-taskplane-task/SKILL.md` diff --git a/taskplane-tasks/TP-102-runtime-v2-execution-unit-and-packet-path-contracts/PROMPT.md b/taskplane-tasks/TP-102-runtime-v2-execution-unit-and-packet-path-contracts/PROMPT.md index 051d5dbc..29e39d34 100644 --- a/taskplane-tasks/TP-102-runtime-v2-execution-unit-and-packet-path-contracts/PROMPT.md +++ b/taskplane-tasks/TP-102-runtime-v2-execution-unit-and-packet-path-contracts/PROMPT.md @@ -42,6 +42,15 @@ Define the foundational Runtime V2 contracts in code: execution units, packet-pa - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/types.ts` diff --git a/taskplane-tasks/TP-103-extract-task-executor-core-from-task-runner/PROMPT.md b/taskplane-tasks/TP-103-extract-task-executor-core-from-task-runner/PROMPT.md index 4a23db50..033e8e2c 100644 --- a/taskplane-tasks/TP-103-extract-task-executor-core-from-task-runner/PROMPT.md +++ b/taskplane-tasks/TP-103-extract-task-executor-core-from-task-runner/PROMPT.md @@ -43,6 +43,15 @@ Extract the headless task execution state machine from `extensions/task-runner.t - **Workspace:** `extensions/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/task-runner.ts` diff --git a/taskplane-tasks/TP-104-direct-agent-host-process-registry-and-normalized-events/PROMPT.md b/taskplane-tasks/TP-104-direct-agent-host-process-registry-and-normalized-events/PROMPT.md index edcccee3..cc09c7d1 100644 --- a/taskplane-tasks/TP-104-direct-agent-host-process-registry-and-normalized-events/PROMPT.md +++ b/taskplane-tasks/TP-104-direct-agent-host-process-registry-and-normalized-events/PROMPT.md @@ -43,6 +43,15 @@ Implement the direct-child Runtime V2 agent host and process registry. Worker, r - **Workspace:** `bin/`, `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `bin/rpc-wrapper.mjs` diff --git a/taskplane-tasks/TP-105-headless-lane-runner-and-single-task-orch-v2/PROMPT.md b/taskplane-tasks/TP-105-headless-lane-runner-and-single-task-orch-v2/PROMPT.md index 4ae0acc9..3cafc549 100644 --- a/taskplane-tasks/TP-105-headless-lane-runner-and-single-task-orch-v2/PROMPT.md +++ b/taskplane-tasks/TP-105-headless-lane-runner-and-single-task-orch-v2/PROMPT.md @@ -44,6 +44,15 @@ Implement the headless `lane-runner` and route single-task `/orch ` e - **Workspace:** `extensions/taskplane/`, `extensions/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/lane-runner.ts` diff --git a/taskplane-tasks/TP-106-mailbox-replies-broadcast-and-registry-backed-supervisor-control/PROMPT.md b/taskplane-tasks/TP-106-mailbox-replies-broadcast-and-registry-backed-supervisor-control/PROMPT.md index 3b03ff5d..8b436769 100644 --- a/taskplane-tasks/TP-106-mailbox-replies-broadcast-and-registry-backed-supervisor-control/PROMPT.md +++ b/taskplane-tasks/TP-106-mailbox-replies-broadcast-and-registry-backed-supervisor-control/PROMPT.md @@ -43,6 +43,15 @@ Finish the mailbox-first control plane on Runtime V2: registry-backed supervisor - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/extension.ts` diff --git a/taskplane-tasks/TP-107-dashboard-runtime-v2-conversations-messages-and-agent-panel/PROMPT.md b/taskplane-tasks/TP-107-dashboard-runtime-v2-conversations-messages-and-agent-panel/PROMPT.md index c82b089c..11bafd22 100644 --- a/taskplane-tasks/TP-107-dashboard-runtime-v2-conversations-messages-and-agent-panel/PROMPT.md +++ b/taskplane-tasks/TP-107-dashboard-runtime-v2-conversations-messages-and-agent-panel/PROMPT.md @@ -44,6 +44,15 @@ Migrate the dashboard onto Runtime V2 artifacts so it becomes the authoritative - **Workspace:** `dashboard/`, `docs/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `dashboard/server.cjs` diff --git a/taskplane-tasks/TP-108-batch-and-merge-runtime-v2-migration/PROMPT.md b/taskplane-tasks/TP-108-batch-and-merge-runtime-v2-migration/PROMPT.md index 126def8a..3bec98c4 100644 --- a/taskplane-tasks/TP-108-batch-and-merge-runtime-v2-migration/PROMPT.md +++ b/taskplane-tasks/TP-108-batch-and-merge-runtime-v2-migration/PROMPT.md @@ -44,6 +44,15 @@ Migrate full batch execution and merge hosting onto Runtime V2. The engine shoul - **Workspace:** `extensions/taskplane/`, `bin/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/engine.ts` diff --git a/taskplane-tasks/TP-109-workspace-packet-home-and-resume-on-runtime-v2/PROMPT.md b/taskplane-tasks/TP-109-workspace-packet-home-and-resume-on-runtime-v2/PROMPT.md index 65cc2f62..029b86b2 100644 --- a/taskplane-tasks/TP-109-workspace-packet-home-and-resume-on-runtime-v2/PROMPT.md +++ b/taskplane-tasks/TP-109-workspace-packet-home-and-resume-on-runtime-v2/PROMPT.md @@ -44,6 +44,15 @@ Thread authoritative packet-home paths through Runtime V2 end-to-end and make wo - **Workspace:** `extensions/taskplane/`, `docs/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/engine.ts` diff --git a/taskplane-tasks/TP-110-runtime-v2-assumption-lab/PROMPT.md b/taskplane-tasks/TP-110-runtime-v2-assumption-lab/PROMPT.md index bd282741..6f4fd282 100644 --- a/taskplane-tasks/TP-110-runtime-v2-assumption-lab/PROMPT.md +++ b/taskplane-tasks/TP-110-runtime-v2-assumption-lab/PROMPT.md @@ -43,6 +43,15 @@ Validate the highest-risk Runtime V2 architectural assumptions outside the curre - **Workspace:** `scripts/`, `docs/specifications/framework/taskplane-runtime-v2/`, `taskplane-tasks/` - **Services required:** Local `pi` CLI must be available on PATH + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `scripts/runtime-v2-lab/*` diff --git a/taskplane-tasks/TP-111-runtime-v2-conversation-event-fidelity/PROMPT.md b/taskplane-tasks/TP-111-runtime-v2-conversation-event-fidelity/PROMPT.md index 54d1a9c7..3b273c63 100644 --- a/taskplane-tasks/TP-111-runtime-v2-conversation-event-fidelity/PROMPT.md +++ b/taskplane-tasks/TP-111-runtime-v2-conversation-event-fidelity/PROMPT.md @@ -53,6 +53,15 @@ Implement reliable normalized conversation emission from the Runtime V2 agent-ho - **Workspace:** `extensions/taskplane/`, `dashboard/`, `docs/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/agent-host.ts` diff --git a/taskplane-tasks/TP-112-runtime-v2-resume-and-monitor-detmux/PROMPT.md b/taskplane-tasks/TP-112-runtime-v2-resume-and-monitor-detmux/PROMPT.md index ade68ada..9416ee02 100644 --- a/taskplane-tasks/TP-112-runtime-v2-resume-and-monitor-detmux/PROMPT.md +++ b/taskplane-tasks/TP-112-runtime-v2-resume-and-monitor-detmux/PROMPT.md @@ -61,6 +61,15 @@ TP-112 must ensure Runtime V2 execution correctness does not depend on TMUX for: - **Workspace:** `extensions/taskplane/`, `docs/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/resume.ts` diff --git a/taskplane-tasks/TP-113-legacy-tmux-backend-deprecation-and-registry-only-operator-surface/PROMPT.md b/taskplane-tasks/TP-113-legacy-tmux-backend-deprecation-and-registry-only-operator-surface/PROMPT.md index f44f5926..9dc3523d 100644 --- a/taskplane-tasks/TP-113-legacy-tmux-backend-deprecation-and-registry-only-operator-surface/PROMPT.md +++ b/taskplane-tasks/TP-113-legacy-tmux-backend-deprecation-and-registry-only-operator-surface/PROMPT.md @@ -62,6 +62,15 @@ This task follows TP-112 and is the cleanup step toward Runtime V2 default/sunse - **Workspace:** `extensions/taskplane/`, `docs/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/extension.ts` diff --git a/taskplane-tasks/TP-114-single-task-test/PROMPT.md b/taskplane-tasks/TP-114-single-task-test/PROMPT.md index 185f8adc..18a54f9e 100644 --- a/taskplane-tasks/TP-114-single-task-test/PROMPT.md +++ b/taskplane-tasks/TP-114-single-task-test/PROMPT.md @@ -11,6 +11,21 @@ priority: P1 ## Objective Verify Runtime V2 single-task execution, telemetry capture, and dashboard observability. +## Environment + +- **Workspace:** `extensions/taskplane/`, `dashboard/`, `bin/` +- **Services required:** None +- **Submodule workspace:** `.pi/git/github.com/loopyd/taskplane` (absolute: `/mnt/PROJECTS/repos/bof3-decomp/.pi/git/github.com/loopyd/taskplane`) + + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## Steps ### Step 0: Preflight diff --git a/taskplane-tasks/TP-115-runtime-v2-telemetry-and-dashboard-observability/PROMPT.md b/taskplane-tasks/TP-115-runtime-v2-telemetry-and-dashboard-observability/PROMPT.md index 873a67ba..df0ccd43 100644 --- a/taskplane-tasks/TP-115-runtime-v2-telemetry-and-dashboard-observability/PROMPT.md +++ b/taskplane-tasks/TP-115-runtime-v2-telemetry-and-dashboard-observability/PROMPT.md @@ -46,6 +46,15 @@ Fix the telemetry and observability gaps discovered during the first Runtime V2 - **Workspace:** `extensions/taskplane/`, `dashboard/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/lane-runner.ts` diff --git a/taskplane-tasks/TP-116-outcome-embedded-telemetry/PROMPT.md b/taskplane-tasks/TP-116-outcome-embedded-telemetry/PROMPT.md index 9873d32b..11813fbf 100644 --- a/taskplane-tasks/TP-116-outcome-embedded-telemetry/PROMPT.md +++ b/taskplane-tasks/TP-116-outcome-embedded-telemetry/PROMPT.md @@ -12,6 +12,21 @@ dependencies: [] ## Objective Eliminate fragile string-key matching in the batch history writer by embedding telemetry directly into `LaneTaskOutcome`. The lane-runner already has authoritative telemetry from `AgentHostResult` — it should attach it to the outcome at emission time, not require the engine to reconstruct it later via lane snapshot lookups. +## Environment + +- **Workspace:** `extensions/taskplane/`, `dashboard/`, `bin/` +- **Services required:** None +- **Submodule workspace:** `.pi/git/github.com/loopyd/taskplane` (absolute: `/mnt/PROJECTS/repos/bof3-decomp/.pi/git/github.com/loopyd/taskplane`) + + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## Background The batch history writer in `engine.ts` reads V2 lane snapshots and tries to match them to task outcomes via lane number parsing from sessionName strings. This has caused multiple bugs: - `batchState.lanes` undefined → TypeError (v0.23.10) diff --git a/taskplane-tasks/TP-117-tmux-deprecation-messaging-and-dead-code-removal/PROMPT.md b/taskplane-tasks/TP-117-tmux-deprecation-messaging-and-dead-code-removal/PROMPT.md index 14170bac..68263b16 100644 --- a/taskplane-tasks/TP-117-tmux-deprecation-messaging-and-dead-code-removal/PROMPT.md +++ b/taskplane-tasks/TP-117-tmux-deprecation-messaging-and-dead-code-removal/PROMPT.md @@ -22,6 +22,21 @@ taskplane-tasks/TP-117-tmux-deprecation-messaging-and-dead-code-removal/ Remove dead TMUX backend code and add deprecation messaging. Since `selectRuntimeBackend()` always returns `"v2"`, the legacy TMUX execution paths are never called. This task removes them cleanly while preserving the `tmuxSessionName` naming (deferred to TP-118) and TMUX abort fallbacks (deferred to TP-119). +## Environment + +- **Workspace:** `extensions/taskplane/`, `dashboard/`, `bin/` +- **Services required:** None +- **Submodule workspace:** `.pi/git/github.com/loopyd/taskplane` (absolute: `/mnt/PROJECTS/repos/bof3-decomp/.pi/git/github.com/loopyd/taskplane`) + + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## Dependencies - None (V2 is already the only backend) diff --git a/taskplane-tasks/TP-118-lane-session-naming-cleanup/PROMPT.md b/taskplane-tasks/TP-118-lane-session-naming-cleanup/PROMPT.md index a472b146..439e9fee 100644 --- a/taskplane-tasks/TP-118-lane-session-naming-cleanup/PROMPT.md +++ b/taskplane-tasks/TP-118-lane-session-naming-cleanup/PROMPT.md @@ -24,6 +24,21 @@ Rename `tmuxSessionName` to `laneSessionId` throughout the codebase. This field **Strategy:** Type alias first (backward compatible), then gradual field rename, then remove alias. +## Environment + +- **Workspace:** `extensions/taskplane/`, `dashboard/`, `bin/` +- **Services required:** None +- **Submodule workspace:** `.pi/git/github.com/loopyd/taskplane` (absolute: `/mnt/PROJECTS/repos/bof3-decomp/.pi/git/github.com/loopyd/taskplane`) + + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## Dependencies - **Task:** TP-117 (dead code removal reduces the number of rename sites) diff --git a/taskplane-tasks/TP-119-remove-tmux-abort-fallbacks/PROMPT.md b/taskplane-tasks/TP-119-remove-tmux-abort-fallbacks/PROMPT.md index 85fd3503..1788d2b0 100644 --- a/taskplane-tasks/TP-119-remove-tmux-abort-fallbacks/PROMPT.md +++ b/taskplane-tasks/TP-119-remove-tmux-abort-fallbacks/PROMPT.md @@ -22,6 +22,21 @@ taskplane-tasks/TP-119-remove-tmux-abort-fallbacks/ Remove the TMUX abort/cleanup fallback paths that run alongside V2 registry-based cleanup. After TP-117 removes dead execution code and TP-118 cleans up naming, these fallback paths are the last TMUX coupling. They check for TMUX sessions that no longer exist in V2 — the checks always return false and the fallback code never activates. +## Environment + +- **Workspace:** `extensions/taskplane/`, `dashboard/`, `bin/` +- **Services required:** None +- **Submodule workspace:** `.pi/git/github.com/loopyd/taskplane` (absolute: `/mnt/PROJECTS/repos/bof3-decomp/.pi/git/github.com/loopyd/taskplane`) + + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## Dependencies - **Task:** TP-117 (dead code removal) diff --git a/taskplane-tasks/TP-120-tmux-removal-remediation/PROMPT.md b/taskplane-tasks/TP-120-tmux-removal-remediation/PROMPT.md index 3ccae799..88813da3 100644 --- a/taskplane-tasks/TP-120-tmux-removal-remediation/PROMPT.md +++ b/taskplane-tasks/TP-120-tmux-removal-remediation/PROMPT.md @@ -24,6 +24,21 @@ Complete the TMUX removal that TP-119 left unfinished. After this task, there sh This is a **breaking change** for the `tmux_prefix` config field — it will be renamed to `sessionPrefix`. +## Environment + +- **Workspace:** `extensions/taskplane/`, `dashboard/`, `bin/` +- **Services required:** None +- **Submodule workspace:** `.pi/git/github.com/loopyd/taskplane` (absolute: `/mnt/PROJECTS/repos/bof3-decomp/.pi/git/github.com/loopyd/taskplane`) + + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## Dependencies - **Task:** TP-117 (dead code removal — done) diff --git a/taskplane-tasks/TP-121-reviewer-dashboard-visibility/PROMPT.md b/taskplane-tasks/TP-121-reviewer-dashboard-visibility/PROMPT.md index 5437f7ac..2e089a27 100644 --- a/taskplane-tasks/TP-121-reviewer-dashboard-visibility/PROMPT.md +++ b/taskplane-tasks/TP-121-reviewer-dashboard-visibility/PROMPT.md @@ -24,6 +24,21 @@ Restore reviewer agent visibility in the dashboard during V2 execution. When a w **Approach:** The bridge extension's `review_step` tool writes reviewer telemetry to a `.reviewer-state.json` file in the task folder during reviewer execution. The lane-runner's `onTelemetry` callback reads this file and populates the `reviewer` field in the lane snapshot. The dashboard already renders reviewer sub-rows when `snapshot.reviewer` is non-null — it just needs real data. +## Environment + +- **Workspace:** `extensions/taskplane/`, `dashboard/`, `bin/` +- **Services required:** None +- **Submodule workspace:** `.pi/git/github.com/loopyd/taskplane` (absolute: `/mnt/PROJECTS/repos/bof3-decomp/.pi/git/github.com/loopyd/taskplane`) + + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## Dependencies - None (builds on existing bridge extension review_step from v0.23.15) diff --git a/taskplane-tasks/TP-122-tmux-reference-baseline-and-guardrails/PROMPT.md b/taskplane-tasks/TP-122-tmux-reference-baseline-and-guardrails/PROMPT.md index 903aa3e2..96782ee8 100644 --- a/taskplane-tasks/TP-122-tmux-reference-baseline-and-guardrails/PROMPT.md +++ b/taskplane-tasks/TP-122-tmux-reference-baseline-and-guardrails/PROMPT.md @@ -22,6 +22,21 @@ taskplane-tasks/TP-122-tmux-reference-baseline-and-guardrails/ Create a deterministic TMUX-reference audit + guardrail so future changes cannot accidentally reintroduce functional TMUX runtime behavior. This task establishes the baseline and gives all follow-up tasks an objective pass/fail gate. +## Environment + +- **Workspace:** `extensions/taskplane/`, `dashboard/`, `bin/` +- **Services required:** None +- **Submodule workspace:** `.pi/git/github.com/loopyd/taskplane` (absolute: `/mnt/PROJECTS/repos/bof3-decomp/.pi/git/github.com/loopyd/taskplane`) + + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## Dependencies - **Task:** TP-120 (TMUX removal remediation baseline) diff --git a/taskplane-tasks/TP-123-runtime-v2-operator-messaging-detmux/PROMPT.md b/taskplane-tasks/TP-123-runtime-v2-operator-messaging-detmux/PROMPT.md index 3c8bb691..3aeaa2d0 100644 --- a/taskplane-tasks/TP-123-runtime-v2-operator-messaging-detmux/PROMPT.md +++ b/taskplane-tasks/TP-123-runtime-v2-operator-messaging-detmux/PROMPT.md @@ -22,6 +22,21 @@ taskplane-tasks/TP-123-runtime-v2-operator-messaging-detmux/ Remove TMUX-centric wording from operator surfaces while preserving behavior. Replace attach/session guidance with Runtime V2 equivalents so users are not instructed to use tmux commands in a no-TMUX runtime. +## Environment + +- **Workspace:** `extensions/taskplane/`, `dashboard/`, `bin/` +- **Services required:** None +- **Submodule workspace:** `.pi/git/github.com/loopyd/taskplane` (absolute: `/mnt/PROJECTS/repos/bof3-decomp/.pi/git/github.com/loopyd/taskplane`) + + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## Dependencies - **Task:** TP-122 (baseline + guardrails) diff --git a/taskplane-tasks/TP-124-comment-and-type-doc-detmux-sweep/PROMPT.md b/taskplane-tasks/TP-124-comment-and-type-doc-detmux-sweep/PROMPT.md index 0a58d47a..808d1024 100644 --- a/taskplane-tasks/TP-124-comment-and-type-doc-detmux-sweep/PROMPT.md +++ b/taskplane-tasks/TP-124-comment-and-type-doc-detmux-sweep/PROMPT.md @@ -22,6 +22,21 @@ taskplane-tasks/TP-124-comment-and-type-doc-detmux-sweep/ Clean residual TMUX wording in code comments, JSDoc, and type descriptions so the Runtime V2 codebase reads consistently. Preserve compatibility behavior and literal external contracts where required. +## Environment + +- **Workspace:** `extensions/taskplane/`, `dashboard/`, `bin/` +- **Services required:** None +- **Submodule workspace:** `.pi/git/github.com/loopyd/taskplane` (absolute: `/mnt/PROJECTS/repos/bof3-decomp/.pi/git/github.com/loopyd/taskplane`) + + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## Dependencies - **Task:** TP-122 (reference audit/guard) diff --git a/taskplane-tasks/TP-125-centralize-legacy-tmux-compat-shim/PROMPT.md b/taskplane-tasks/TP-125-centralize-legacy-tmux-compat-shim/PROMPT.md index 93428ca2..3d598670 100644 --- a/taskplane-tasks/TP-125-centralize-legacy-tmux-compat-shim/PROMPT.md +++ b/taskplane-tasks/TP-125-centralize-legacy-tmux-compat-shim/PROMPT.md @@ -22,6 +22,21 @@ taskplane-tasks/TP-125-centralize-legacy-tmux-compat-shim/ Centralize all remaining required TMUX compatibility behavior into one module so runtime files no longer carry scattered TMUX conditionals. This preserves backward compatibility while making final removal safe and auditable. +## Environment + +- **Workspace:** `extensions/taskplane/`, `dashboard/`, `bin/` +- **Services required:** None +- **Submodule workspace:** `.pi/git/github.com/loopyd/taskplane` (absolute: `/mnt/PROJECTS/repos/bof3-decomp/.pi/git/github.com/loopyd/taskplane`) + + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## Dependencies - **Task:** TP-122 (audit + guardrails) diff --git a/taskplane-tasks/TP-126-final-tmux-compat-removal-and-migration/PROMPT.md b/taskplane-tasks/TP-126-final-tmux-compat-removal-and-migration/PROMPT.md index 0e3cec48..fa15ccee 100644 --- a/taskplane-tasks/TP-126-final-tmux-compat-removal-and-migration/PROMPT.md +++ b/taskplane-tasks/TP-126-final-tmux-compat-removal-and-migration/PROMPT.md @@ -22,6 +22,21 @@ taskplane-tasks/TP-126-final-tmux-compat-removal-and-migration/ Remove the remaining centralized TMUX compatibility surface after TP-125, while preserving operator safety through explicit migration handling. The result should eliminate TMUX references from active runtime contracts, with clear upgrade guidance and deterministic failure/migration behavior for legacy inputs. +## Environment + +- **Workspace:** `extensions/taskplane/`, `dashboard/`, `bin/` +- **Services required:** None +- **Submodule workspace:** `.pi/git/github.com/loopyd/taskplane` (absolute: `/mnt/PROJECTS/repos/bof3-decomp/.pi/git/github.com/loopyd/taskplane`) + + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## Dependencies - **Task:** TP-122 (guardrails) diff --git a/taskplane-tasks/TP-127-wave-transition-stale-snapshot/PROMPT.md b/taskplane-tasks/TP-127-wave-transition-stale-snapshot/PROMPT.md index dc0382b7..f3337734 100644 --- a/taskplane-tasks/TP-127-wave-transition-stale-snapshot/PROMPT.md +++ b/taskplane-tasks/TP-127-wave-transition-stale-snapshot/PROMPT.md @@ -28,6 +28,21 @@ The startup grace (snap == null → assume alive) doesn't help because the snaps **Fix:** In `resolveTaskMonitorState`, when the V2 lane snapshot exists but its `taskId` doesn't match the task being monitored, treat it as stale (same as null → assume alive). The lane-runner will overwrite it with the new task's snapshot shortly. +## Environment + +- **Workspace:** `extensions/taskplane/`, `dashboard/`, `bin/` +- **Services required:** None +- **Submodule workspace:** `.pi/git/github.com/loopyd/taskplane` (absolute: `/mnt/PROJECTS/repos/bof3-decomp/.pi/git/github.com/loopyd/taskplane`) + + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## Dependencies - None diff --git a/taskplane-tasks/TP-128-full-package-tmux-extrication/PROMPT.md b/taskplane-tasks/TP-128-full-package-tmux-extrication/PROMPT.md index e06f7d88..f6fbd01c 100644 --- a/taskplane-tasks/TP-128-full-package-tmux-extrication/PROMPT.md +++ b/taskplane-tasks/TP-128-full-package-tmux-extrication/PROMPT.md @@ -29,6 +29,21 @@ Complete the TMUX extrication across the entire taskplane package. The orch runt After this task, no file in the published package should contain functional TMUX code. The only acceptable TMUX references are migration comments and the `tmux-compat.ts` shim. +## Environment + +- **Workspace:** `extensions/taskplane/`, `dashboard/`, `bin/` +- **Services required:** None +- **Submodule workspace:** `.pi/git/github.com/loopyd/taskplane` (absolute: `/mnt/PROJECTS/repos/bof3-decomp/.pi/git/github.com/loopyd/taskplane`) + + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## Dependencies - **Task:** TP-126 (final compat removal — done) diff --git a/taskplane-tasks/TP-129-live-context-pct-and-reviewer-telemetry/PROMPT.md b/taskplane-tasks/TP-129-live-context-pct-and-reviewer-telemetry/PROMPT.md index 7171e4ea..a1374b9e 100644 --- a/taskplane-tasks/TP-129-live-context-pct-and-reviewer-telemetry/PROMPT.md +++ b/taskplane-tasks/TP-129-live-context-pct-and-reviewer-telemetry/PROMPT.md @@ -28,6 +28,21 @@ Currently `get_session_stats` is requested once (after the first assistant messa ### 2. Full reviewer telemetry parity The reviewer sub-row in the dashboard currently shows only tool count, cost, and last tool. The worker row shows elapsed time, token counts, context %, and token summary badges. The reviewer should show the same telemetry fields. +## Environment + +- **Workspace:** `extensions/taskplane/`, `dashboard/`, `bin/` +- **Services required:** None +- **Submodule workspace:** `.pi/git/github.com/loopyd/taskplane` (absolute: `/mnt/PROJECTS/repos/bof3-decomp/.pi/git/github.com/loopyd/taskplane`) + + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## Dependencies - **Task:** TP-121 (reviewer dashboard visibility — done) diff --git a/taskplane-tasks/TP-130-engine-worker-diagnostics-and-resilience/PROMPT.md b/taskplane-tasks/TP-130-engine-worker-diagnostics-and-resilience/PROMPT.md index b239112f..c0533882 100644 --- a/taskplane-tasks/TP-130-engine-worker-diagnostics-and-resilience/PROMPT.md +++ b/taskplane-tasks/TP-130-engine-worker-diagnostics-and-resilience/PROMPT.md @@ -33,6 +33,21 @@ In `extension.ts` where the engine-worker child is forked, capture the child's s ### 3. Snapshot failure counter with graceful degradation (P2) In the `reviewerRefresh` interval in `lane-runner.ts`, count consecutive `emitSnapshot` failures. After a threshold (e.g., 5 consecutive), disable the reviewer refresh interval for that task and log a warning. This prevents a broken snapshot path from generating thousands of silent errors per run. +## Environment + +- **Workspace:** `extensions/taskplane/`, `dashboard/`, `bin/` +- **Services required:** None +- **Submodule workspace:** `.pi/git/github.com/loopyd/taskplane` (absolute: `/mnt/PROJECTS/repos/bof3-decomp/.pi/git/github.com/loopyd/taskplane`) + + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## Dependencies - None diff --git a/taskplane-tasks/TP-131-tmux-naming-residual-cleanup/PROMPT.md b/taskplane-tasks/TP-131-tmux-naming-residual-cleanup/PROMPT.md index 7a89bd96..91cb9f4c 100644 --- a/taskplane-tasks/TP-131-tmux-naming-residual-cleanup/PROMPT.md +++ b/taskplane-tasks/TP-131-tmux-naming-residual-cleanup/PROMPT.md @@ -51,6 +51,21 @@ Clean up remaining TMUX naming artifacts across the published package. TP-128 re **Audit script (`scripts/tmux-reference-audit.mjs`):** - Add `skills/` to scan roots (it's a published directory) +## Environment + +- **Workspace:** `extensions/taskplane/`, `dashboard/`, `bin/` +- **Services required:** None +- **Submodule workspace:** `.pi/git/github.com/loopyd/taskplane` (absolute: `/mnt/PROJECTS/repos/bof3-decomp/.pi/git/github.com/loopyd/taskplane`) + + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## Dependencies - None diff --git a/taskplane-tasks/TP-132-multi-repo-spec-v2-alignment/PROMPT.md b/taskplane-tasks/TP-132-multi-repo-spec-v2-alignment/PROMPT.md index d0ef445f..82fcf958 100644 --- a/taskplane-tasks/TP-132-multi-repo-spec-v2-alignment/PROMPT.md +++ b/taskplane-tasks/TP-132-multi-repo-spec-v2-alignment/PROMPT.md @@ -30,6 +30,21 @@ Update `docs/specifications/taskplane/multi-repo-task-execution.md` to reflect R Also define the MVP scope clearly: sequential per-task segment execution, no dynamic expansion in first tranche. Dynamic expansion is deferred to a follow-up. +## Environment + +- **Workspace:** `extensions/taskplane/`, `dashboard/`, `bin/` +- **Services required:** None +- **Submodule workspace:** `.pi/git/github.com/loopyd/taskplane` (absolute: `/mnt/PROJECTS/repos/bof3-decomp/.pi/git/github.com/loopyd/taskplane`) + + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## Dependencies - None diff --git a/taskplane-tasks/TP-133-engine-segment-frontier/PROMPT.md b/taskplane-tasks/TP-133-engine-segment-frontier/PROMPT.md index f81fb83e..7e12d829 100644 --- a/taskplane-tasks/TP-133-engine-segment-frontier/PROMPT.md +++ b/taskplane-tasks/TP-133-engine-segment-frontier/PROMPT.md @@ -37,6 +37,21 @@ Make the engine consume segment plans from `computeWaveAssignments()` and execut - Completion detection checks `.DONE` in the execution repo, not the packet home repo - No segment lifecycle state transitions during execution +## Environment + +- **Workspace:** `extensions/taskplane/`, `dashboard/`, `bin/` +- **Services required:** None +- **Submodule workspace:** `.pi/git/github.com/loopyd/taskplane` (absolute: `/mnt/PROJECTS/repos/bof3-decomp/.pi/git/github.com/loopyd/taskplane`) + + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## Dependencies - **Task:** TP-132 (spec V2 alignment) diff --git a/taskplane-tasks/TP-134-segment-aware-lane-execution/PROMPT.md b/taskplane-tasks/TP-134-segment-aware-lane-execution/PROMPT.md index f67b9991..8f2a4cbd 100644 --- a/taskplane-tasks/TP-134-segment-aware-lane-execution/PROMPT.md +++ b/taskplane-tasks/TP-134-segment-aware-lane-execution/PROMPT.md @@ -36,6 +36,21 @@ Make `lane-runner.ts` segment-aware so it can execute tasks where the working di - Packet paths (STATUS.md, PROMPT.md) may be in a different repo's worktree - Reviewer state file path assumes packet files are local to cwd +## Environment + +- **Workspace:** `extensions/taskplane/`, `dashboard/`, `bin/` +- **Services required:** None +- **Submodule workspace:** `.pi/git/github.com/loopyd/taskplane` (absolute: `/mnt/PROJECTS/repos/bof3-decomp/.pi/git/github.com/loopyd/taskplane`) + + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## Dependencies - **Task:** TP-133 (engine segment frontier) diff --git a/taskplane-tasks/TP-135-segment-persistence-and-resume/PROMPT.md b/taskplane-tasks/TP-135-segment-persistence-and-resume/PROMPT.md index 698a60e7..f004178f 100644 --- a/taskplane-tasks/TP-135-segment-persistence-and-resume/PROMPT.md +++ b/taskplane-tasks/TP-135-segment-persistence-and-resume/PROMPT.md @@ -36,6 +36,21 @@ Populate and maintain `PersistedTaskRecord.segments[]` during execution and teac - No segment lifecycle events in persistence (start/complete/fail) - No reconciliation of in-flight segments after crash +## Environment + +- **Workspace:** `extensions/taskplane/`, `dashboard/`, `bin/` +- **Services required:** None +- **Submodule workspace:** `.pi/git/github.com/loopyd/taskplane` (absolute: `/mnt/PROJECTS/repos/bof3-decomp/.pi/git/github.com/loopyd/taskplane`) + + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## Dependencies - **Task:** TP-133 (engine segment frontier) diff --git a/taskplane-tasks/TP-136-segment-observability-and-supervisor-alerts/PROMPT.md b/taskplane-tasks/TP-136-segment-observability-and-supervisor-alerts/PROMPT.md index d0713be2..00241df7 100644 --- a/taskplane-tasks/TP-136-segment-observability-and-supervisor-alerts/PROMPT.md +++ b/taskplane-tasks/TP-136-segment-observability-and-supervisor-alerts/PROMPT.md @@ -29,6 +29,21 @@ Surface segment-level information in operator-facing tools: dashboard, superviso 3. **orch-status**: Show segment progress when running multi-segment tasks 4. **Batch summary**: Include segment-level outcomes in completion summary +## Environment + +- **Workspace:** `extensions/taskplane/`, `dashboard/`, `bin/` +- **Services required:** None +- **Submodule workspace:** `.pi/git/github.com/loopyd/taskplane` (absolute: `/mnt/PROJECTS/repos/bof3-decomp/.pi/git/github.com/loopyd/taskplane`) + + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## Dependencies - **Task:** TP-134 (segment-aware lane execution — provides segmentId in snapshots) diff --git a/taskplane-tasks/TP-137-batch-history-persistence-fix/PROMPT.md b/taskplane-tasks/TP-137-batch-history-persistence-fix/PROMPT.md index b906ba2b..70c78878 100644 --- a/taskplane-tasks/TP-137-batch-history-persistence-fix/PROMPT.md +++ b/taskplane-tasks/TP-137-batch-history-persistence-fix/PROMPT.md @@ -38,6 +38,21 @@ Other possible causes to investigate: - `extension.ts`: `orch_integrate` merges orch branch into main (or creates PR) - Dashboard `server.cjs`: `loadHistory()` reads `.pi/batch-history.json` +## Environment + +- **Workspace:** `extensions/taskplane/`, `dashboard/`, `bin/` +- **Services required:** None +- **Submodule workspace:** `.pi/git/github.com/loopyd/taskplane` (absolute: `/mnt/PROJECTS/repos/bof3-decomp/.pi/git/github.com/loopyd/taskplane`) + + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## Dependencies - None diff --git a/taskplane-tasks/TP-138-agent-model-thinking-ux-and-global-defaults/PROMPT.md b/taskplane-tasks/TP-138-agent-model-thinking-ux-and-global-defaults/PROMPT.md index a86912eb..b0524f24 100644 --- a/taskplane-tasks/TP-138-agent-model-thinking-ux-and-global-defaults/PROMPT.md +++ b/taskplane-tasks/TP-138-agent-model-thinking-ux-and-global-defaults/PROMPT.md @@ -35,6 +35,21 @@ Fix agent defaults to "inherit" and add a thinking-mode picker to `/taskplane-se 3. **Thinking picker in /taskplane-settings** — add thinking-mode selection (not free-text) for worker, reviewer, and merge thinking settings. Options: "inherit (use session thinking)", "on", "off". When a model is changed to one with thinking support, suggest setting thinking to "on". +## Environment + +- **Workspace:** `extensions/taskplane/`, `dashboard/`, `bin/` +- **Services required:** None +- **Submodule workspace:** `.pi/git/github.com/loopyd/taskplane` (absolute: `/mnt/PROJECTS/repos/bof3-decomp/.pi/git/github.com/loopyd/taskplane`) + + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## Dependencies - None diff --git a/taskplane-tasks/TP-139-init-model-picker-and-global-defaults/PROMPT.md b/taskplane-tasks/TP-139-init-model-picker-and-global-defaults/PROMPT.md index 42bf62ec..d2e4e40a 100644 --- a/taskplane-tasks/TP-139-init-model-picker-and-global-defaults/PROMPT.md +++ b/taskplane-tasks/TP-139-init-model-picker-and-global-defaults/PROMPT.md @@ -48,6 +48,15 @@ Currently `taskplane init` does not offer model selection — users must manuall - **Workspace:** `bin/taskplane.mjs`, `extensions/taskplane/config-loader.ts` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `bin/taskplane.mjs` diff --git a/taskplane-tasks/TP-140-global-preferences-architecture/PROMPT.md b/taskplane-tasks/TP-140-global-preferences-architecture/PROMPT.md index ccb360c0..f9e86da9 100644 --- a/taskplane-tasks/TP-140-global-preferences-architecture/PROMPT.md +++ b/taskplane-tasks/TP-140-global-preferences-architecture/PROMPT.md @@ -82,6 +82,15 @@ Currently the precedence is reversed: project config is the full document and us - **Workspace:** `extensions/taskplane/`, `bin/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/config-schema.ts` diff --git a/taskplane-tasks/TP-141-first-install-bootstrap-and-guidance/PROMPT.md b/taskplane-tasks/TP-141-first-install-bootstrap-and-guidance/PROMPT.md index 183a74e5..f6a443f6 100644 --- a/taskplane-tasks/TP-141-first-install-bootstrap-and-guidance/PROMPT.md +++ b/taskplane-tasks/TP-141-first-install-bootstrap-and-guidance/PROMPT.md @@ -71,6 +71,15 @@ Implement first-install detection, global preferences bootstrapping, and intelli - **Workspace:** `extensions/taskplane/`, `bin/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/config-schema.ts` diff --git a/taskplane-tasks/TP-142-segment-expansion-tool-and-ipc/PROMPT.md b/taskplane-tasks/TP-142-segment-expansion-tool-and-ipc/PROMPT.md index d188fac7..f76602a9 100644 --- a/taskplane-tasks/TP-142-segment-expansion-tool-and-ipc/PROMPT.md +++ b/taskplane-tasks/TP-142-segment-expansion-tool-and-ipc/PROMPT.md @@ -46,6 +46,15 @@ This is the worker-facing half of dynamic segment expansion. The tool validates - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/agent-bridge-extension.ts` diff --git a/taskplane-tasks/TP-143-engine-segment-graph-mutation/PROMPT.md b/taskplane-tasks/TP-143-engine-segment-graph-mutation/PROMPT.md index 983c2a9b..f3ea5ca0 100644 --- a/taskplane-tasks/TP-143-engine-segment-graph-mutation/PROMPT.md +++ b/taskplane-tasks/TP-143-engine-segment-graph-mutation/PROMPT.md @@ -47,6 +47,15 @@ This is the critical path of dynamic segment expansion. The engine must mutate a - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/engine.ts` diff --git a/taskplane-tasks/TP-144-segment-expansion-acceptance-tests/PROMPT.md b/taskplane-tasks/TP-144-segment-expansion-acceptance-tests/PROMPT.md index 844117b3..38082e85 100644 --- a/taskplane-tasks/TP-144-segment-expansion-acceptance-tests/PROMPT.md +++ b/taskplane-tasks/TP-144-segment-expansion-acceptance-tests/PROMPT.md @@ -43,6 +43,15 @@ Validate dynamic segment expansion end-to-end in the polyrepo test workspace (`C - **Workspace:** `C:\dev\tp-test-workspace` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `C:/dev/tp-test-workspace/shared-libs/task-management/platform/general/` (new test tasks) diff --git a/taskplane-tasks/TP-145-multi-segment-done-and-expansion-edge-fix/PROMPT.md b/taskplane-tasks/TP-145-multi-segment-done-and-expansion-edge-fix/PROMPT.md index 0c19e2c1..a997b3b6 100644 --- a/taskplane-tasks/TP-145-multi-segment-done-and-expansion-edge-fix/PROMPT.md +++ b/taskplane-tasks/TP-145-multi-segment-done-and-expansion-edge-fix/PROMPT.md @@ -39,6 +39,21 @@ Workers file expansion requests with edges like `{ from: "shared-libs", to: "web **Fix:** In `validateSegmentExpansionRequestAtBoundary`, allow edge endpoints that reference the anchor segment's repo ID. The edge is redundant (after-current placement already implies that dependency) — accept it silently or strip it before graph mutation. +## Environment + +- **Workspace:** `extensions/taskplane/`, `dashboard/`, `bin/` +- **Services required:** None +- **Submodule workspace:** `.pi/git/github.com/loopyd/taskplane` (absolute: `/mnt/PROJECTS/repos/bof3-decomp/.pi/git/github.com/loopyd/taskplane`) + + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## Dependencies - None diff --git a/taskplane-tasks/TP-146-investigate-missing-orch-branch/PROMPT.md b/taskplane-tasks/TP-146-investigate-missing-orch-branch/PROMPT.md index e0955510..6594c7c3 100644 --- a/taskplane-tasks/TP-146-investigate-missing-orch-branch/PROMPT.md +++ b/taskplane-tasks/TP-146-investigate-missing-orch-branch/PROMPT.md @@ -36,6 +36,21 @@ The orch branch model requires ALL repos to have isolated orch branches during b 3. **Document findings** in STATUS.md with specific code paths, commit evidence, and recommended fix. +## Environment + +- **Workspace:** `extensions/taskplane/`, `dashboard/`, `bin/` +- **Services required:** None +- **Submodule workspace:** `.pi/git/github.com/loopyd/taskplane` (absolute: `/mnt/PROJECTS/repos/bof3-decomp/.pi/git/github.com/loopyd/taskplane`) + + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## Dependencies - None diff --git a/taskplane-tasks/TP-147-skipped-task-progress-and-history/PROMPT.md b/taskplane-tasks/TP-147-skipped-task-progress-and-history/PROMPT.md index 16cdc6b7..d11f3ae8 100644 --- a/taskplane-tasks/TP-147-skipped-task-progress-and-history/PROMPT.md +++ b/taskplane-tasks/TP-147-skipped-task-progress-and-history/PROMPT.md @@ -39,6 +39,21 @@ TP-006 was completely absent from `batch-history.json` despite being in the wave **Fix:** Ensure ALL tasks in the wave plan are recorded in batch history, even if they never started execution (status: "pending" or "blocked"). +## Environment + +- **Workspace:** `extensions/taskplane/`, `dashboard/`, `bin/` +- **Services required:** None +- **Submodule workspace:** `.pi/git/github.com/loopyd/taskplane` (absolute: `/mnt/PROJECTS/repos/bof3-decomp/.pi/git/github.com/loopyd/taskplane`) + + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## Dependencies - None diff --git a/taskplane-tasks/TP-148-wave-display-maxlanes-session-naming/PROMPT.md b/taskplane-tasks/TP-148-wave-display-maxlanes-session-naming/PROMPT.md index aea949f5..dd9f16ef 100644 --- a/taskplane-tasks/TP-148-wave-display-maxlanes-session-naming/PROMPT.md +++ b/taskplane-tasks/TP-148-wave-display-maxlanes-session-naming/PROMPT.md @@ -45,6 +45,21 @@ The TUI widget shows "session dead" for all running lanes because batch state us **Fix:** Align the naming — either the widget should look up by `agentId` pattern, or the batch state should store the V2 agent ID alongside the legacy session ID. +## Environment + +- **Workspace:** `extensions/taskplane/`, `dashboard/`, `bin/` +- **Services required:** None +- **Submodule workspace:** `.pi/git/github.com/loopyd/taskplane` (absolute: `/mnt/PROJECTS/repos/bof3-decomp/.pi/git/github.com/loopyd/taskplane`) + + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## Dependencies - None diff --git a/taskplane-tasks/TP-149-supervisor-integration-ordering/PROMPT.md b/taskplane-tasks/TP-149-supervisor-integration-ordering/PROMPT.md index f8774882..1f779292 100644 --- a/taskplane-tasks/TP-149-supervisor-integration-ordering/PROMPT.md +++ b/taskplane-tasks/TP-149-supervisor-integration-ordering/PROMPT.md @@ -39,6 +39,21 @@ When the supervisor runs `orch_integrate` autonomously (auto integration mode), Before attempting integration, check if any repo has remotes configured. If none do, skip PR mode entirely and go straight to FF → merge. +## Environment + +- **Workspace:** `extensions/taskplane/`, `dashboard/`, `bin/` +- **Services required:** None +- **Submodule workspace:** `.pi/git/github.com/loopyd/taskplane` (absolute: `/mnt/PROJECTS/repos/bof3-decomp/.pi/git/github.com/loopyd/taskplane`) + + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## Dependencies - None diff --git a/taskplane-tasks/TP-150-docs-readme-and-single-task-tutorial/PROMPT.md b/taskplane-tasks/TP-150-docs-readme-and-single-task-tutorial/PROMPT.md index 735d0932..3a0fd7af 100644 --- a/taskplane-tasks/TP-150-docs-readme-and-single-task-tutorial/PROMPT.md +++ b/taskplane-tasks/TP-150-docs-readme-and-single-task-tutorial/PROMPT.md @@ -44,6 +44,15 @@ Update `docs/README.md` and rewrite `docs/tutorials/run-your-first-task.md` to r - **Workspace:** `docs/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `docs/README.md` diff --git a/taskplane-tasks/TP-151-docs-install-tutorial/PROMPT.md b/taskplane-tasks/TP-151-docs-install-tutorial/PROMPT.md index be660bea..eaadc29c 100644 --- a/taskplane-tasks/TP-151-docs-install-tutorial/PROMPT.md +++ b/taskplane-tasks/TP-151-docs-install-tutorial/PROMPT.md @@ -39,6 +39,15 @@ Update `docs/tutorials/install.md` to reflect the current Taskplane architecture - **Workspace:** `docs/tutorials/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `docs/tutorials/install.md` diff --git a/taskplane-tasks/TP-152-docs-commands-reference/PROMPT.md b/taskplane-tasks/TP-152-docs-commands-reference/PROMPT.md index 84063ef9..5d26e3f0 100644 --- a/taskplane-tasks/TP-152-docs-commands-reference/PROMPT.md +++ b/taskplane-tasks/TP-152-docs-commands-reference/PROMPT.md @@ -41,6 +41,15 @@ Also clean up any remaining `/task` references in other parts of the file (e.g., - **Workspace:** `docs/reference/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `docs/reference/commands.md` diff --git a/taskplane-tasks/TP-153-docs-architecture-and-explanations/PROMPT.md b/taskplane-tasks/TP-153-docs-architecture-and-explanations/PROMPT.md index 487e786b..0343fc57 100644 --- a/taskplane-tasks/TP-153-docs-architecture-and-explanations/PROMPT.md +++ b/taskplane-tasks/TP-153-docs-architecture-and-explanations/PROMPT.md @@ -41,6 +41,15 @@ The primary file needing significant changes is `docs/explanation/architecture.m - **Workspace:** `docs/explanation/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `docs/explanation/architecture.md` diff --git a/taskplane-tasks/TP-154-docs-howto-config-guides/PROMPT.md b/taskplane-tasks/TP-154-docs-howto-config-guides/PROMPT.md index 567af47a..028fe764 100644 --- a/taskplane-tasks/TP-154-docs-howto-config-guides/PROMPT.md +++ b/taskplane-tasks/TP-154-docs-howto-config-guides/PROMPT.md @@ -47,6 +47,15 @@ Write from the perspective of what exists today. No deprecation notices, no hist - **Workspace:** `docs/how-to/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `docs/how-to/configure-task-runner.md` diff --git a/taskplane-tasks/TP-155-docs-dev-setup-and-orch-tutorial/PROMPT.md b/taskplane-tasks/TP-155-docs-dev-setup-and-orch-tutorial/PROMPT.md index caa4ee5d..5867ae65 100644 --- a/taskplane-tasks/TP-155-docs-dev-setup-and-orch-tutorial/PROMPT.md +++ b/taskplane-tasks/TP-155-docs-dev-setup-and-orch-tutorial/PROMPT.md @@ -39,6 +39,15 @@ Update `docs/maintainers/development-setup.md` and `docs/tutorials/run-your-firs - **Workspace:** `docs/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `docs/maintainers/development-setup.md` diff --git a/taskplane-tasks/TP-156-docs-root-readme/PROMPT.md b/taskplane-tasks/TP-156-docs-root-readme/PROMPT.md index c0421073..e58a3f00 100644 --- a/taskplane-tasks/TP-156-docs-root-readme/PROMPT.md +++ b/taskplane-tasks/TP-156-docs-root-readme/PROMPT.md @@ -36,6 +36,15 @@ Update the root `README.md` to remove all remaining references to the `/task` co - **Workspace:** project root - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `README.md` diff --git a/taskplane-tasks/TP-157-path-resolver-utility/PROMPT.md b/taskplane-tasks/TP-157-path-resolver-utility/PROMPT.md index de6ea189..aacd31c2 100644 --- a/taskplane-tasks/TP-157-path-resolver-utility/PROMPT.md +++ b/taskplane-tasks/TP-157-path-resolver-utility/PROMPT.md @@ -50,6 +50,15 @@ The `npm root -g` dynamic call must be the **primary** resolution path (covers a - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/path-resolver.ts` ← new file diff --git a/taskplane-tasks/TP-158-orch-config-reload-on-start/PROMPT.md b/taskplane-tasks/TP-158-orch-config-reload-on-start/PROMPT.md index 02235acd..21123b17 100644 --- a/taskplane-tasks/TP-158-orch-config-reload-on-start/PROMPT.md +++ b/taskplane-tasks/TP-158-orch-config-reload-on-start/PROMPT.md @@ -43,6 +43,15 @@ The fix: at the beginning of `doOrchStart()` in `extension.ts`, attempt to reloa - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/extension.ts` diff --git a/taskplane-tasks/TP-159-ghost-worker-liveness-detection/PROMPT.md b/taskplane-tasks/TP-159-ghost-worker-liveness-detection/PROMPT.md index 0757ab75..9d693056 100644 --- a/taskplane-tasks/TP-159-ghost-worker-liveness-detection/PROMPT.md +++ b/taskplane-tasks/TP-159-ghost-worker-liveness-detection/PROMPT.md @@ -49,6 +49,15 @@ The fix has two parts: - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/execution.ts` diff --git a/taskplane-tasks/TP-160-reviewer-model-not-passed-to-subprocess/PROMPT.md b/taskplane-tasks/TP-160-reviewer-model-not-passed-to-subprocess/PROMPT.md index 302a6e5d..a7fd220a 100644 --- a/taskplane-tasks/TP-160-reviewer-model-not-passed-to-subprocess/PROMPT.md +++ b/taskplane-tasks/TP-160-reviewer-model-not-passed-to-subprocess/PROMPT.md @@ -57,6 +57,15 @@ The fix: thread `runnerConfig.reviewer` through the call chain as env vars, and - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/engine.ts` — pass reviewer config into `executeWave` diff --git a/taskplane-tasks/TP-161-task-runner-extract-utilities/PROMPT.md b/taskplane-tasks/TP-161-task-runner-extract-utilities/PROMPT.md index 94b4f43e..ee4c3b5a 100644 --- a/taskplane-tasks/TP-161-task-runner-extract-utilities/PROMPT.md +++ b/taskplane-tasks/TP-161-task-runner-extract-utilities/PROMPT.md @@ -51,6 +51,15 @@ First phase of the task-runner consolidation (see `docs/specifications/taskplane - **Workspace:** `extensions/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/sidecar-telemetry.ts` (new) diff --git a/taskplane-tasks/TP-162-task-runner-delete-and-cleanup/PROMPT.md b/taskplane-tasks/TP-162-task-runner-delete-and-cleanup/PROMPT.md index cb362e19..32b21c5d 100644 --- a/taskplane-tasks/TP-162-task-runner-delete-and-cleanup/PROMPT.md +++ b/taskplane-tasks/TP-162-task-runner-delete-and-cleanup/PROMPT.md @@ -48,6 +48,15 @@ Second and final phase of the task-runner consolidation. TP-161 has already crea - **Workspace:** project root - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/task-runner.ts` ← **deleted** diff --git a/taskplane-tasks/TP-163-fix-worktree-enoent-staging-commit/PROMPT.md b/taskplane-tasks/TP-163-fix-worktree-enoent-staging-commit/PROMPT.md index 9cc701bf..6448b71e 100644 --- a/taskplane-tasks/TP-163-fix-worktree-enoent-staging-commit/PROMPT.md +++ b/taskplane-tasks/TP-163-fix-worktree-enoent-staging-commit/PROMPT.md @@ -56,6 +56,15 @@ The cleanest place to do this: in `ensureTaskFilesCommitted` itself (in `executi - **Workspace:** `extensions/taskplane/` - **Services required:** None (git operations only) + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/execution.ts` — update `ensureTaskFilesCommitted` and `executeWave` diff --git a/taskplane-tasks/TP-164-merge-agent-live-dashboard-telemetry/PROMPT.md b/taskplane-tasks/TP-164-merge-agent-live-dashboard-telemetry/PROMPT.md index d9e29e84..9c567530 100644 --- a/taskplane-tasks/TP-164-merge-agent-live-dashboard-telemetry/PROMPT.md +++ b/taskplane-tasks/TP-164-merge-agent-live-dashboard-telemetry/PROMPT.md @@ -51,6 +51,15 @@ The fix follows the exact same pattern as worker lane snapshots: - **Workspace:** `extensions/taskplane/` and `dashboard/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/types.ts` — add `RuntimeMergeSnapshot` interface and `runtimeMergeSnapshotPath()` function diff --git a/taskplane-tasks/TP-165-segment-boundary-done-guard/PROMPT.md b/taskplane-tasks/TP-165-segment-boundary-done-guard/PROMPT.md index dc91d542..22c84022 100644 --- a/taskplane-tasks/TP-165-segment-boundary-done-guard/PROMPT.md +++ b/taskplane-tasks/TP-165-segment-boundary-done-guard/PROMPT.md @@ -40,6 +40,15 @@ Fix two related segment lifecycle bugs: (1) `.DONE` is created after the first s - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/engine.ts` diff --git a/taskplane-tasks/TP-166-wave-planner-and-global-lane-cap/PROMPT.md b/taskplane-tasks/TP-166-wave-planner-and-global-lane-cap/PROMPT.md index e6da73a5..e19da6b3 100644 --- a/taskplane-tasks/TP-166-wave-planner-and-global-lane-cap/PROMPT.md +++ b/taskplane-tasks/TP-166-wave-planner-and-global-lane-cap/PROMPT.md @@ -39,6 +39,15 @@ Fix two wave planner issues: (1) the planner creates excessive phantom waves for - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/waves.ts` diff --git a/taskplane-tasks/TP-167-init-windows-backslash-normalization/PROMPT.md b/taskplane-tasks/TP-167-init-windows-backslash-normalization/PROMPT.md index 8f73f2cb..bbdeb5d0 100644 --- a/taskplane-tasks/TP-167-init-windows-backslash-normalization/PROMPT.md +++ b/taskplane-tasks/TP-167-init-windows-backslash-normalization/PROMPT.md @@ -36,6 +36,15 @@ Fix `taskplane init` on Windows writing backslash paths into `.pi/taskplane-work - **Workspace:** `bin/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `bin/taskplane.mjs` diff --git a/taskplane-tasks/TP-168-artifact-cleanup-policy/PROMPT.md b/taskplane-tasks/TP-168-artifact-cleanup-policy/PROMPT.md index f80674a7..7b80de4e 100644 --- a/taskplane-tasks/TP-168-artifact-cleanup-policy/PROMPT.md +++ b/taskplane-tasks/TP-168-artifact-cleanup-policy/PROMPT.md @@ -42,6 +42,15 @@ Changes needed: - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/cleanup.ts` diff --git a/taskplane-tasks/TP-169-segment-expansion-resume-and-orch-branch/PROMPT.md b/taskplane-tasks/TP-169-segment-expansion-resume-and-orch-branch/PROMPT.md index c575d520..276305d3 100644 --- a/taskplane-tasks/TP-169-segment-expansion-resume-and-orch-branch/PROMPT.md +++ b/taskplane-tasks/TP-169-segment-expansion-resume-and-orch-branch/PROMPT.md @@ -39,6 +39,15 @@ Fix two bugs: (1) resuming after segment expansion crashes with `allocTask.task. - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/engine.ts` diff --git a/taskplane-tasks/TP-170-cli-widget-session-dead-display/PROMPT.md b/taskplane-tasks/TP-170-cli-widget-session-dead-display/PROMPT.md index 85889cbe..40517128 100644 --- a/taskplane-tasks/TP-170-cli-widget-session-dead-display/PROMPT.md +++ b/taskplane-tasks/TP-170-cli-widget-session-dead-display/PROMPT.md @@ -36,6 +36,15 @@ Fix the CLI widget incorrectly showing 'session dead' / 'failed' for completed w - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/formatting.ts` diff --git a/taskplane-tasks/TP-171-skip-progress-preservation-and-history/PROMPT.md b/taskplane-tasks/TP-171-skip-progress-preservation-and-history/PROMPT.md index b3c639fe..d218e303 100644 --- a/taskplane-tasks/TP-171-skip-progress-preservation-and-history/PROMPT.md +++ b/taskplane-tasks/TP-171-skip-progress-preservation-and-history/PROMPT.md @@ -40,6 +40,15 @@ Fix two related batch outcome bugs: (1) skipped tasks lose all worker progress - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/merge.ts` diff --git a/taskplane-tasks/TP-172-supervisor-in-the-loop-worker-exit/PROMPT.md b/taskplane-tasks/TP-172-supervisor-in-the-loop-worker-exit/PROMPT.md index fe8fe371..b8ef0f14 100644 --- a/taskplane-tasks/TP-172-supervisor-in-the-loop-worker-exit/PROMPT.md +++ b/taskplane-tasks/TP-172-supervisor-in-the-loop-worker-exit/PROMPT.md @@ -65,6 +65,15 @@ Worker continues with full conversation context + supervisor guidance - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/agent-host.ts` diff --git a/taskplane-tasks/TP-173-discovery-segment-step-parsing/PROMPT.md b/taskplane-tasks/TP-173-discovery-segment-step-parsing/PROMPT.md index 3090a637..b8f803b7 100644 --- a/taskplane-tasks/TP-173-discovery-segment-step-parsing/PROMPT.md +++ b/taskplane-tasks/TP-173-discovery-segment-step-parsing/PROMPT.md @@ -42,6 +42,15 @@ Add parsing of `#### Segment: ` markers within PROMPT.md steps to `disco - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/discovery.ts` diff --git a/taskplane-tasks/TP-174-lane-runner-segment-scoping/PROMPT.md b/taskplane-tasks/TP-174-lane-runner-segment-scoping/PROMPT.md index 7f6d1f08..02d7985d 100644 --- a/taskplane-tasks/TP-174-lane-runner-segment-scoping/PROMPT.md +++ b/taskplane-tasks/TP-174-lane-runner-segment-scoping/PROMPT.md @@ -41,6 +41,15 @@ Modify the lane-runner to scope worker visibility, progress tracking, and exit c - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/lane-runner.ts` diff --git a/taskplane-tasks/TP-175-worker-prompt-and-skill-segment-markers/PROMPT.md b/taskplane-tasks/TP-175-worker-prompt-and-skill-segment-markers/PROMPT.md index 48531f77..6404b4fe 100644 --- a/taskplane-tasks/TP-175-worker-prompt-and-skill-segment-markers/PROMPT.md +++ b/taskplane-tasks/TP-175-worker-prompt-and-skill-segment-markers/PROMPT.md @@ -44,6 +44,15 @@ Update the worker agent prompt template (`task-worker.md`) with multi-segment ta - **Workspace:** `templates/`, `skills/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `templates/agents/task-worker.md` diff --git a/taskplane-tasks/TP-176-dashboard-segment-progress/PROMPT.md b/taskplane-tasks/TP-176-dashboard-segment-progress/PROMPT.md index e05faef1..8dd40376 100644 --- a/taskplane-tasks/TP-176-dashboard-segment-progress/PROMPT.md +++ b/taskplane-tasks/TP-176-dashboard-segment-progress/PROMPT.md @@ -43,6 +43,15 @@ Update the dashboard to show segment-scoped progress for multi-segment tasks. Th - **Workspace:** `dashboard/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `dashboard/public/app.js` diff --git a/taskplane-tasks/TP-177-polyrepo-segment-integration-test/PROMPT.md b/taskplane-tasks/TP-177-polyrepo-segment-integration-test/PROMPT.md index 4f0660f3..423048c9 100644 --- a/taskplane-tasks/TP-177-polyrepo-segment-integration-test/PROMPT.md +++ b/taskplane-tasks/TP-177-polyrepo-segment-integration-test/PROMPT.md @@ -43,6 +43,15 @@ This is the acceptance test for the Phase A specification. - **Workspace:** `C:\dev\tp-test-workspace\` (polyrepo test workspace) - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `C:\dev\tp-test-workspace\shared-libs\task-management\platform\general\TP-*/PROMPT.md` diff --git a/taskplane-tasks/TP-178-dashboard-display-fixes/PROMPT.md b/taskplane-tasks/TP-178-dashboard-display-fixes/PROMPT.md index a47c09b7..683553c1 100644 --- a/taskplane-tasks/TP-178-dashboard-display-fixes/PROMPT.md +++ b/taskplane-tasks/TP-178-dashboard-display-fixes/PROMPT.md @@ -40,6 +40,15 @@ Fix six display bugs in the dashboard's `app.js` that were discovered during pol - **Workspace:** `dashboard/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `dashboard/public/app.js` diff --git a/taskplane-tasks/TP-179-dashboard-state-and-server-fixes/PROMPT.md b/taskplane-tasks/TP-179-dashboard-state-and-server-fixes/PROMPT.md index 63ee5e09..e588c7d9 100644 --- a/taskplane-tasks/TP-179-dashboard-state-and-server-fixes/PROMPT.md +++ b/taskplane-tasks/TP-179-dashboard-state-and-server-fixes/PROMPT.md @@ -41,6 +41,15 @@ Fix two dashboard server-side issues: (1) `orch-integrate` doesn't write `integr - **Workspace:** `dashboard/`, `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/extension.ts` diff --git a/taskplane-tasks/TP-180-forward-extensions-to-spawned-agents/PROMPT.md b/taskplane-tasks/TP-180-forward-extensions-to-spawned-agents/PROMPT.md index 2b04902c..bf5f3506 100644 --- a/taskplane-tasks/TP-180-forward-extensions-to-spawned-agents/PROMPT.md +++ b/taskplane-tasks/TP-180-forward-extensions-to-spawned-agents/PROMPT.md @@ -50,6 +50,15 @@ Additionally, add a Settings TUI submenu where users can toggle specific extensi - **Workspace:** `extensions/taskplane/` - **Services required:** None + +## Execution Target + +- **Repo:** taskplane +- **Submodule path:** `.pi/git/github.com/loopyd/taskplane` +- **Upstream URL:** `https://github.com/loopyd/taskplane.git` + +> This task operates within the `taskplane` submodule. All file paths, git operations, and worktrees are scoped to this submodule's repository root. + ## File Scope - `extensions/taskplane/settings-loader.ts` (new) diff --git a/templates/agents/task-merger.md b/templates/agents/task-merger.md index cd682b0b..6e73b6a5 100644 --- a/templates/agents/task-merger.md +++ b/templates/agents/task-merger.md @@ -47,25 +47,33 @@ Use the source branch and merge message from the merge request. ### Step 3: Handle Result **If merge succeeds (no conflicts):** + - Proceed to Verification (Step 4) **If merge has conflicts:** + 1. List conflicted files: + ```bash git diff --name-only --diff-filter=U ``` + 2. Classify each conflict using the Conflict Classification table below 3. For auto-resolvable conflicts: resolve them, then `git add` the resolved files 4. If ALL conflicts are resolved: + ```bash git add . git commit -m "merge: resolved conflicts in {source_branch} → {target_branch}" ``` + Proceed to Verification (Step 4) — status will be `CONFLICT_RESOLVED` 5. If ANY conflict is **not** auto-resolvable: + ```bash git merge --abort ``` + Write a `CONFLICT_UNRESOLVED` result and stop. ### Step 4: Verification @@ -80,23 +88,25 @@ If the verification section is empty, skip verification and proceed with the mer `"CONFLICT_RESOLVED"` if conflicts were auto-resolved). **If verification fails:** + ```bash git revert HEAD --no-edit # Undo the merge commit ``` + Write a `BUILD_FAILURE` result with the error output from the failed command. --- ## Conflict Classification -| Type | Auto-Resolvable | Resolution Strategy | -|------|-----------------|---------------------| -| Different files modified | N/A (git handles automatically) | No action needed | -| Same file, different sections | Yes — accept both changes | Edit file to include both changes, remove conflict markers | -| Same file, same lines | **No** — needs human review | Abort merge immediately | -| Generated files (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`) | Yes — regenerate | Run package manager install command to regenerate | -| `STATUS.md` / `.DONE` files | Yes — keep both | Accept incoming STATUS.md; keep `.DONE` markers | -| `CONTEXT.md` (append-only sections) | Yes — keep both additions | Merge both additions into relevant sections | +| Type | Auto-Resolvable | Resolution Strategy | +| -------------------------------------------------------------------- | ------------------------------- | ---------------------------------------------------------- | +| Different files modified | N/A (git handles automatically) | No action needed | +| Same file, different sections | Yes — accept both changes | Edit file to include both changes, remove conflict markers | +| Same file, same lines | **No** — needs human review | Abort merge immediately | +| Generated files (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`) | Yes — regenerate | Run package manager install command to regenerate | +| `STATUS.md` / `.DONE` files | Yes — keep both | Accept incoming STATUS.md; keep `.DONE` markers | +| `CONTEXT.md` (append-only sections) | Yes — keep both additions | Merge both additions into relevant sections | ### Auto-Resolution Rules @@ -109,6 +119,7 @@ Write a `BUILD_FAILURE` result with the error output from the failed command. `pnpm install`, or `yarn install`), then `git add` the regenerated file. 3. **STATUS.md:** These are per-task tracking files. Accept theirs: + ```bash git checkout --theirs STATUS.md && git add STATUS.md ``` @@ -142,29 +153,29 @@ Write your result as JSON to the path specified in the merge request ### Field Reference -| Field | Type | Description | -|-------|------|-------------| -| `status` | string | One of: `SUCCESS`, `CONFLICT_RESOLVED`, `CONFLICT_UNRESOLVED`, `BUILD_FAILURE` | -| `source_branch` | string | The lane branch that was merged (from merge request) | -| `target_branch` | string | Target branch from merge request (typically integration branch, e.g. `main`) | -| `merge_commit` | string | Merge commit SHA (present only if merge succeeded) | -| `conflicts` | array | List of conflict entries (empty if no conflicts) | -| `conflicts[].file` | string | Path to conflicted file | -| `conflicts[].type` | string | Classification (`different-sections`, `same-lines`, `generated`, `status-file`) | -| `conflicts[].resolved` | boolean | Whether conflict was auto-resolved | -| `conflicts[].resolution` | string | Resolution summary | -| `verification.ran` | boolean | Whether verification commands were executed | -| `verification.passed` | boolean | Whether verification commands passed | -| `verification.output` | string | Verification output (useful on failures) | +| Field | Type | Description | +| ------------------------ | ------- | ------------------------------------------------------------------------------- | +| `status` | string | One of: `SUCCESS`, `CONFLICT_RESOLVED`, `CONFLICT_UNRESOLVED`, `BUILD_FAILURE` | +| `source_branch` | string | The lane branch that was merged (from merge request) | +| `target_branch` | string | Target branch from merge request (typically integration branch, e.g. `main`) | +| `merge_commit` | string | Merge commit SHA (present only if merge succeeded) | +| `conflicts` | array | List of conflict entries (empty if no conflicts) | +| `conflicts[].file` | string | Path to conflicted file | +| `conflicts[].type` | string | Classification (`different-sections`, `same-lines`, `generated`, `status-file`) | +| `conflicts[].resolved` | boolean | Whether conflict was auto-resolved | +| `conflicts[].resolution` | string | Resolution summary | +| `verification.ran` | boolean | Whether verification commands were executed | +| `verification.passed` | boolean | Whether verification commands passed | +| `verification.output` | string | Verification output (useful on failures) | ### Status Definitions -| Status | Meaning | Orchestrator Action | -|--------|---------|---------------------| -| `SUCCESS` | Merge completed, verification passed | Continue to next lane | -| `CONFLICT_RESOLVED` | Conflicts auto-resolved, verification passed | Log details, continue | -| `CONFLICT_UNRESOLVED` | Conflict requires human intervention | Pause batch, notify user | -| `BUILD_FAILURE` | Merge succeeded but verification failed (merge reverted) | Pause batch, notify user | +| Status | Meaning | Orchestrator Action | +| --------------------- | -------------------------------------------------------- | ------------------------ | +| `SUCCESS` | Merge completed, verification passed | Continue to next lane | +| `CONFLICT_RESOLVED` | Conflicts auto-resolved, verification passed | Log details, continue | +| `CONFLICT_UNRESOLVED` | Conflict requires human intervention | Pause batch, notify user | +| `BUILD_FAILURE` | Merge succeeded but verification failed (merge reverted) | Pause batch, notify user | ### Example: Conflict Resolved