taskplane 0.22.8 → 0.22.9
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.
|
@@ -6,7 +6,7 @@ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "
|
|
|
6
6
|
import { join, dirname, basename, resolve } from "path";
|
|
7
7
|
|
|
8
8
|
import { FATAL_DISCOVERY_CODES } from "./types.ts";
|
|
9
|
-
import type { DiscoveryError, DiscoveryResult, ParsedTask, TaskArea, WorkspaceConfig } from "./types.ts";
|
|
9
|
+
import type { DiscoveryError, DiscoveryResult, ParsedTask, PromptSegmentDagMetadata, TaskArea, WorkspaceConfig } from "./types.ts";
|
|
10
10
|
|
|
11
11
|
// ── PROMPT.md Parsing ────────────────────────────────────────────────
|
|
12
12
|
|
|
@@ -56,6 +56,301 @@ export function normalizeDependencyReference(raw: string): string {
|
|
|
56
56
|
return parsed.areaName ? `${parsed.areaName}/${parsed.taskId}` : parsed.taskId;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
const SEGMENT_REPO_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
60
|
+
|
|
61
|
+
function normalizeSegmentRepoToken(raw: string): string {
|
|
62
|
+
let token = raw.trim();
|
|
63
|
+
token = token.replace(/^`(.+)`$/, "$1").trim();
|
|
64
|
+
token = token.replace(/^\*\*(.+)\*\*$/, "$1").trim();
|
|
65
|
+
return token.toLowerCase();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface ParsedSegmentDagBody {
|
|
69
|
+
metadata: PromptSegmentDagMetadata | null;
|
|
70
|
+
error: DiscoveryError | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parse optional explicit segment DAG metadata from `## Segment DAG`.
|
|
75
|
+
*
|
|
76
|
+
* Supported v1 syntax:
|
|
77
|
+
*
|
|
78
|
+
* ## Segment DAG
|
|
79
|
+
* Repos:
|
|
80
|
+
* - api
|
|
81
|
+
* - web-client
|
|
82
|
+
* Edges:
|
|
83
|
+
* - api -> web-client
|
|
84
|
+
*
|
|
85
|
+
* Notes:
|
|
86
|
+
* - `Repos:` / `Edges:` keys accept markdown decoration (`**Repos:**`) and whitespace.
|
|
87
|
+
* - Repo IDs are normalized to lowercase and validated against routing repo ID rules.
|
|
88
|
+
* - Unknown edge endpoints (not present in explicit repo list) fail fast.
|
|
89
|
+
* - Self-edges and cycles fail fast with `SEGMENT_DAG_INVALID`.
|
|
90
|
+
*/
|
|
91
|
+
function parseSegmentDagMetadata(
|
|
92
|
+
content: string,
|
|
93
|
+
taskId: string,
|
|
94
|
+
promptPath: string,
|
|
95
|
+
): ParsedSegmentDagBody {
|
|
96
|
+
const headerMatch = content.match(/^##\s+Segment DAG\s*$/im);
|
|
97
|
+
if (!headerMatch || headerMatch.index === undefined) {
|
|
98
|
+
return { metadata: null, error: null };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const headerIndex = headerMatch.index;
|
|
102
|
+
const afterHeaderIndex = content.indexOf("\n", headerIndex);
|
|
103
|
+
if (afterHeaderIndex === -1) {
|
|
104
|
+
return { metadata: null, error: null };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const rest = content.slice(afterHeaderIndex + 1);
|
|
108
|
+
const nextBoundary = rest.search(/^##\s|^---/m);
|
|
109
|
+
const body = nextBoundary !== -1 ? rest.slice(0, nextBoundary) : rest;
|
|
110
|
+
|
|
111
|
+
const repoIds: string[] = [];
|
|
112
|
+
const repoSet = new Set<string>();
|
|
113
|
+
const edgePairs = new Set<string>();
|
|
114
|
+
const edges: Array<{ fromRepoId: string; toRepoId: string }> = [];
|
|
115
|
+
const baseLine = content.slice(0, afterHeaderIndex + 1).split(/\r?\n/).length;
|
|
116
|
+
|
|
117
|
+
let mode: "repos" | "edges" | null = null;
|
|
118
|
+
const lines = body.split(/\r?\n/);
|
|
119
|
+
|
|
120
|
+
for (let i = 0; i < lines.length; i++) {
|
|
121
|
+
const rawLine = lines[i];
|
|
122
|
+
const trimmed = rawLine.trim();
|
|
123
|
+
if (!trimmed) continue;
|
|
124
|
+
|
|
125
|
+
if (/^\*?\*?Repos:?\*?\*?\s*$/i.test(trimmed)) {
|
|
126
|
+
mode = "repos";
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (/^\*?\*?Edges:?\*?\*?\s*$/i.test(trimmed)) {
|
|
130
|
+
mode = "edges";
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!mode) {
|
|
135
|
+
return {
|
|
136
|
+
metadata: null,
|
|
137
|
+
error: {
|
|
138
|
+
code: "SEGMENT_DAG_INVALID",
|
|
139
|
+
message:
|
|
140
|
+
`Task ${taskId} has malformed ## Segment DAG metadata at line ${baseLine + i}: ` +
|
|
141
|
+
`expected a Repos: or Edges: subsection header before entries.`,
|
|
142
|
+
taskId,
|
|
143
|
+
taskPath: promptPath,
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const bulletMatch = rawLine.match(/^\s*[-*]\s+(.+)$/);
|
|
149
|
+
if (!bulletMatch) {
|
|
150
|
+
return {
|
|
151
|
+
metadata: null,
|
|
152
|
+
error: {
|
|
153
|
+
code: "SEGMENT_DAG_INVALID",
|
|
154
|
+
message:
|
|
155
|
+
`Task ${taskId} has malformed ## Segment DAG metadata at line ${baseLine + i}: ` +
|
|
156
|
+
`expected a bullet entry ("- ...").`,
|
|
157
|
+
taskId,
|
|
158
|
+
taskPath: promptPath,
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const entry = bulletMatch[1].trim();
|
|
164
|
+
if (!entry) continue;
|
|
165
|
+
|
|
166
|
+
if (mode === "repos") {
|
|
167
|
+
if (entry.includes("->")) {
|
|
168
|
+
return {
|
|
169
|
+
metadata: null,
|
|
170
|
+
error: {
|
|
171
|
+
code: "SEGMENT_DAG_INVALID",
|
|
172
|
+
message:
|
|
173
|
+
`Task ${taskId} has malformed ## Segment DAG metadata at line ${baseLine + i}: ` +
|
|
174
|
+
`repo list entries must be a single repo ID.`,
|
|
175
|
+
taskId,
|
|
176
|
+
taskPath: promptPath,
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
const repoId = normalizeSegmentRepoToken(entry);
|
|
181
|
+
if (!SEGMENT_REPO_ID_PATTERN.test(repoId)) {
|
|
182
|
+
return {
|
|
183
|
+
metadata: null,
|
|
184
|
+
error: {
|
|
185
|
+
code: "SEGMENT_DAG_INVALID",
|
|
186
|
+
message:
|
|
187
|
+
`Task ${taskId} has invalid repo ID "${entry}" in ## Segment DAG at line ${baseLine + i}. ` +
|
|
188
|
+
`Repo IDs must match /^[a-z0-9][a-z0-9-]*$/.`,
|
|
189
|
+
taskId,
|
|
190
|
+
taskPath: promptPath,
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
if (!repoSet.has(repoId)) {
|
|
195
|
+
repoSet.add(repoId);
|
|
196
|
+
repoIds.push(repoId);
|
|
197
|
+
}
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const edgeMatch = entry.match(/^(.+?)\s*->\s*(.+)$/);
|
|
202
|
+
if (!edgeMatch) {
|
|
203
|
+
return {
|
|
204
|
+
metadata: null,
|
|
205
|
+
error: {
|
|
206
|
+
code: "SEGMENT_DAG_INVALID",
|
|
207
|
+
message:
|
|
208
|
+
`Task ${taskId} has malformed edge "${entry}" in ## Segment DAG at line ${baseLine + i}. ` +
|
|
209
|
+
`Expected format: <repo-a> -> <repo-b>.`,
|
|
210
|
+
taskId,
|
|
211
|
+
taskPath: promptPath,
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const fromRepoId = normalizeSegmentRepoToken(edgeMatch[1]);
|
|
217
|
+
const toRepoId = normalizeSegmentRepoToken(edgeMatch[2]);
|
|
218
|
+
if (!SEGMENT_REPO_ID_PATTERN.test(fromRepoId) || !SEGMENT_REPO_ID_PATTERN.test(toRepoId)) {
|
|
219
|
+
return {
|
|
220
|
+
metadata: null,
|
|
221
|
+
error: {
|
|
222
|
+
code: "SEGMENT_DAG_INVALID",
|
|
223
|
+
message:
|
|
224
|
+
`Task ${taskId} has malformed edge "${entry}" in ## Segment DAG at line ${baseLine + i}. ` +
|
|
225
|
+
`Repo IDs must match /^[a-z0-9][a-z0-9-]*$/.`,
|
|
226
|
+
taskId,
|
|
227
|
+
taskPath: promptPath,
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
if (fromRepoId === toRepoId) {
|
|
232
|
+
return {
|
|
233
|
+
metadata: null,
|
|
234
|
+
error: {
|
|
235
|
+
code: "SEGMENT_DAG_INVALID",
|
|
236
|
+
message:
|
|
237
|
+
`Task ${taskId} has self-edge "${fromRepoId} -> ${toRepoId}" in ## Segment DAG at line ${baseLine + i}.`,
|
|
238
|
+
taskId,
|
|
239
|
+
taskPath: promptPath,
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const edgeKey = `${fromRepoId}->${toRepoId}`;
|
|
245
|
+
if (!edgePairs.has(edgeKey)) {
|
|
246
|
+
edgePairs.add(edgeKey);
|
|
247
|
+
edges.push({ fromRepoId, toRepoId });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (repoIds.length === 0 && edges.length === 0) {
|
|
252
|
+
return { metadata: null, error: null };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
for (const edge of edges) {
|
|
256
|
+
if (!repoSet.has(edge.fromRepoId)) {
|
|
257
|
+
return {
|
|
258
|
+
metadata: null,
|
|
259
|
+
error: {
|
|
260
|
+
code: "SEGMENT_REPO_UNKNOWN",
|
|
261
|
+
message:
|
|
262
|
+
`Task ${taskId} has edge endpoint repo "${edge.fromRepoId}" in ## Segment DAG that is not declared in Repos:.`,
|
|
263
|
+
taskId,
|
|
264
|
+
taskPath: promptPath,
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
if (!repoSet.has(edge.toRepoId)) {
|
|
269
|
+
return {
|
|
270
|
+
metadata: null,
|
|
271
|
+
error: {
|
|
272
|
+
code: "SEGMENT_REPO_UNKNOWN",
|
|
273
|
+
message:
|
|
274
|
+
`Task ${taskId} has edge endpoint repo "${edge.toRepoId}" in ## Segment DAG that is not declared in Repos:.`,
|
|
275
|
+
taskId,
|
|
276
|
+
taskPath: promptPath,
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const sortedEdges = [...edges].sort((a, b) => {
|
|
283
|
+
if (a.fromRepoId !== b.fromRepoId) return a.fromRepoId.localeCompare(b.fromRepoId);
|
|
284
|
+
return a.toRepoId.localeCompare(b.toRepoId);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const adjacency = new Map<string, string[]>();
|
|
288
|
+
for (const repoId of repoIds) {
|
|
289
|
+
adjacency.set(repoId, []);
|
|
290
|
+
}
|
|
291
|
+
for (const edge of sortedEdges) {
|
|
292
|
+
adjacency.get(edge.fromRepoId)!.push(edge.toRepoId);
|
|
293
|
+
}
|
|
294
|
+
for (const neighbors of adjacency.values()) {
|
|
295
|
+
neighbors.sort();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const visited = new Set<string>();
|
|
299
|
+
const stack = new Set<string>();
|
|
300
|
+
const path: string[] = [];
|
|
301
|
+
let cycle: string[] | null = null;
|
|
302
|
+
|
|
303
|
+
function dfs(repoId: string): void {
|
|
304
|
+
if (cycle) return;
|
|
305
|
+
visited.add(repoId);
|
|
306
|
+
stack.add(repoId);
|
|
307
|
+
path.push(repoId);
|
|
308
|
+
|
|
309
|
+
const neighbors = adjacency.get(repoId) || [];
|
|
310
|
+
for (const next of neighbors) {
|
|
311
|
+
if (cycle) return;
|
|
312
|
+
if (!visited.has(next)) {
|
|
313
|
+
dfs(next);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (stack.has(next)) {
|
|
317
|
+
const start = path.indexOf(next);
|
|
318
|
+
cycle = [...path.slice(start), next];
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
path.pop();
|
|
324
|
+
stack.delete(repoId);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
for (const repoId of [...repoIds].sort()) {
|
|
328
|
+
if (!visited.has(repoId)) dfs(repoId);
|
|
329
|
+
if (cycle) break;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (cycle) {
|
|
333
|
+
return {
|
|
334
|
+
metadata: null,
|
|
335
|
+
error: {
|
|
336
|
+
code: "SEGMENT_DAG_INVALID",
|
|
337
|
+
message:
|
|
338
|
+
`Task ${taskId} has cyclic ## Segment DAG metadata: ${cycle.join(" -> ")}.`,
|
|
339
|
+
taskId,
|
|
340
|
+
taskPath: promptPath,
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
metadata: {
|
|
347
|
+
repoIds,
|
|
348
|
+
edges: sortedEdges,
|
|
349
|
+
},
|
|
350
|
+
error: null,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
59
354
|
/**
|
|
60
355
|
* Parse a PROMPT.md file and extract orchestrator-relevant metadata.
|
|
61
356
|
*
|
|
@@ -255,6 +550,16 @@ export function parsePromptForOrchestrator(
|
|
|
255
550
|
}
|
|
256
551
|
}
|
|
257
552
|
|
|
553
|
+
// ── Extract optional explicit segment DAG metadata ──────────
|
|
554
|
+
const segmentDagResult = parseSegmentDagMetadata(content, taskId, resolve(promptPath));
|
|
555
|
+
if (segmentDagResult.error) {
|
|
556
|
+
return {
|
|
557
|
+
task: null,
|
|
558
|
+
error: segmentDagResult.error,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
const explicitSegmentDag = segmentDagResult.metadata;
|
|
562
|
+
|
|
258
563
|
return {
|
|
259
564
|
task: {
|
|
260
565
|
taskId,
|
|
@@ -268,6 +573,7 @@ export function parsePromptForOrchestrator(
|
|
|
268
573
|
areaName,
|
|
269
574
|
status: "pending",
|
|
270
575
|
...(promptRepoId ? { promptRepoId } : {}),
|
|
576
|
+
...(explicitSegmentDag ? { explicitSegmentDag } : {}),
|
|
271
577
|
},
|
|
272
578
|
error: null,
|
|
273
579
|
};
|
|
@@ -893,6 +1199,22 @@ export function resolveTaskRouting(
|
|
|
893
1199
|
const strictMode = workspaceConfig.routing.strict === true;
|
|
894
1200
|
|
|
895
1201
|
for (const task of discovery.pending.values()) {
|
|
1202
|
+
// ── Explicit segment DAG repo validation (workspace IDs) ─
|
|
1203
|
+
if (task.explicitSegmentDag) {
|
|
1204
|
+
const unknownRepos = task.explicitSegmentDag.repoIds.filter((repoId) => !validRepoIds.has(repoId));
|
|
1205
|
+
if (unknownRepos.length > 0) {
|
|
1206
|
+
errors.push({
|
|
1207
|
+
code: "SEGMENT_REPO_UNKNOWN",
|
|
1208
|
+
message:
|
|
1209
|
+
`Task ${task.taskId} declares unknown repo ID(s) in ## Segment DAG: ${unknownRepos.join(", ")}. ` +
|
|
1210
|
+
`Known repos: ${[...validRepoIds.keys()].join(", ")}`,
|
|
1211
|
+
taskId: task.taskId,
|
|
1212
|
+
taskPath: task.promptPath,
|
|
1213
|
+
});
|
|
1214
|
+
continue;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
896
1218
|
// ── Strict mode enforcement ──────────────────────────────
|
|
897
1219
|
// When strict routing is enabled, every task MUST declare an
|
|
898
1220
|
// explicit execution target in PROMPT.md. Area-level and
|
|
@@ -60,6 +60,26 @@ export interface OrchestratorConfig {
|
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/** Stable segment identifier: `<taskId>::<repoId>` */
|
|
64
|
+
export type SegmentId = `${string}::${string}`;
|
|
65
|
+
|
|
66
|
+
/** How an intra-task segment edge was produced (for observability/debugging). */
|
|
67
|
+
export type SegmentEdgeProvenance = "explicit" | "inferred";
|
|
68
|
+
|
|
69
|
+
/** Repo-scoped edge parsed from optional `## Segment DAG` prompt metadata. */
|
|
70
|
+
export interface PromptSegmentDagEdge {
|
|
71
|
+
fromRepoId: string;
|
|
72
|
+
toRepoId: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Optional explicit segment metadata parsed from PROMPT.md. */
|
|
76
|
+
export interface PromptSegmentDagMetadata {
|
|
77
|
+
/** Repo IDs participating in this task's segment graph, first-seen order. */
|
|
78
|
+
repoIds: string[];
|
|
79
|
+
/** Directed repo-level edges, sorted by `fromRepoId` then `toRepoId`. */
|
|
80
|
+
edges: PromptSegmentDagEdge[];
|
|
81
|
+
}
|
|
82
|
+
|
|
63
83
|
/** A parsed task from PROMPT.md, enriched for orchestrator use */
|
|
64
84
|
export interface ParsedTask {
|
|
65
85
|
taskId: string;
|
|
@@ -76,8 +96,61 @@ export interface ParsedTask {
|
|
|
76
96
|
promptRepoId?: string;
|
|
77
97
|
/** Resolved repo ID after routing precedence (workspace mode only). Undefined in repo mode. */
|
|
78
98
|
resolvedRepoId?: string;
|
|
99
|
+
/** Optional explicit segment DAG metadata from `## Segment DAG`. */
|
|
100
|
+
explicitSegmentDag?: PromptSegmentDagMetadata;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Build a stable segment ID from task + repo identity (`<taskId>::<repoId>`). */
|
|
104
|
+
export function buildSegmentId(taskId: string, repoId: string): SegmentId {
|
|
105
|
+
return `${taskId}::${repoId}` as SegmentId;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** One repo-scoped segment node for a task. */
|
|
109
|
+
export interface TaskSegmentNode {
|
|
110
|
+
segmentId: SegmentId;
|
|
111
|
+
taskId: string;
|
|
112
|
+
repoId: string;
|
|
113
|
+
/**
|
|
114
|
+
* Deterministic segment order within a task (0-indexed).
|
|
115
|
+
* Stable tie-break: repoId lexical order.
|
|
116
|
+
*/
|
|
117
|
+
order: number;
|
|
79
118
|
}
|
|
80
119
|
|
|
120
|
+
/** Directed edge between two segment nodes in the same task. */
|
|
121
|
+
export interface TaskSegmentEdge {
|
|
122
|
+
fromSegmentId: SegmentId;
|
|
123
|
+
toSegmentId: SegmentId;
|
|
124
|
+
provenance: SegmentEdgeProvenance;
|
|
125
|
+
/** Optional explanation of why this edge exists (debug/telemetry aid). */
|
|
126
|
+
reason?: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Deterministic segment plan for one task.
|
|
131
|
+
*
|
|
132
|
+
* Ordering contract:
|
|
133
|
+
* - `segments`: sorted by `order`, then `repoId`
|
|
134
|
+
* - `edges`: sorted by `fromSegmentId`, then `toSegmentId`
|
|
135
|
+
*/
|
|
136
|
+
export interface TaskSegmentPlan {
|
|
137
|
+
taskId: string;
|
|
138
|
+
segments: TaskSegmentNode[];
|
|
139
|
+
edges: TaskSegmentEdge[];
|
|
140
|
+
/**
|
|
141
|
+
* explicit-dag: parsed from prompt metadata
|
|
142
|
+
* inferred-sequential: deterministic fallback inference
|
|
143
|
+
* repo-singleton: repo mode fallback (`resolvedRepoId ?? "default"`)
|
|
144
|
+
*/
|
|
145
|
+
mode: "explicit-dag" | "inferred-sequential" | "repo-singleton";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* TaskId-keyed segment plans.
|
|
150
|
+
* Iteration order must be deterministic: sort task IDs lexicographically.
|
|
151
|
+
*/
|
|
152
|
+
export type TaskSegmentPlanMap = Map<string, TaskSegmentPlan>;
|
|
153
|
+
|
|
81
154
|
/** A wave: a group of tasks whose dependencies are all satisfied */
|
|
82
155
|
export interface WaveAssignment {
|
|
83
156
|
waveNumber: number;
|
|
@@ -403,7 +476,9 @@ export interface DiscoveryError {
|
|
|
403
476
|
| "DEP_SOURCE_FALLBACK"
|
|
404
477
|
| "TASK_REPO_UNRESOLVED"
|
|
405
478
|
| "TASK_REPO_UNKNOWN"
|
|
406
|
-
| "TASK_ROUTING_STRICT"
|
|
479
|
+
| "TASK_ROUTING_STRICT"
|
|
480
|
+
| "SEGMENT_DAG_INVALID"
|
|
481
|
+
| "SEGMENT_REPO_UNKNOWN";
|
|
407
482
|
message: string;
|
|
408
483
|
taskPath?: string;
|
|
409
484
|
taskId?: string;
|
|
@@ -425,6 +500,8 @@ export const FATAL_DISCOVERY_CODES: ReadonlyArray<DiscoveryError["code"]> = [
|
|
|
425
500
|
"TASK_REPO_UNRESOLVED",
|
|
426
501
|
"TASK_REPO_UNKNOWN",
|
|
427
502
|
"TASK_ROUTING_STRICT",
|
|
503
|
+
"SEGMENT_DAG_INVALID",
|
|
504
|
+
"SEGMENT_REPO_UNKNOWN",
|
|
428
505
|
] as const;
|
|
429
506
|
|
|
430
507
|
/** Result of the full discovery pipeline */
|
|
@@ -457,6 +534,8 @@ export interface GraphValidationResult {
|
|
|
457
534
|
export interface WaveComputationResult {
|
|
458
535
|
waves: WaveAssignment[];
|
|
459
536
|
errors: DiscoveryError[];
|
|
537
|
+
/** Optional task→segment planning map (TP-080, additive contract). */
|
|
538
|
+
segmentPlans?: TaskSegmentPlanMap;
|
|
460
539
|
}
|
|
461
540
|
|
|
462
541
|
|
|
@@ -6,8 +6,8 @@ import { join } from "path";
|
|
|
6
6
|
|
|
7
7
|
import { parseDependencyReference } from "./discovery.ts";
|
|
8
8
|
import { resolveOperatorId } from "./naming.ts";
|
|
9
|
-
import { AllocationError, getTaskDurationMinutes } from "./types.ts";
|
|
10
|
-
import type { AllocatedLane, AllocatedTask, AllocateLanesResult, AllocationErrorCode, DependencyGraph, DiscoveryError, GraphValidationResult, LaneAssignment, OrchestratorConfig, ParsedTask, WaveAssignment, WaveComputationResult, WorkspaceConfig, WorktreeInfo } from "./types.ts";
|
|
9
|
+
import { AllocationError, buildSegmentId, getTaskDurationMinutes } from "./types.ts";
|
|
10
|
+
import type { AllocatedLane, AllocatedTask, AllocateLanesResult, AllocationErrorCode, DependencyGraph, DiscoveryError, GraphValidationResult, LaneAssignment, OrchestratorConfig, ParsedTask, TaskSegmentPlan, TaskSegmentPlanMap, WaveAssignment, WaveComputationResult, WorkspaceConfig, WorktreeInfo } from "./types.ts";
|
|
11
11
|
import { getCurrentBranch } from "./git.ts";
|
|
12
12
|
import { ensureLaneWorktrees, removeAllWorktrees, removeWorktree } from "./worktree.ts";
|
|
13
13
|
|
|
@@ -606,6 +606,196 @@ export function resolveBaseBranch(
|
|
|
606
606
|
}
|
|
607
607
|
|
|
608
608
|
|
|
609
|
+
// ── Segment Planning (TP-080) ───────────────────────────────────────
|
|
610
|
+
|
|
611
|
+
const SEGMENT_REPO_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
612
|
+
const INFERRED_LINEAR_REASON = "inferred:first-appearance-linear-chain";
|
|
613
|
+
|
|
614
|
+
function normalizeRepoIdCandidate(raw: string): string | null {
|
|
615
|
+
const candidate = raw.trim().toLowerCase();
|
|
616
|
+
if (!SEGMENT_REPO_ID_PATTERN.test(candidate)) return null;
|
|
617
|
+
return candidate;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function collectKnownRepoIds(pending: Map<string, ParsedTask>): Set<string> {
|
|
621
|
+
const known = new Set<string>();
|
|
622
|
+
for (const task of pending.values()) {
|
|
623
|
+
if (task.resolvedRepoId) {
|
|
624
|
+
const repoId = normalizeRepoIdCandidate(task.resolvedRepoId);
|
|
625
|
+
if (repoId) known.add(repoId);
|
|
626
|
+
}
|
|
627
|
+
if (task.explicitSegmentDag) {
|
|
628
|
+
for (const repoIdRaw of task.explicitSegmentDag.repoIds) {
|
|
629
|
+
const repoId = normalizeRepoIdCandidate(repoIdRaw);
|
|
630
|
+
if (repoId) known.add(repoId);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return known;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function extractRepoPrefixFromFileScope(fileScopeEntry: string): string | null {
|
|
638
|
+
const normalized = fileScopeEntry.replace(/\\/g, "/").trim();
|
|
639
|
+
if (!normalized) return null;
|
|
640
|
+
const firstSegment = normalized.split("/")[0]?.trim();
|
|
641
|
+
if (!firstSegment) return null;
|
|
642
|
+
return normalizeRepoIdCandidate(firstSegment);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
interface InferredRepoOrder {
|
|
646
|
+
repoIds: string[];
|
|
647
|
+
usedFallback: boolean;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Build deterministic repo ordering for inferred segment plans.
|
|
652
|
+
*
|
|
653
|
+
* Signal precedence:
|
|
654
|
+
* 1) file scope repo prefixes (first appearance)
|
|
655
|
+
* 2) dependency task repos (first appearance)
|
|
656
|
+
* 3) fallback to `resolvedRepoId`, then synthetic `default`
|
|
657
|
+
*/
|
|
658
|
+
export function inferTaskRepoOrder(
|
|
659
|
+
task: ParsedTask,
|
|
660
|
+
pending: Map<string, ParsedTask>,
|
|
661
|
+
knownRepoIds: Set<string>,
|
|
662
|
+
): InferredRepoOrder {
|
|
663
|
+
const firstAppearance = new Map<string, number>();
|
|
664
|
+
let cursor = 0;
|
|
665
|
+
|
|
666
|
+
function record(repoIdRaw: string, requireKnown = false): string | null {
|
|
667
|
+
const repoId = normalizeRepoIdCandidate(repoIdRaw);
|
|
668
|
+
if (!repoId) return null;
|
|
669
|
+
if (requireKnown && knownRepoIds.size > 0 && !knownRepoIds.has(repoId)) return null;
|
|
670
|
+
if (!firstAppearance.has(repoId)) {
|
|
671
|
+
firstAppearance.set(repoId, cursor++);
|
|
672
|
+
}
|
|
673
|
+
return repoId;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
let hasPrimarySignal = false;
|
|
677
|
+
|
|
678
|
+
for (const scopeEntry of task.fileScope) {
|
|
679
|
+
if (knownRepoIds.size === 0) {
|
|
680
|
+
// Repo-mode guard: without known workspace repo IDs, fileScope prefixes like
|
|
681
|
+
// "src/" or "lib/" are ambiguous and should not create synthetic segments.
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
const repoId = extractRepoPrefixFromFileScope(scopeEntry);
|
|
685
|
+
if (!repoId) continue;
|
|
686
|
+
if (record(repoId, true) !== null) {
|
|
687
|
+
hasPrimarySignal = true;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
for (const depRaw of task.dependencies) {
|
|
692
|
+
const depId = parseDependencyReference(depRaw).taskId;
|
|
693
|
+
const depTask = pending.get(depId);
|
|
694
|
+
if (depTask?.resolvedRepoId && record(depTask.resolvedRepoId, true) !== null) {
|
|
695
|
+
hasPrimarySignal = true;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (!hasPrimarySignal) {
|
|
700
|
+
const fallback = normalizeRepoIdCandidate(task.resolvedRepoId ?? "") || "default";
|
|
701
|
+
return {
|
|
702
|
+
repoIds: [fallback],
|
|
703
|
+
usedFallback: true,
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (task.resolvedRepoId) {
|
|
708
|
+
record(task.resolvedRepoId, true);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const repoIds = [...firstAppearance.entries()]
|
|
712
|
+
.sort((a, b) => {
|
|
713
|
+
if (a[1] !== b[1]) return a[1] - b[1];
|
|
714
|
+
return a[0].localeCompare(b[0]);
|
|
715
|
+
})
|
|
716
|
+
.map(([repoId]) => repoId);
|
|
717
|
+
|
|
718
|
+
return {
|
|
719
|
+
repoIds,
|
|
720
|
+
usedFallback: false,
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function sortSegmentEdges<T extends { fromSegmentId: string; toSegmentId: string }>(
|
|
725
|
+
edges: T[],
|
|
726
|
+
): T[] {
|
|
727
|
+
return [...edges].sort((a, b) => {
|
|
728
|
+
if (a.fromSegmentId !== b.fromSegmentId) return a.fromSegmentId.localeCompare(b.fromSegmentId);
|
|
729
|
+
return a.toSegmentId.localeCompare(b.toSegmentId);
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function buildSegmentNodes(taskId: string, repoIds: string[]) {
|
|
734
|
+
const nodes = repoIds.map((repoId, order) => ({
|
|
735
|
+
segmentId: buildSegmentId(taskId, repoId),
|
|
736
|
+
taskId,
|
|
737
|
+
repoId,
|
|
738
|
+
order,
|
|
739
|
+
}));
|
|
740
|
+
return nodes.sort((a, b) => (a.order - b.order) || a.repoId.localeCompare(b.repoId));
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
export function buildSegmentPlanForTask(
|
|
744
|
+
task: ParsedTask,
|
|
745
|
+
pending: Map<string, ParsedTask>,
|
|
746
|
+
knownRepoIds: Set<string>,
|
|
747
|
+
): TaskSegmentPlan {
|
|
748
|
+
if (task.explicitSegmentDag) {
|
|
749
|
+
const repoIds = [...task.explicitSegmentDag.repoIds];
|
|
750
|
+
const segments = buildSegmentNodes(task.taskId, repoIds);
|
|
751
|
+
const edges = sortSegmentEdges(
|
|
752
|
+
task.explicitSegmentDag.edges.map((edge) => ({
|
|
753
|
+
fromSegmentId: buildSegmentId(task.taskId, edge.fromRepoId),
|
|
754
|
+
toSegmentId: buildSegmentId(task.taskId, edge.toRepoId),
|
|
755
|
+
provenance: "explicit" as const,
|
|
756
|
+
reason: "prompt:segment-dag",
|
|
757
|
+
})),
|
|
758
|
+
);
|
|
759
|
+
return {
|
|
760
|
+
taskId: task.taskId,
|
|
761
|
+
segments,
|
|
762
|
+
edges,
|
|
763
|
+
mode: "explicit-dag",
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const inferred = inferTaskRepoOrder(task, pending, knownRepoIds);
|
|
768
|
+
const segments = buildSegmentNodes(task.taskId, inferred.repoIds);
|
|
769
|
+
const edges = sortSegmentEdges(
|
|
770
|
+
segments.slice(0, -1).map((segment, idx) => ({
|
|
771
|
+
fromSegmentId: segment.segmentId,
|
|
772
|
+
toSegmentId: segments[idx + 1].segmentId,
|
|
773
|
+
provenance: "inferred" as const,
|
|
774
|
+
reason: INFERRED_LINEAR_REASON,
|
|
775
|
+
})),
|
|
776
|
+
);
|
|
777
|
+
|
|
778
|
+
return {
|
|
779
|
+
taskId: task.taskId,
|
|
780
|
+
segments,
|
|
781
|
+
edges,
|
|
782
|
+
mode: inferred.usedFallback ? "repo-singleton" : "inferred-sequential",
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** Build a deterministic taskId→segmentPlan map for the whole pending set. */
|
|
787
|
+
export function buildTaskSegmentPlans(pending: Map<string, ParsedTask>): TaskSegmentPlanMap {
|
|
788
|
+
const knownRepoIds = collectKnownRepoIds(pending);
|
|
789
|
+
const plans: TaskSegmentPlanMap = new Map();
|
|
790
|
+
for (const taskId of [...pending.keys()].sort()) {
|
|
791
|
+
const task = pending.get(taskId);
|
|
792
|
+
if (!task) continue;
|
|
793
|
+
plans.set(taskId, buildSegmentPlanForTask(task, pending, knownRepoIds));
|
|
794
|
+
}
|
|
795
|
+
return plans;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
|
|
609
799
|
// ── Lane Assignment ──────────────────────────────────────────────────
|
|
610
800
|
|
|
611
801
|
/**
|
|
@@ -1181,6 +1371,9 @@ export function computeWaveAssignments(
|
|
|
1181
1371
|
return { waves: [], errors: waveErrors };
|
|
1182
1372
|
}
|
|
1183
1373
|
|
|
1374
|
+
// Step 3.5: Build additive segment planning output (deterministic map)
|
|
1375
|
+
const segmentPlans = buildTaskSegmentPlans(pending);
|
|
1376
|
+
|
|
1184
1377
|
// Step 4: Assign tasks to lanes within each wave
|
|
1185
1378
|
const waveAssignments: WaveAssignment[] = [];
|
|
1186
1379
|
for (let i = 0; i < rawWaves.length; i++) {
|
|
@@ -1199,5 +1392,5 @@ export function computeWaveAssignments(
|
|
|
1199
1392
|
});
|
|
1200
1393
|
}
|
|
1201
1394
|
|
|
1202
|
-
return { waves: waveAssignments, errors };
|
|
1395
|
+
return { waves: waveAssignments, errors, segmentPlans };
|
|
1203
1396
|
}
|
package/package.json
CHANGED
|
@@ -70,12 +70,11 @@ Use the source branch and merge message from the merge request.
|
|
|
70
70
|
|
|
71
71
|
### Step 4: Verification
|
|
72
72
|
|
|
73
|
-
Run each verification command from the merge request
|
|
73
|
+
Run each verification command from the merge request's `## Verification Commands`
|
|
74
|
+
section **exactly as written**. Do NOT substitute your own test commands — the
|
|
75
|
+
merge request contains the project's actual test command.
|
|
74
76
|
|
|
75
|
-
|
|
76
|
-
npm test # Unit/integration checks
|
|
77
|
-
npm run build # Build/compile checks
|
|
78
|
-
```
|
|
77
|
+
If the verification section is empty, skip verification and proceed with the merge.
|
|
79
78
|
|
|
80
79
|
**If verification passes:** Write result with `status: "SUCCESS"` (or
|
|
81
80
|
`"CONFLICT_RESOLVED"` if conflicts were auto-resolved).
|