taskplane 0.1.15 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/taskplane.mjs +317 -5
- package/extensions/taskplane/abort.ts +461 -466
- package/extensions/taskplane/config.ts +17 -12
- package/extensions/taskplane/discovery.ts +168 -32
- package/extensions/taskplane/engine.ts +22 -12
- package/extensions/taskplane/execution.ts +108 -46
- package/extensions/taskplane/extension.ts +780 -693
- package/extensions/taskplane/index.ts +23 -22
- package/extensions/taskplane/messages.ts +146 -134
- package/extensions/taskplane/resume.ts +9 -3
- package/extensions/taskplane/types.ts +238 -1
- package/extensions/taskplane/workspace.ts +382 -0
- package/extensions/taskplane/worktree.ts +107 -6
- package/package.json +1 -1
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Config loading from YAML
|
|
3
|
-
* @module orch/config
|
|
4
|
-
*/
|
|
5
|
-
import { readFileSync, existsSync } from "fs";
|
|
6
|
-
import { join } from "path";
|
|
7
|
-
import { parse as yamlParse } from "yaml";
|
|
8
|
-
|
|
9
|
-
import { DEFAULT_ORCHESTRATOR_CONFIG, DEFAULT_TASK_RUNNER_CONFIG } from "./types.ts";
|
|
10
|
-
import type { OrchestratorConfig, TaskArea, TaskRunnerConfig } from "./types.ts";
|
|
11
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Config loading from YAML
|
|
3
|
+
* @module orch/config
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync, existsSync } from "fs";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
import { parse as yamlParse } from "yaml";
|
|
8
|
+
|
|
9
|
+
import { DEFAULT_ORCHESTRATOR_CONFIG, DEFAULT_TASK_RUNNER_CONFIG } from "./types.ts";
|
|
10
|
+
import type { OrchestratorConfig, TaskArea, TaskRunnerConfig } from "./types.ts";
|
|
11
|
+
|
|
12
12
|
// ── Config Loading ───────────────────────────────────────────────────
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -84,11 +84,16 @@ export function loadTaskRunnerConfig(cwd: string): TaskRunnerConfig {
|
|
|
84
84
|
if (loaded?.task_areas) {
|
|
85
85
|
for (const [name, area] of Object.entries(loaded.task_areas)) {
|
|
86
86
|
const a = area as any;
|
|
87
|
-
|
|
87
|
+
const ta: TaskArea = {
|
|
88
88
|
path: a?.path || "",
|
|
89
89
|
prefix: a?.prefix || "",
|
|
90
90
|
context: a?.context || "",
|
|
91
91
|
};
|
|
92
|
+
// Parse repo_id (snake_case YAML key) into repoId for routing
|
|
93
|
+
if (a?.repo_id && typeof a.repo_id === "string" && a.repo_id.trim()) {
|
|
94
|
+
ta.repoId = a.repo_id.trim();
|
|
95
|
+
}
|
|
96
|
+
taskAreas[name] = ta;
|
|
92
97
|
}
|
|
93
98
|
}
|
|
94
99
|
return {
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Task discovery, PROMPT.md parsing, dependency resolution
|
|
3
|
-
* @module orch/discovery
|
|
4
|
-
*/
|
|
5
|
-
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "fs";
|
|
6
|
-
import { join, dirname, basename, resolve } from "path";
|
|
7
|
-
|
|
8
|
-
import
|
|
9
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Task discovery, PROMPT.md parsing, dependency resolution
|
|
3
|
+
* @module orch/discovery
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "fs";
|
|
6
|
+
import { join, dirname, basename, resolve } from "path";
|
|
7
|
+
|
|
8
|
+
import { FATAL_DISCOVERY_CODES } from "./types.ts";
|
|
9
|
+
import type { DiscoveryError, DiscoveryResult, ParsedTask, TaskArea, WorkspaceConfig } from "./types.ts";
|
|
10
|
+
|
|
10
11
|
// ── PROMPT.md Parsing ────────────────────────────────────────────────
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -184,6 +185,54 @@ export function parsePromptForOrchestrator(
|
|
|
184
185
|
}
|
|
185
186
|
}
|
|
186
187
|
|
|
188
|
+
// ── Extract execution target (repo ID) ──────────────────────
|
|
189
|
+
// Repo ID validation: lowercase alphanumeric + hyphens, starting with alnum
|
|
190
|
+
const REPO_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
191
|
+
|
|
192
|
+
let promptRepoId: string | undefined;
|
|
193
|
+
|
|
194
|
+
// Priority 1: Section-based "## Execution Target" with "Repo: <id>" line
|
|
195
|
+
// Capture everything from section header to the next heading or --- divider.
|
|
196
|
+
// We avoid \n$ (which in multiline mode matches blank lines) by using a two-pass
|
|
197
|
+
// approach: find the section start, then slice to the next section boundary.
|
|
198
|
+
const execTargetHeaderIdx = content.search(/^##\s+Execution Target\s*$/m);
|
|
199
|
+
let execTargetSectionBody: string | null = null;
|
|
200
|
+
if (execTargetHeaderIdx !== -1) {
|
|
201
|
+
const afterHeader = content.indexOf("\n", execTargetHeaderIdx);
|
|
202
|
+
if (afterHeader !== -1) {
|
|
203
|
+
const rest = content.slice(afterHeader + 1);
|
|
204
|
+
const nextSectionMatch = rest.search(/^##\s|^---/m);
|
|
205
|
+
execTargetSectionBody = nextSectionMatch !== -1
|
|
206
|
+
? rest.slice(0, nextSectionMatch)
|
|
207
|
+
: rest;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (execTargetSectionBody !== null) {
|
|
211
|
+
// Match "Repo: api" or "**Repo:** api" or "Repo: api" with whitespace
|
|
212
|
+
const repoLineMatch = execTargetSectionBody.match(
|
|
213
|
+
/^\s*\*?\*?Repo:?\*?\*?\s+(\S+)/mi,
|
|
214
|
+
);
|
|
215
|
+
if (repoLineMatch) {
|
|
216
|
+
const candidate = repoLineMatch[1].trim().toLowerCase();
|
|
217
|
+
if (REPO_ID_PATTERN.test(candidate)) {
|
|
218
|
+
promptRepoId = candidate;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Priority 2 (fallback): Inline "**Repo:** <id>" anywhere in content
|
|
224
|
+
if (!promptRepoId) {
|
|
225
|
+
const inlineRepoMatch = content.match(
|
|
226
|
+
/^\*\*Repo:\*\*\s+(\S+)/m,
|
|
227
|
+
);
|
|
228
|
+
if (inlineRepoMatch) {
|
|
229
|
+
const candidate = inlineRepoMatch[1].trim().toLowerCase();
|
|
230
|
+
if (REPO_ID_PATTERN.test(candidate)) {
|
|
231
|
+
promptRepoId = candidate;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
187
236
|
// ── Extract file scope ───────────────────────────────────────
|
|
188
237
|
const fileScope: string[] = [];
|
|
189
238
|
const fileScopeMatch = content.match(
|
|
@@ -214,12 +263,13 @@ export function parsePromptForOrchestrator(
|
|
|
214
263
|
promptPath: resolve(promptPath),
|
|
215
264
|
areaName,
|
|
216
265
|
status: "pending",
|
|
266
|
+
...(promptRepoId ? { promptRepoId } : {}),
|
|
217
267
|
},
|
|
218
268
|
error: null,
|
|
219
269
|
};
|
|
220
270
|
}
|
|
221
271
|
|
|
222
|
-
|
|
272
|
+
|
|
223
273
|
// ── Area Scanning ────────────────────────────────────────────────────
|
|
224
274
|
|
|
225
275
|
/**
|
|
@@ -291,7 +341,7 @@ export function scanAreaForTasks(
|
|
|
291
341
|
return { tasks, errors };
|
|
292
342
|
}
|
|
293
343
|
|
|
294
|
-
|
|
344
|
+
|
|
295
345
|
// ── Completed Task Set ───────────────────────────────────────────────
|
|
296
346
|
|
|
297
347
|
/**
|
|
@@ -361,7 +411,7 @@ export function buildCompletedTaskSet(areaPaths: string[]): Set<string> {
|
|
|
361
411
|
return completed;
|
|
362
412
|
}
|
|
363
413
|
|
|
364
|
-
|
|
414
|
+
|
|
365
415
|
// ── Argument Resolution ──────────────────────────────────────────────
|
|
366
416
|
|
|
367
417
|
/**
|
|
@@ -440,6 +490,8 @@ export interface DiscoveryOptions {
|
|
|
440
490
|
refreshDependencies?: boolean;
|
|
441
491
|
dependencySource?: "prompt" | "agent";
|
|
442
492
|
useDependencyCache?: boolean;
|
|
493
|
+
/** Workspace config for repo routing (null/undefined = repo mode, no routing). */
|
|
494
|
+
workspaceConfig?: WorkspaceConfig | null;
|
|
443
495
|
}
|
|
444
496
|
|
|
445
497
|
export interface DependencyCacheFile {
|
|
@@ -531,7 +583,7 @@ export function applyDependenciesFromCache(
|
|
|
531
583
|
return { applied };
|
|
532
584
|
}
|
|
533
585
|
|
|
534
|
-
|
|
586
|
+
|
|
535
587
|
// ── Task Registry ────────────────────────────────────────────────────
|
|
536
588
|
|
|
537
589
|
/**
|
|
@@ -637,7 +689,7 @@ export function buildTaskRegistry(
|
|
|
637
689
|
return { pending, completed, errors };
|
|
638
690
|
}
|
|
639
691
|
|
|
640
|
-
|
|
692
|
+
|
|
641
693
|
// ── Cross-Area Dependency Resolution ─────────────────────────────────
|
|
642
694
|
|
|
643
695
|
/** Candidate match for a dependency reference found in task areas. */
|
|
@@ -806,7 +858,93 @@ export function resolveDependencies(
|
|
|
806
858
|
return errors;
|
|
807
859
|
}
|
|
808
860
|
|
|
809
|
-
|
|
861
|
+
|
|
862
|
+
// ── Task-to-Repo Routing ─────────────────────────────────────────────
|
|
863
|
+
|
|
864
|
+
/** Repo ID validation: lowercase alphanumeric + hyphens, starting with alnum */
|
|
865
|
+
const ROUTING_REPO_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Resolve the target repo for each discovered task using the routing
|
|
869
|
+
* precedence chain:
|
|
870
|
+
*
|
|
871
|
+
* 1. `task.promptRepoId` — declared in PROMPT.md metadata
|
|
872
|
+
* 2. `taskArea.repoId` — area-level config from task-runner.yaml
|
|
873
|
+
* 3. `workspaceConfig.routing.defaultRepo` — workspace-level default
|
|
874
|
+
*
|
|
875
|
+
* Only applied in workspace mode (when `workspaceConfig` is provided).
|
|
876
|
+
* In repo mode this function is never called.
|
|
877
|
+
*
|
|
878
|
+
* Returns an array of DiscoveryError for routing failures:
|
|
879
|
+
* - TASK_REPO_UNRESOLVED: no source provided a repo ID
|
|
880
|
+
* - TASK_REPO_UNKNOWN: resolved repo ID is not in workspace repos map
|
|
881
|
+
*/
|
|
882
|
+
export function resolveTaskRouting(
|
|
883
|
+
discovery: DiscoveryResult,
|
|
884
|
+
taskAreas: Record<string, TaskArea>,
|
|
885
|
+
workspaceConfig: WorkspaceConfig,
|
|
886
|
+
): DiscoveryError[] {
|
|
887
|
+
const errors: DiscoveryError[] = [];
|
|
888
|
+
const validRepoIds = workspaceConfig.repos;
|
|
889
|
+
|
|
890
|
+
for (const task of discovery.pending.values()) {
|
|
891
|
+
// Precedence 1: prompt-declared repo
|
|
892
|
+
let resolvedId = task.promptRepoId;
|
|
893
|
+
let source = "prompt";
|
|
894
|
+
|
|
895
|
+
// Precedence 2: area-level repo
|
|
896
|
+
if (!resolvedId) {
|
|
897
|
+
const area = taskAreas[task.areaName];
|
|
898
|
+
if (area?.repoId) {
|
|
899
|
+
const candidate = area.repoId.trim().toLowerCase();
|
|
900
|
+
if (ROUTING_REPO_ID_PATTERN.test(candidate)) {
|
|
901
|
+
resolvedId = candidate;
|
|
902
|
+
source = "area";
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Precedence 3: workspace default repo
|
|
908
|
+
if (!resolvedId) {
|
|
909
|
+
resolvedId = workspaceConfig.routing.defaultRepo;
|
|
910
|
+
source = "default";
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// Validate resolution
|
|
914
|
+
if (!resolvedId) {
|
|
915
|
+
errors.push({
|
|
916
|
+
code: "TASK_REPO_UNRESOLVED",
|
|
917
|
+
message:
|
|
918
|
+
`Task ${task.taskId} has no resolved repo. ` +
|
|
919
|
+
`Add a Repo: field to the PROMPT, set repo_id on area "${task.areaName}", ` +
|
|
920
|
+
`or set routing.default_repo in the workspace config.`,
|
|
921
|
+
taskId: task.taskId,
|
|
922
|
+
taskPath: task.promptPath,
|
|
923
|
+
});
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
if (!validRepoIds.has(resolvedId)) {
|
|
928
|
+
errors.push({
|
|
929
|
+
code: "TASK_REPO_UNKNOWN",
|
|
930
|
+
message:
|
|
931
|
+
`Task ${task.taskId} resolved to repo "${resolvedId}" (via ${source}), ` +
|
|
932
|
+
`but no repo with that ID exists in the workspace config. ` +
|
|
933
|
+
`Known repos: ${[...validRepoIds.keys()].join(", ")}`,
|
|
934
|
+
taskId: task.taskId,
|
|
935
|
+
taskPath: task.promptPath,
|
|
936
|
+
});
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Attach resolved repo to the task
|
|
941
|
+
task.resolvedRepoId = resolvedId;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
return errors;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
|
|
810
948
|
// ── Discovery Pipeline (Public) ──────────────────────────────────────
|
|
811
949
|
|
|
812
950
|
/**
|
|
@@ -901,6 +1039,13 @@ export function runDiscovery(
|
|
|
901
1039
|
}
|
|
902
1040
|
}
|
|
903
1041
|
|
|
1042
|
+
// Step 6: Task-to-repo routing (workspace mode only)
|
|
1043
|
+
const workspaceConfig = options.workspaceConfig;
|
|
1044
|
+
if (workspaceConfig && workspaceConfig.mode === "workspace") {
|
|
1045
|
+
const routingErrors = resolveTaskRouting(discovery, taskAreas, workspaceConfig);
|
|
1046
|
+
discovery.errors.push(...routingErrors);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
904
1049
|
return discovery;
|
|
905
1050
|
}
|
|
906
1051
|
|
|
@@ -939,8 +1084,12 @@ export function formatDiscoveryResults(result: DiscoveryResult): string {
|
|
|
939
1084
|
task.dependencies.length > 0
|
|
940
1085
|
? ` → depends on: ${task.dependencies.join(", ")}`
|
|
941
1086
|
: "";
|
|
1087
|
+
const repo =
|
|
1088
|
+
task.resolvedRepoId
|
|
1089
|
+
? ` → repo: ${task.resolvedRepoId}`
|
|
1090
|
+
: "";
|
|
942
1091
|
lines.push(
|
|
943
|
-
` ${task.taskId} [${task.size}] ${task.taskName}${deps}`,
|
|
1092
|
+
` ${task.taskId} [${task.size}] ${task.taskName}${deps}${repo}`,
|
|
944
1093
|
);
|
|
945
1094
|
}
|
|
946
1095
|
}
|
|
@@ -949,22 +1098,9 @@ export function formatDiscoveryResults(result: DiscoveryResult): string {
|
|
|
949
1098
|
|
|
950
1099
|
// Show errors
|
|
951
1100
|
if (result.errors.length > 0) {
|
|
952
|
-
const
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
e.code === "DEP_UNRESOLVED" ||
|
|
956
|
-
e.code === "DEP_PENDING" ||
|
|
957
|
-
e.code === "DEP_AMBIGUOUS" ||
|
|
958
|
-
e.code === "PARSE_MISSING_ID",
|
|
959
|
-
);
|
|
960
|
-
const warnings = result.errors.filter(
|
|
961
|
-
(e) =>
|
|
962
|
-
e.code !== "DUPLICATE_ID" &&
|
|
963
|
-
e.code !== "DEP_UNRESOLVED" &&
|
|
964
|
-
e.code !== "DEP_PENDING" &&
|
|
965
|
-
e.code !== "DEP_AMBIGUOUS" &&
|
|
966
|
-
e.code !== "PARSE_MISSING_ID",
|
|
967
|
-
);
|
|
1101
|
+
const fatalCodes = new Set<string>(FATAL_DISCOVERY_CODES);
|
|
1102
|
+
const fatalErrors = result.errors.filter((e) => fatalCodes.has(e.code));
|
|
1103
|
+
const warnings = result.errors.filter((e) => !fatalCodes.has(e.code));
|
|
968
1104
|
|
|
969
1105
|
if (fatalErrors.length > 0) {
|
|
970
1106
|
lines.push("❌ Errors:");
|
|
@@ -13,10 +13,10 @@ import { mergeWave } from "./merge.ts";
|
|
|
13
13
|
import { ORCH_MESSAGES } from "./messages.ts";
|
|
14
14
|
import { deleteBatchState, loadBatchHistory, persistRuntimeState, saveBatchHistory, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts";
|
|
15
15
|
import { listOrchSessions } from "./sessions.ts";
|
|
16
|
-
import { generateBatchId } from "./types.ts";
|
|
17
|
-
import type { AllocatedLane, BatchHistorySummary, BatchTaskSummary, BatchWaveSummary, DiscoveryResult, LaneExecutionResult, LaneTaskOutcome, MergeWaveResult, OrchBatchPhase, OrchBatchRuntimeState, OrchestratorConfig, TaskRunnerConfig, TokenCounts } from "./types.ts";
|
|
16
|
+
import { FATAL_DISCOVERY_CODES, generateBatchId } from "./types.ts";
|
|
17
|
+
import type { AllocatedLane, BatchHistorySummary, BatchTaskSummary, BatchWaveSummary, DiscoveryResult, LaneExecutionResult, LaneTaskOutcome, MergeWaveResult, OrchBatchPhase, OrchBatchRuntimeState, OrchestratorConfig, TaskRunnerConfig, TokenCounts, WorkspaceConfig } from "./types.ts";
|
|
18
18
|
import { buildDependencyGraph, computeWaves, validateGraph } from "./waves.ts";
|
|
19
|
-
import { deleteBranchBestEffort, formatPreflightResults, listWorktrees, removeAllWorktrees, removeWorktree, runPreflight, safeResetWorktree, sleepSync } from "./worktree.ts";
|
|
19
|
+
import { deleteBranchBestEffort, forceCleanupWorktree, formatPreflightResults, listWorktrees, removeAllWorktrees, removeWorktree, runPreflight, safeResetWorktree, sleepSync } from "./worktree.ts";
|
|
20
20
|
|
|
21
21
|
// ── /orch Execution Engine ───────────────────────────────────────────
|
|
22
22
|
|
|
@@ -32,6 +32,7 @@ import { deleteBranchBestEffort, formatPreflightResults, listWorktrees, removeAl
|
|
|
32
32
|
* @param batchState - Mutable batch state (updated throughout execution)
|
|
33
33
|
* @param onNotify - Callback for user-facing messages
|
|
34
34
|
* @param onMonitorUpdate - Optional callback for dashboard updates
|
|
35
|
+
* @param workspaceConfig - Workspace configuration for repo routing (null = repo mode)
|
|
35
36
|
*/
|
|
36
37
|
export async function executeOrchBatch(
|
|
37
38
|
args: string,
|
|
@@ -41,6 +42,7 @@ export async function executeOrchBatch(
|
|
|
41
42
|
batchState: OrchBatchRuntimeState,
|
|
42
43
|
onNotify: (message: string, level: "info" | "warning" | "error") => void,
|
|
43
44
|
onMonitorUpdate?: MonitorUpdateCallback,
|
|
45
|
+
workspaceConfig?: WorkspaceConfig | null,
|
|
44
46
|
): Promise<void> {
|
|
45
47
|
const repoRoot = cwd;
|
|
46
48
|
|
|
@@ -95,23 +97,27 @@ export async function executeOrchBatch(
|
|
|
95
97
|
refreshDependencies: false,
|
|
96
98
|
dependencySource: orchConfig.dependencies.source,
|
|
97
99
|
useDependencyCache: orchConfig.dependencies.cache,
|
|
100
|
+
workspaceConfig: workspaceConfig ?? null,
|
|
98
101
|
});
|
|
99
102
|
onNotify(formatDiscoveryResults(discovery), discovery.errors.length > 0 ? "warning" : "info");
|
|
100
103
|
|
|
101
104
|
// Check for fatal errors
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
e.code === "DUPLICATE_ID" ||
|
|
105
|
-
e.code === "DEP_UNRESOLVED" ||
|
|
106
|
-
e.code === "DEP_PENDING" ||
|
|
107
|
-
e.code === "DEP_AMBIGUOUS" ||
|
|
108
|
-
e.code === "PARSE_MISSING_ID",
|
|
109
|
-
);
|
|
105
|
+
const fatalCodes = new Set<string>(FATAL_DISCOVERY_CODES);
|
|
106
|
+
const fatalErrors = discovery.errors.filter((e) => fatalCodes.has(e.code));
|
|
110
107
|
if (fatalErrors.length > 0) {
|
|
111
108
|
batchState.phase = "failed";
|
|
112
109
|
batchState.endedAt = Date.now();
|
|
113
110
|
batchState.errors.push("Discovery had fatal errors — cannot proceed");
|
|
114
111
|
onNotify("❌ Cannot execute due to discovery errors above.", "error");
|
|
112
|
+
const hasRoutingErrors = fatalErrors.some(
|
|
113
|
+
(e) => e.code === "TASK_REPO_UNRESOLVED" || e.code === "TASK_REPO_UNKNOWN",
|
|
114
|
+
);
|
|
115
|
+
if (hasRoutingErrors) {
|
|
116
|
+
onNotify(
|
|
117
|
+
"💡 Check PROMPT Repo: fields, area repo_id config, and routing.default_repo in workspace config.",
|
|
118
|
+
"info",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
115
121
|
return;
|
|
116
122
|
}
|
|
117
123
|
|
|
@@ -500,10 +506,14 @@ export async function executeOrchBatch(
|
|
|
500
506
|
removeWorktree(wt, repoRoot);
|
|
501
507
|
execLog("batch", batchState.batchId, `removed unrecoverable worktree for lane ${wt.laneNumber}`);
|
|
502
508
|
} catch (removeErr: unknown) {
|
|
503
|
-
execLog("batch", batchState.batchId, `failed
|
|
509
|
+
execLog("batch", batchState.batchId, `removeWorktree failed for lane ${wt.laneNumber}, attempting force cleanup`, {
|
|
504
510
|
error: removeErr instanceof Error ? removeErr.message : String(removeErr),
|
|
505
511
|
path: wt.path,
|
|
506
512
|
});
|
|
513
|
+
// Last resort: force-remove the directory and prune git worktree state.
|
|
514
|
+
// This handles cases where git has partially deregistered the worktree
|
|
515
|
+
// or undeletable files (e.g., Windows reserved names like "nul") block removal.
|
|
516
|
+
forceCleanupWorktree(wt, repoRoot, batchState.batchId);
|
|
507
517
|
}
|
|
508
518
|
} else {
|
|
509
519
|
execLog("batch", batchState.batchId, `worktree reset OK for lane ${wt.laneNumber}`);
|
|
@@ -118,7 +118,8 @@ export function buildLaneEnvVars(
|
|
|
118
118
|
if (promptNorm.startsWith(repoRootNorm + "/")) {
|
|
119
119
|
relativePath = promptNorm.slice(repoRootNorm.length + 1);
|
|
120
120
|
} else {
|
|
121
|
-
//
|
|
121
|
+
// External task folder (workspace mode): prompt path is outside repo root.
|
|
122
|
+
// Use the absolute path as-is — task-runner accepts absolute TASK_AUTOSTART paths.
|
|
122
123
|
relativePath = promptPath;
|
|
123
124
|
}
|
|
124
125
|
|
|
@@ -300,44 +301,122 @@ export function readTaskStatusTail(
|
|
|
300
301
|
}
|
|
301
302
|
|
|
302
303
|
/**
|
|
303
|
-
*
|
|
304
|
+
* Result of canonical task-folder path resolution.
|
|
304
305
|
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
306
|
+
* Encapsulates the resolved task folder, .DONE path, and STATUS.md path
|
|
307
|
+
* so callers don't need to re-derive them with inconsistent logic.
|
|
308
|
+
*/
|
|
309
|
+
export interface ResolvedTaskPaths {
|
|
310
|
+
/** Absolute path to the resolved task folder (may be in worktree or external) */
|
|
311
|
+
taskFolderResolved: string;
|
|
312
|
+
/** Absolute path to the .DONE file */
|
|
313
|
+
donePath: string;
|
|
314
|
+
/** Absolute path to the STATUS.md file */
|
|
315
|
+
statusPath: string;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Canonical task-folder path resolver.
|
|
307
320
|
*
|
|
308
|
-
*
|
|
321
|
+
* Single source of truth for translating a task folder path (as stored in
|
|
322
|
+
* ParsedTask) into the correct filesystem paths for .DONE and STATUS.md
|
|
323
|
+
* probing. Handles two cases:
|
|
324
|
+
*
|
|
325
|
+
* 1. **Task folder inside repoRoot** (monorepo / repo mode):
|
|
326
|
+
* Strip the repoRoot prefix to get a relative path, then join with
|
|
327
|
+
* worktreePath. This is the existing behavior — worktrees mirror the
|
|
328
|
+
* repo structure so the relative path is the same.
|
|
329
|
+
*
|
|
330
|
+
* 2. **Task folder outside repoRoot** (workspace mode with external tasks root):
|
|
331
|
+
* The task folder is not inside the execution repo. Use the absolute
|
|
332
|
+
* task folder path directly — the .DONE and STATUS.md files live in
|
|
333
|
+
* the canonical task folder, not in any worktree.
|
|
334
|
+
*
|
|
335
|
+
* Both branches include archive fallback: if the primary location doesn't
|
|
336
|
+
* exist, check `<parent>/archive/<taskDirName>/` for relocated task folders.
|
|
337
|
+
*
|
|
338
|
+
* @param taskFolder - Absolute task folder path (from ParsedTask.taskFolder)
|
|
309
339
|
* @param worktreePath - Absolute path to the lane worktree
|
|
310
340
|
* @param repoRoot - Absolute path to the main repository root
|
|
311
|
-
* @returns
|
|
341
|
+
* @returns Resolved paths for task folder, .DONE, and STATUS.md
|
|
312
342
|
*/
|
|
313
|
-
export function
|
|
343
|
+
export function resolveCanonicalTaskPaths(
|
|
314
344
|
taskFolder: string,
|
|
315
345
|
worktreePath: string,
|
|
316
346
|
repoRoot: string,
|
|
317
|
-
):
|
|
347
|
+
): ResolvedTaskPaths {
|
|
318
348
|
const repoRootNorm = resolve(repoRoot).replace(/\\/g, "/");
|
|
319
349
|
const folderNorm = resolve(taskFolder).replace(/\\/g, "/");
|
|
320
350
|
|
|
321
|
-
let
|
|
351
|
+
let resolvedFolder: string;
|
|
352
|
+
|
|
322
353
|
if (folderNorm.startsWith(repoRootNorm + "/")) {
|
|
323
|
-
|
|
354
|
+
// Case 1: Task folder is inside the repo root.
|
|
355
|
+
// Translate to equivalent path in the worktree.
|
|
356
|
+
const relativePath = folderNorm.slice(repoRootNorm.length + 1);
|
|
357
|
+
resolvedFolder = join(worktreePath, relativePath);
|
|
324
358
|
} else {
|
|
325
|
-
|
|
359
|
+
// Case 2: Task folder is outside the repo root (workspace mode).
|
|
360
|
+
// Use the absolute path directly — task state lives in the
|
|
361
|
+
// canonical task folder, not mirrored in any worktree.
|
|
362
|
+
resolvedFolder = resolve(taskFolder);
|
|
326
363
|
}
|
|
327
364
|
|
|
328
|
-
|
|
329
|
-
|
|
365
|
+
// Check primary location
|
|
366
|
+
const primaryDone = join(resolvedFolder, ".DONE");
|
|
367
|
+
const primaryStatus = join(resolvedFolder, "STATUS.md");
|
|
368
|
+
if (existsSync(primaryDone) || existsSync(primaryStatus)) {
|
|
369
|
+
return {
|
|
370
|
+
taskFolderResolved: resolvedFolder,
|
|
371
|
+
donePath: primaryDone,
|
|
372
|
+
statusPath: primaryStatus,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
330
375
|
|
|
331
|
-
//
|
|
376
|
+
// Archive fallback: worker may have archived the task folder during the
|
|
332
377
|
// "Documentation & Delivery" step, moving it under `.../archive/TASK-ID/`.
|
|
333
|
-
|
|
334
|
-
const parts =
|
|
335
|
-
const taskDirName = parts[parts.length - 1];
|
|
336
|
-
const
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
378
|
+
const resolvedNorm = resolve(resolvedFolder).replace(/\\/g, "/");
|
|
379
|
+
const parts = resolvedNorm.split("/");
|
|
380
|
+
const taskDirName = parts[parts.length - 1];
|
|
381
|
+
const parentDir = parts.slice(0, -1).join("/");
|
|
382
|
+
const archiveFolder = join(parentDir, "archive", taskDirName);
|
|
383
|
+
const archiveDone = join(archiveFolder, ".DONE");
|
|
384
|
+
const archiveStatus = join(archiveFolder, "STATUS.md");
|
|
385
|
+
|
|
386
|
+
if (existsSync(archiveDone) || existsSync(archiveStatus)) {
|
|
387
|
+
return {
|
|
388
|
+
taskFolderResolved: archiveFolder,
|
|
389
|
+
donePath: archiveDone,
|
|
390
|
+
statusPath: archiveStatus,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Return primary paths even if nothing exists yet (caller probes existsSync)
|
|
395
|
+
return {
|
|
396
|
+
taskFolderResolved: resolvedFolder,
|
|
397
|
+
donePath: primaryDone,
|
|
398
|
+
statusPath: primaryStatus,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Resolve the path to a task's .DONE file inside a worktree.
|
|
404
|
+
*
|
|
405
|
+
* Delegates to `resolveCanonicalTaskPaths` for consistent path resolution
|
|
406
|
+
* across repo mode (task folder inside repo) and workspace mode (external
|
|
407
|
+
* task folder).
|
|
408
|
+
*
|
|
409
|
+
* @param taskFolder - Absolute task folder path (from main repo)
|
|
410
|
+
* @param worktreePath - Absolute path to the lane worktree
|
|
411
|
+
* @param repoRoot - Absolute path to the main repository root
|
|
412
|
+
* @returns Absolute path to the .DONE file in the worktree
|
|
413
|
+
*/
|
|
414
|
+
export function resolveTaskDonePath(
|
|
415
|
+
taskFolder: string,
|
|
416
|
+
worktreePath: string,
|
|
417
|
+
repoRoot: string,
|
|
418
|
+
): string {
|
|
419
|
+
return resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot).donePath;
|
|
341
420
|
}
|
|
342
421
|
|
|
343
422
|
/**
|
|
@@ -467,8 +546,9 @@ export async function pollUntilTaskComplete(
|
|
|
467
546
|
): Promise<{ status: LaneTaskStatus; exitReason: string; doneFileFound: boolean }> {
|
|
468
547
|
const sessionName = lane.tmuxSessionName;
|
|
469
548
|
const laneId = lane.laneId;
|
|
470
|
-
const
|
|
471
|
-
const
|
|
549
|
+
const resolved = resolveCanonicalTaskPaths(task.task.taskFolder, lane.worktreePath, repoRoot);
|
|
550
|
+
const donePath = resolved.donePath;
|
|
551
|
+
const statusPath = resolved.statusPath;
|
|
472
552
|
const laneLogPath = resolveLaneLogPath(lane, task);
|
|
473
553
|
|
|
474
554
|
execLog(laneId, task.taskId, "polling for completion", {
|
|
@@ -788,30 +868,12 @@ export function parseWorktreeStatusMd(
|
|
|
788
868
|
worktreePath: string,
|
|
789
869
|
repoRoot: string,
|
|
790
870
|
): { parsed: ParsedWorktreeStatus | null; error: string | null } {
|
|
791
|
-
//
|
|
792
|
-
const
|
|
793
|
-
const
|
|
794
|
-
|
|
795
|
-
let relativePath: string;
|
|
796
|
-
if (folderNorm.startsWith(repoRootNorm + "/")) {
|
|
797
|
-
relativePath = folderNorm.slice(repoRootNorm.length + 1);
|
|
798
|
-
} else {
|
|
799
|
-
relativePath = taskFolder;
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
let statusPath = join(worktreePath, relativePath, "STATUS.md");
|
|
871
|
+
// Use canonical resolver for consistent path translation
|
|
872
|
+
const resolved = resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot);
|
|
873
|
+
const statusPath = resolved.statusPath;
|
|
803
874
|
|
|
804
875
|
if (!existsSync(statusPath)) {
|
|
805
|
-
|
|
806
|
-
const parts = relativePath.replace(/\\/g, "/").split("/");
|
|
807
|
-
const taskDirName = parts[parts.length - 1];
|
|
808
|
-
const parentParts = parts.slice(0, -1);
|
|
809
|
-
const archiveStatusPath = join(worktreePath, ...parentParts, "archive", taskDirName, "STATUS.md");
|
|
810
|
-
if (existsSync(archiveStatusPath)) {
|
|
811
|
-
statusPath = archiveStatusPath;
|
|
812
|
-
} else {
|
|
813
|
-
return { parsed: null, error: `STATUS.md not found at ${statusPath}` };
|
|
814
|
-
}
|
|
876
|
+
return { parsed: null, error: `STATUS.md not found at ${statusPath}` };
|
|
815
877
|
}
|
|
816
878
|
|
|
817
879
|
let content: string;
|