feat(gsd): ADR-004 — derived-graph reactive task execution (#1546)
Add reactive (graph-derived parallel) task execution within slices. When enabled via preferences, the dispatch table derives a task dependency graph from IO annotations in task plans and dispatches multiple ready, non-conflicting tasks in parallel via subagent. Architecture: - Graph derivation happens at dispatch time (auto-dispatch.ts) - A new reactive-execute prompt instructs the agent to use subagent parallel mode to dispatch all currently-ready tasks - The auto-loop treats reactive-execute as a single unit type - After agent_end, the orchestrator checks which tasks completed and loops New files: - reactive-graph.ts: pure graph derivation, ready-set resolution, conflict detection, deadlock detection, IO loader, state persistence - prompts/reactive-execute.md: prompt template for parallel dispatch - tests/reactive-graph.test.ts: 22 unit tests for graph functions - tests/reactive-executor.test.ts: 11 integration tests for dispatch rules, preferences validation, state persistence, re-entry Modified files: - types.ts: TaskIO, DerivedTaskNode, ReactiveExecutionConfig, ReactiveExecutionState interfaces - files.ts: parseTaskPlanIO() extracts IO from task plan sections - preferences-types.ts: reactive_execution config + known keys - preferences-validation.ts: validation with range checks - auto-dispatch.ts: new reactive-execute dispatch rule - auto-prompts.ts: buildReactiveExecutePrompt() - auto-recovery.ts: artifact verification for reactive-execute - auto-post-unit.ts: reactive state cleanup on slice completion Backward compatible: disabled by default, falls through to sequential execution when disabled, ambiguous, or only 1 task is ready.
This commit is contained in:
parent
39cd932abb
commit
567751471a
12 changed files with 1306 additions and 1 deletions
|
|
@ -38,6 +38,7 @@ import {
|
|||
buildRunUatPrompt,
|
||||
buildReassessRoadmapPrompt,
|
||||
buildRewriteDocsPrompt,
|
||||
buildReactiveExecutePrompt,
|
||||
checkNeedsReassessment,
|
||||
checkNeedsRunUat,
|
||||
} from "./auto-prompts.js";
|
||||
|
|
@ -309,6 +310,83 @@ const DISPATCH_RULES: DispatchRule[] = [
|
|||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "executing → reactive-execute (parallel dispatch)",
|
||||
match: async ({ state, mid, midTitle, basePath, prefs }) => {
|
||||
if (state.phase !== "executing" || !state.activeTask) return null;
|
||||
if (!state.activeSlice) return null; // fall through
|
||||
|
||||
// Only activate when reactive_execution is explicitly enabled
|
||||
const reactiveConfig = prefs?.reactive_execution;
|
||||
if (!reactiveConfig?.enabled) return null;
|
||||
|
||||
const sid = state.activeSlice.id;
|
||||
const sTitle = state.activeSlice.title;
|
||||
const maxParallel = reactiveConfig.max_parallel ?? 2;
|
||||
|
||||
// Dry-run mode: max_parallel=1 means graph is derived and logged but
|
||||
// execution remains sequential
|
||||
if (maxParallel <= 1) return null;
|
||||
|
||||
try {
|
||||
const {
|
||||
loadSliceTaskIO,
|
||||
deriveTaskGraph,
|
||||
isGraphAmbiguous,
|
||||
getReadyTasks,
|
||||
chooseNonConflictingSubset,
|
||||
graphMetrics,
|
||||
} = await import("./reactive-graph.js");
|
||||
|
||||
const taskIO = await loadSliceTaskIO(basePath, mid, sid);
|
||||
if (taskIO.length < 2) return null; // single task, no point
|
||||
|
||||
const graph = deriveTaskGraph(taskIO);
|
||||
|
||||
// Ambiguous graph → fall through to sequential
|
||||
if (isGraphAmbiguous(graph)) return null;
|
||||
|
||||
const completed = new Set(graph.filter((n) => n.done).map((n) => n.id));
|
||||
const readyIds = getReadyTasks(graph, completed, new Set());
|
||||
|
||||
// Only activate reactive dispatch when >1 task is ready
|
||||
if (readyIds.length <= 1) return null;
|
||||
|
||||
const selected = chooseNonConflictingSubset(
|
||||
readyIds,
|
||||
graph,
|
||||
maxParallel,
|
||||
new Set(),
|
||||
);
|
||||
if (selected.length <= 1) return null;
|
||||
|
||||
// Log graph metrics for observability
|
||||
const metrics = graphMetrics(graph);
|
||||
process.stderr.write(
|
||||
`gsd-reactive: ${mid}/${sid} graph — tasks:${metrics.taskCount} edges:${metrics.edgeCount} ` +
|
||||
`ready:${metrics.readySetSize} dispatching:${selected.length} ambiguous:${metrics.ambiguous}\n`,
|
||||
);
|
||||
|
||||
return {
|
||||
action: "dispatch",
|
||||
unitType: "reactive-execute",
|
||||
unitId: `${mid}/${sid}/reactive`,
|
||||
prompt: await buildReactiveExecutePrompt(
|
||||
mid,
|
||||
midTitle,
|
||||
sid,
|
||||
sTitle,
|
||||
selected,
|
||||
basePath,
|
||||
),
|
||||
};
|
||||
} catch (err) {
|
||||
// Non-fatal — fall through to sequential execution
|
||||
process.stderr.write(`gsd-reactive: graph derivation failed: ${(err as Error).message}\n`);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "executing → execute-task (recover missing task plan → plan-slice)",
|
||||
match: async ({ state, mid, midTitle, basePath }) => {
|
||||
|
|
|
|||
|
|
@ -217,6 +217,20 @@ export async function postUnitPreVerification(pctx: PostUnitContext): Promise<"d
|
|||
}
|
||||
}
|
||||
|
||||
// Reactive state cleanup on slice completion
|
||||
if (s.currentUnit.type === "complete-slice") {
|
||||
try {
|
||||
const parts = s.currentUnit.id.split("/");
|
||||
const [mid, sid] = parts;
|
||||
if (mid && sid) {
|
||||
const { clearReactiveState } = await import("./reactive-graph.js");
|
||||
clearReactiveState(s.basePath, mid, sid);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
// Post-triage: execute actionable resolutions
|
||||
if (s.currentUnit.type === "triage-captures") {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1234,6 +1234,74 @@ export async function buildReassessRoadmapPrompt(
|
|||
});
|
||||
}
|
||||
|
||||
// ─── Reactive Execute Prompt ──────────────────────────────────────────────
|
||||
|
||||
export async function buildReactiveExecutePrompt(
|
||||
mid: string, midTitle: string, sid: string, sTitle: string,
|
||||
readyTaskIds: string[], base: string,
|
||||
): Promise<string> {
|
||||
const { loadSliceTaskIO, deriveTaskGraph, graphMetrics } = await import("./reactive-graph.js");
|
||||
|
||||
// Build graph for context
|
||||
const taskIO = await loadSliceTaskIO(base, mid, sid);
|
||||
const graph = deriveTaskGraph(taskIO);
|
||||
const metrics = graphMetrics(graph);
|
||||
|
||||
// Build graph context section
|
||||
const graphLines: string[] = [];
|
||||
for (const node of graph) {
|
||||
const status = node.done ? "✅ done" : readyTaskIds.includes(node.id) ? "🟢 ready" : "⏳ waiting";
|
||||
const deps = node.dependsOn.length > 0 ? ` (depends on: ${node.dependsOn.join(", ")})` : "";
|
||||
graphLines.push(`- **${node.id}: ${node.title}** — ${status}${deps}`);
|
||||
if (node.outputFiles.length > 0) {
|
||||
graphLines.push(` - Outputs: ${node.outputFiles.map(f => `\`${f}\``).join(", ")}`);
|
||||
}
|
||||
}
|
||||
const graphContext = [
|
||||
`Tasks: ${metrics.taskCount}, Edges: ${metrics.edgeCount}, Ready: ${metrics.readySetSize}`,
|
||||
"",
|
||||
...graphLines,
|
||||
].join("\n");
|
||||
|
||||
// Build individual subagent prompts for each ready task
|
||||
const subagentSections: string[] = [];
|
||||
const readyTaskListLines: string[] = [];
|
||||
|
||||
for (const tid of readyTaskIds) {
|
||||
const node = graph.find((n) => n.id === tid);
|
||||
const tTitle = node?.title ?? tid;
|
||||
readyTaskListLines.push(`- **${tid}: ${tTitle}**`);
|
||||
|
||||
// Build a full execute-task prompt for this task (reuse existing builder)
|
||||
const taskPrompt = await buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, base);
|
||||
|
||||
subagentSections.push([
|
||||
`### ${tid}: ${tTitle}`,
|
||||
"",
|
||||
"Use this as the prompt for a `subagent` call:",
|
||||
"",
|
||||
"```",
|
||||
taskPrompt,
|
||||
"```",
|
||||
].join("\n"));
|
||||
}
|
||||
|
||||
const inlinedTemplates = inlineTemplate("task-summary", "Task Summary");
|
||||
|
||||
return loadPrompt("reactive-execute", {
|
||||
workingDirectory: base,
|
||||
milestoneId: mid,
|
||||
milestoneTitle: midTitle,
|
||||
sliceId: sid,
|
||||
sliceTitle: sTitle,
|
||||
graphContext,
|
||||
readyTaskCount: String(readyTaskIds.length),
|
||||
readyTaskList: readyTaskListLines.join("\n"),
|
||||
subagentPrompts: subagentSections.join("\n\n---\n\n"),
|
||||
inlinedTemplates,
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildRewriteDocsPrompt(
|
||||
mid: string, midTitle: string,
|
||||
activeSlice: { id: string; title: string } | null,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
resolveSlicePath,
|
||||
resolveSliceFile,
|
||||
resolveTasksDir,
|
||||
resolveTaskFiles,
|
||||
relMilestoneFile,
|
||||
relSliceFile,
|
||||
relSlicePath,
|
||||
|
|
@ -110,6 +111,9 @@ export function resolveExpectedArtifactPath(
|
|||
}
|
||||
case "rewrite-docs":
|
||||
return null;
|
||||
case "reactive-execute":
|
||||
// Reactive execute produces multiple task summaries — verified separately
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
@ -148,6 +152,20 @@ export function verifyExpectedArtifact(
|
|||
return !content.includes("**Scope:** active");
|
||||
}
|
||||
|
||||
// Reactive-execute: verify that at least one new task summary was written.
|
||||
// The unitId is "{mid}/{sid}/reactive" — extract mid and sid to check.
|
||||
if (unitType === "reactive-execute") {
|
||||
const parts = unitId.split("/");
|
||||
const mid = parts[0];
|
||||
const sid = parts[1];
|
||||
if (!mid || !sid) return false;
|
||||
const tDir = resolveTasksDir(base, mid, sid);
|
||||
if (!tDir) return false;
|
||||
const summaryFiles = resolveTaskFiles(tDir, "SUMMARY");
|
||||
// At least one summary file should exist
|
||||
return summaryFiles.length > 0;
|
||||
}
|
||||
|
||||
const absPath = resolveExpectedArtifactPath(unitType, unitId, base);
|
||||
// For unit types with no verifiable artifact (null path), the parent directory
|
||||
// is missing on disk — treat as stale completion state so the key gets evicted (#313).
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import type {
|
|||
Summary, SummaryFrontmatter, SummaryRequires, FileModified,
|
||||
Continue, ContinueFrontmatter, ContinueStatus,
|
||||
RequirementCounts,
|
||||
TaskIO,
|
||||
SecretsManifest, SecretsManifestEntry, SecretsManifestEntryStatus,
|
||||
ManifestStatus,
|
||||
} from './types.js';
|
||||
|
|
@ -724,6 +725,50 @@ export function countMustHavesMentionedInSummary(
|
|||
return count;
|
||||
}
|
||||
|
||||
// ─── Task Plan IO Extractor ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract input and output file paths from a task plan's `## Inputs` and
|
||||
* `## Expected Output` sections. Looks for backtick-wrapped file paths on
|
||||
* each line (e.g. `` `src/foo.ts` ``).
|
||||
*
|
||||
* Returns empty arrays for missing/empty sections — callers should treat
|
||||
* tasks with no IO as ambiguous (sequential fallback trigger).
|
||||
*/
|
||||
export function parseTaskPlanIO(content: string): { inputFiles: string[]; outputFiles: string[] } {
|
||||
const backtickPathRegex = /`([^`]+)`/g;
|
||||
|
||||
function extractPaths(sectionText: string | null): string[] {
|
||||
if (!sectionText) return [];
|
||||
const paths: string[] = [];
|
||||
for (const line of sectionText.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
let match: RegExpExecArray | null;
|
||||
backtickPathRegex.lastIndex = 0;
|
||||
while ((match = backtickPathRegex.exec(trimmed)) !== null) {
|
||||
const candidate = match[1];
|
||||
// Filter out things that look like code tokens rather than file paths
|
||||
// (e.g. `true`, `false`, `npm run test`). A file path has at least one
|
||||
// dot or slash.
|
||||
if (candidate.includes("/") || candidate.includes(".")) {
|
||||
paths.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
const [, body] = splitFrontmatter(content);
|
||||
const inputSection = extractSection(body, "Inputs");
|
||||
const outputSection = extractSection(body, "Expected Output");
|
||||
|
||||
return {
|
||||
inputFiles: extractPaths(inputSection),
|
||||
outputFiles: extractPaths(outputSection),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── UAT Type Extractor ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import type {
|
|||
ParallelConfig,
|
||||
CompressionStrategy,
|
||||
ContextSelectionMode,
|
||||
ReactiveExecutionConfig,
|
||||
} from "./types.js";
|
||||
import type { DynamicRoutingConfig } from "./model-router.js";
|
||||
|
||||
|
|
@ -86,12 +87,13 @@ export const KNOWN_PREFERENCE_KEYS = new Set<string>([
|
|||
"compression_strategy",
|
||||
"context_selection",
|
||||
"widget_mode",
|
||||
"reactive_execution",
|
||||
]);
|
||||
|
||||
/** Canonical list of all dispatch unit types. */
|
||||
export const KNOWN_UNIT_TYPES = [
|
||||
"research-milestone", "plan-milestone", "research-slice", "plan-slice",
|
||||
"execute-task", "complete-slice", "replan-slice", "reassess-roadmap",
|
||||
"execute-task", "reactive-execute", "complete-slice", "replan-slice", "reassess-roadmap",
|
||||
"run-uat", "complete-milestone",
|
||||
] as const;
|
||||
export type UnitType = (typeof KNOWN_UNIT_TYPES)[number];
|
||||
|
|
@ -215,6 +217,8 @@ export interface GSDPreferences {
|
|||
context_selection?: ContextSelectionMode;
|
||||
/** Default widget display mode for auto-mode dashboard. "full" | "small" | "min" | "off". Default: "full". */
|
||||
widget_mode?: "full" | "small" | "min" | "off";
|
||||
/** Reactive (graph-derived parallel) task execution within slices. Disabled by default. */
|
||||
reactive_execution?: ReactiveExecutionConfig;
|
||||
}
|
||||
|
||||
export interface LoadedGSDPreferences {
|
||||
|
|
|
|||
|
|
@ -496,6 +496,47 @@ export function validatePreferences(preferences: GSDPreferences): {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── Reactive Execution ─────────────────────────────────────────────────
|
||||
if (preferences.reactive_execution !== undefined) {
|
||||
if (typeof preferences.reactive_execution === "object" && preferences.reactive_execution !== null) {
|
||||
const re = preferences.reactive_execution as unknown as Record<string, unknown>;
|
||||
const validRe: Record<string, unknown> = {};
|
||||
|
||||
if (re.enabled !== undefined) {
|
||||
if (typeof re.enabled === "boolean") validRe.enabled = re.enabled;
|
||||
else errors.push("reactive_execution.enabled must be a boolean");
|
||||
}
|
||||
if (re.max_parallel !== undefined) {
|
||||
const mp = typeof re.max_parallel === "number" ? re.max_parallel : Number(re.max_parallel);
|
||||
if (Number.isFinite(mp) && mp >= 1 && mp <= 8) {
|
||||
validRe.max_parallel = Math.floor(mp);
|
||||
} else {
|
||||
errors.push("reactive_execution.max_parallel must be a number between 1 and 8");
|
||||
}
|
||||
}
|
||||
if (re.isolation_mode !== undefined) {
|
||||
if (re.isolation_mode === "same-tree") {
|
||||
validRe.isolation_mode = "same-tree";
|
||||
} else {
|
||||
errors.push('reactive_execution.isolation_mode must be "same-tree"');
|
||||
}
|
||||
}
|
||||
|
||||
const knownReKeys = new Set(["enabled", "max_parallel", "isolation_mode"]);
|
||||
for (const key of Object.keys(re)) {
|
||||
if (!knownReKeys.has(key)) {
|
||||
warnings.push(`unknown reactive_execution key "${key}" — ignored`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(validRe).length > 0) {
|
||||
validated.reactive_execution = validRe as unknown as import("./types.js").ReactiveExecutionConfig;
|
||||
}
|
||||
} else {
|
||||
errors.push("reactive_execution must be an object");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Verification Preferences ───────────────────────────────────────────
|
||||
if (preferences.verification_commands !== undefined) {
|
||||
if (Array.isArray(preferences.verification_commands)) {
|
||||
|
|
|
|||
41
src/resources/extensions/gsd/prompts/reactive-execute.md
Normal file
41
src/resources/extensions/gsd/prompts/reactive-execute.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Reactive Task Execution — Parallel Dispatch
|
||||
|
||||
**Working directory:** `{{workingDirectory}}`
|
||||
**Milestone:** {{milestoneId}} — {{milestoneTitle}}
|
||||
**Slice:** {{sliceId}} — {{sliceTitle}}
|
||||
|
||||
## Mission
|
||||
|
||||
You are executing **multiple tasks in parallel** for this slice. The task graph below shows which tasks are ready for simultaneous execution based on their input/output dependencies.
|
||||
|
||||
**Critical rule:** Use the `subagent` tool in **parallel mode** to dispatch all ready tasks simultaneously. Each subagent gets a self-contained execute-task prompt. After all subagents return, verify each task's outputs and write summaries.
|
||||
|
||||
## Task Dependency Graph
|
||||
|
||||
{{graphContext}}
|
||||
|
||||
## Ready Tasks for Parallel Dispatch
|
||||
|
||||
{{readyTaskCount}} tasks are ready for parallel execution:
|
||||
|
||||
{{readyTaskList}}
|
||||
|
||||
## Execution Protocol
|
||||
|
||||
1. **Dispatch all ready tasks** using `subagent` in parallel mode. Each subagent prompt is provided below.
|
||||
2. **Wait for all subagents** to complete.
|
||||
3. **Verify each task's outputs** — check that expected files were created/modified and that verification commands pass.
|
||||
4. **Write task summaries** for each completed task using the task-summary template.
|
||||
5. **Mark completed tasks** as done in the slice plan (checkbox `[x]`).
|
||||
6. **Commit** all changes with a clear message covering the parallel batch.
|
||||
|
||||
If any subagent fails:
|
||||
- Write a summary for the failed task with `blocker_discovered: true`
|
||||
- Continue marking the successful tasks as done
|
||||
- The orchestrator will handle re-dispatch on the next iteration
|
||||
|
||||
## Subagent Prompts
|
||||
|
||||
{{subagentPrompts}}
|
||||
|
||||
{{inlinedTemplates}}
|
||||
289
src/resources/extensions/gsd/reactive-graph.ts
Normal file
289
src/resources/extensions/gsd/reactive-graph.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
/**
|
||||
* Reactive Task Graph — derives dependency edges from task plan IO signatures.
|
||||
*
|
||||
* Pure functions that build a DAG from task IO intersections and resolve
|
||||
* which tasks are currently ready for parallel dispatch. Used by the
|
||||
* reactive-execute dispatch path (ADR-004).
|
||||
*
|
||||
* Graph derivation and resolution functions are pure (no filesystem access).
|
||||
* The `loadSliceTaskIO` loader at the bottom is the only async/IO function.
|
||||
*/
|
||||
|
||||
import type { TaskIO, DerivedTaskNode, ReactiveExecutionState } from "./types.js";
|
||||
import { loadFile, parsePlan, parseTaskPlanIO } from "./files.js";
|
||||
import { resolveTasksDir, resolveTaskFiles } from "./paths.js";
|
||||
import { join } from "node:path";
|
||||
import { loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js";
|
||||
import { existsSync, unlinkSync } from "node:fs";
|
||||
|
||||
// ─── Graph Construction ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a dependency graph from task IO signatures.
|
||||
*
|
||||
* A task T_b depends on T_a when any of T_b's inputFiles appear in T_a's
|
||||
* outputFiles. Self-references are excluded.
|
||||
*
|
||||
* Tasks are returned in the same order as the input array.
|
||||
*/
|
||||
export function deriveTaskGraph(tasks: TaskIO[]): DerivedTaskNode[] {
|
||||
// Build output → producer lookup
|
||||
const outputToProducer = new Map<string, string[]>();
|
||||
for (const task of tasks) {
|
||||
for (const outFile of task.outputFiles) {
|
||||
const existing = outputToProducer.get(outFile);
|
||||
if (existing) {
|
||||
existing.push(task.id);
|
||||
} else {
|
||||
outputToProducer.set(outFile, [task.id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tasks.map((task) => {
|
||||
const deps = new Set<string>();
|
||||
for (const inFile of task.inputFiles) {
|
||||
const producers = outputToProducer.get(inFile);
|
||||
if (producers) {
|
||||
for (const pid of producers) {
|
||||
if (pid !== task.id) deps.add(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
...task,
|
||||
dependsOn: [...deps].sort(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Ready Set Resolution ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return task IDs whose dependencies are all in `completed`.
|
||||
* Excludes tasks that are already done or in-flight.
|
||||
*/
|
||||
export function getReadyTasks(
|
||||
graph: DerivedTaskNode[],
|
||||
completed: Set<string>,
|
||||
inFlight: Set<string>,
|
||||
): string[] {
|
||||
return graph
|
||||
.filter((node) => {
|
||||
if (node.done || completed.has(node.id) || inFlight.has(node.id)) return false;
|
||||
return node.dependsOn.every((dep) => completed.has(dep));
|
||||
})
|
||||
.map((node) => node.id);
|
||||
}
|
||||
|
||||
// ─── Conflict-Free Subset Selection ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Greedy selection of non-conflicting tasks up to `maxParallel`.
|
||||
*
|
||||
* Two tasks conflict if they share any outputFile. We also exclude tasks
|
||||
* whose outputs overlap with `inFlightOutputs` (files being written by
|
||||
* tasks currently in progress).
|
||||
*/
|
||||
export function chooseNonConflictingSubset(
|
||||
readyIds: string[],
|
||||
graph: DerivedTaskNode[],
|
||||
maxParallel: number,
|
||||
inFlightOutputs: Set<string>,
|
||||
): string[] {
|
||||
const nodeMap = new Map(graph.map((n) => [n.id, n]));
|
||||
const claimed = new Set(inFlightOutputs);
|
||||
const selected: string[] = [];
|
||||
|
||||
for (const id of readyIds) {
|
||||
if (selected.length >= maxParallel) break;
|
||||
const node = nodeMap.get(id);
|
||||
if (!node) continue;
|
||||
|
||||
// Check for output overlap with already-selected or in-flight
|
||||
const conflicts = node.outputFiles.some((f) => claimed.has(f));
|
||||
if (conflicts) continue;
|
||||
|
||||
// Claim this task's outputs
|
||||
for (const f of node.outputFiles) claimed.add(f);
|
||||
selected.push(id);
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
// ─── Graph Quality Checks ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns true if any incomplete task has 0 inputFiles AND 0 outputFiles.
|
||||
*
|
||||
* An ambiguous graph means IO annotations are too sparse to derive reliable
|
||||
* edges — the dispatcher should fall back to sequential execution.
|
||||
*/
|
||||
export function isGraphAmbiguous(graph: DerivedTaskNode[]): boolean {
|
||||
return graph.some(
|
||||
(node) =>
|
||||
!node.done &&
|
||||
node.inputFiles.length === 0 &&
|
||||
node.outputFiles.length === 0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect deadlock: no tasks are ready and none are in-flight, yet incomplete
|
||||
* tasks remain. This indicates a circular dependency or impossible state.
|
||||
*/
|
||||
export function detectDeadlock(
|
||||
graph: DerivedTaskNode[],
|
||||
completed: Set<string>,
|
||||
inFlight: Set<string>,
|
||||
): boolean {
|
||||
const incomplete = graph.filter(
|
||||
(n) => !n.done && !completed.has(n.id) && !inFlight.has(n.id),
|
||||
);
|
||||
if (incomplete.length === 0) return false; // all done
|
||||
if (inFlight.size > 0) return false; // something is running, wait for it
|
||||
|
||||
// Nothing in flight, but incomplete tasks remain — check if any are ready
|
||||
const ready = getReadyTasks(graph, completed, inFlight);
|
||||
return ready.length === 0;
|
||||
}
|
||||
|
||||
// ─── Graph Metrics ────────────────────────────────────────────────────────
|
||||
|
||||
/** Compute summary metrics for logging. */
|
||||
export function graphMetrics(graph: DerivedTaskNode[]): {
|
||||
taskCount: number;
|
||||
edgeCount: number;
|
||||
readySetSize: number;
|
||||
ambiguous: boolean;
|
||||
} {
|
||||
const completed = new Set(graph.filter((n) => n.done).map((n) => n.id));
|
||||
const ready = getReadyTasks(graph, completed, new Set());
|
||||
const edgeCount = graph.reduce((sum, n) => sum + n.dependsOn.length, 0);
|
||||
|
||||
return {
|
||||
taskCount: graph.length,
|
||||
edgeCount,
|
||||
readySetSize: ready.length,
|
||||
ambiguous: isGraphAmbiguous(graph),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── IO Loader (async, filesystem) ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load TaskIO for all tasks in a slice by reading the slice plan (for done
|
||||
* status and task IDs) and individual task plan files (for IO sections).
|
||||
*
|
||||
* Returns [] when the slice plan or tasks directory doesn't exist.
|
||||
*/
|
||||
export async function loadSliceTaskIO(
|
||||
basePath: string,
|
||||
mid: string,
|
||||
sid: string,
|
||||
): Promise<TaskIO[]> {
|
||||
const { resolveSliceFile } = await import("./paths.js");
|
||||
const slicePlanPath = resolveSliceFile(basePath, mid, sid, "PLAN");
|
||||
const planContent = slicePlanPath ? await loadFile(slicePlanPath) : null;
|
||||
if (!planContent) return [];
|
||||
|
||||
const plan = parsePlan(planContent);
|
||||
const tDir = resolveTasksDir(basePath, mid, sid);
|
||||
if (!tDir) return [];
|
||||
|
||||
const results: TaskIO[] = [];
|
||||
|
||||
for (const taskEntry of plan.tasks) {
|
||||
const planFiles = resolveTaskFiles(tDir, "PLAN");
|
||||
const taskFileName = planFiles.find((f) =>
|
||||
f.toUpperCase().startsWith(taskEntry.id.toUpperCase() + "-"),
|
||||
);
|
||||
if (!taskFileName) {
|
||||
// Task plan file missing — include with empty IO (will trigger ambiguous)
|
||||
results.push({
|
||||
id: taskEntry.id,
|
||||
title: taskEntry.title,
|
||||
inputFiles: [],
|
||||
outputFiles: [],
|
||||
done: taskEntry.done,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const taskContent = await loadFile(join(tDir, taskFileName));
|
||||
if (!taskContent) {
|
||||
results.push({
|
||||
id: taskEntry.id,
|
||||
title: taskEntry.title,
|
||||
inputFiles: [],
|
||||
outputFiles: [],
|
||||
done: taskEntry.done,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const io = parseTaskPlanIO(taskContent);
|
||||
results.push({
|
||||
id: taskEntry.id,
|
||||
title: taskEntry.title,
|
||||
inputFiles: io.inputFiles,
|
||||
outputFiles: io.outputFiles,
|
||||
done: taskEntry.done,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── State Persistence ────────────────────────────────────────────────────
|
||||
|
||||
function reactiveStatePath(basePath: string, mid: string, sid: string): string {
|
||||
return join(basePath, ".gsd", "runtime", `${mid}-${sid}-reactive.json`);
|
||||
}
|
||||
|
||||
function isReactiveState(data: unknown): data is ReactiveExecutionState {
|
||||
if (!data || typeof data !== "object") return false;
|
||||
const d = data as Record<string, unknown>;
|
||||
return typeof d.sliceId === "string" && Array.isArray(d.completed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load persisted reactive execution state for a slice.
|
||||
* Returns null when no state file exists or the file is invalid.
|
||||
*/
|
||||
export function loadReactiveState(
|
||||
basePath: string,
|
||||
mid: string,
|
||||
sid: string,
|
||||
): ReactiveExecutionState | null {
|
||||
return loadJsonFileOrNull(reactiveStatePath(basePath, mid, sid), isReactiveState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save reactive execution state to disk.
|
||||
*/
|
||||
export function saveReactiveState(
|
||||
basePath: string,
|
||||
mid: string,
|
||||
sid: string,
|
||||
state: ReactiveExecutionState,
|
||||
): void {
|
||||
saveJsonFile(reactiveStatePath(basePath, mid, sid), state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the reactive state file when a slice completes.
|
||||
*/
|
||||
export function clearReactiveState(
|
||||
basePath: string,
|
||||
mid: string,
|
||||
sid: string,
|
||||
): void {
|
||||
const path = reactiveStatePath(basePath, mid, sid);
|
||||
try {
|
||||
if (existsSync(path)) unlinkSync(path);
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
367
src/resources/extensions/gsd/tests/reactive-executor.test.ts
Normal file
367
src/resources/extensions/gsd/tests/reactive-executor.test.ts
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
loadSliceTaskIO,
|
||||
deriveTaskGraph,
|
||||
isGraphAmbiguous,
|
||||
getReadyTasks,
|
||||
chooseNonConflictingSubset,
|
||||
loadReactiveState,
|
||||
saveReactiveState,
|
||||
clearReactiveState,
|
||||
} from "../reactive-graph.ts";
|
||||
import { validatePreferences } from "../preferences-validation.ts";
|
||||
import type { ReactiveExecutionState } from "../types.ts";
|
||||
|
||||
// ─── Preference Validation ────────────────────────────────────────────────
|
||||
|
||||
test("reactive_execution validation accepts valid config", () => {
|
||||
const result = validatePreferences({
|
||||
reactive_execution: {
|
||||
enabled: true,
|
||||
max_parallel: 4,
|
||||
isolation_mode: "same-tree",
|
||||
},
|
||||
});
|
||||
assert.equal(result.errors.length, 0);
|
||||
assert.deepEqual(result.preferences.reactive_execution, {
|
||||
enabled: true,
|
||||
max_parallel: 4,
|
||||
isolation_mode: "same-tree",
|
||||
});
|
||||
});
|
||||
|
||||
test("reactive_execution validation rejects max_parallel out of range", () => {
|
||||
const result = validatePreferences({
|
||||
reactive_execution: {
|
||||
enabled: true,
|
||||
max_parallel: 10,
|
||||
isolation_mode: "same-tree",
|
||||
} as any,
|
||||
});
|
||||
assert.ok(result.errors.some((e) => e.includes("max_parallel")));
|
||||
});
|
||||
|
||||
test("reactive_execution validation rejects invalid isolation_mode", () => {
|
||||
const result = validatePreferences({
|
||||
reactive_execution: {
|
||||
enabled: true,
|
||||
max_parallel: 2,
|
||||
isolation_mode: "separate-branch",
|
||||
} as any,
|
||||
});
|
||||
assert.ok(result.errors.some((e) => e.includes("isolation_mode")));
|
||||
});
|
||||
|
||||
test("reactive_execution validation warns on unknown keys", () => {
|
||||
const result = validatePreferences({
|
||||
reactive_execution: {
|
||||
enabled: true,
|
||||
max_parallel: 2,
|
||||
isolation_mode: "same-tree",
|
||||
unknown_thing: true,
|
||||
} as any,
|
||||
});
|
||||
assert.equal(result.errors.length, 0);
|
||||
assert.ok(result.warnings.some((w) => w.includes("unknown_thing")));
|
||||
});
|
||||
|
||||
// ─── Dispatch Rule Matching Logic ─────────────────────────────────────────
|
||||
|
||||
test("reactive dispatch requires enabled config and multiple ready tasks", async () => {
|
||||
// Build a minimal filesystem with a slice plan and task plans
|
||||
const repo = mkdtempSync(join(tmpdir(), "gsd-reactive-dispatch-"));
|
||||
try {
|
||||
const gsd = join(repo, ".gsd", "milestones", "M001", "slices", "S01");
|
||||
mkdirSync(join(gsd, "tasks"), { recursive: true });
|
||||
|
||||
// Slice plan with 3 tasks
|
||||
writeFileSync(
|
||||
join(gsd, "S01-PLAN.md"),
|
||||
[
|
||||
"# S01: Test Slice",
|
||||
"",
|
||||
"**Goal:** Test reactive execution",
|
||||
"**Demo:** All three tasks run in parallel",
|
||||
"",
|
||||
"## Tasks",
|
||||
"",
|
||||
"- [ ] **T01: First** `est:15m`",
|
||||
" Create initial types",
|
||||
"- [ ] **T02: Second** `est:15m`",
|
||||
" Create models",
|
||||
"- [ ] **T03: Third** `est:15m`",
|
||||
" Create service layer",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
// Task plans with non-overlapping IO (all independent)
|
||||
writeFileSync(
|
||||
join(gsd, "tasks", "T01-PLAN.md"),
|
||||
[
|
||||
"# T01: First",
|
||||
"",
|
||||
"## Description",
|
||||
"Create types.",
|
||||
"",
|
||||
"## Inputs",
|
||||
"",
|
||||
"- `src/config.json` — Config schema",
|
||||
"",
|
||||
"## Expected Output",
|
||||
"",
|
||||
"- `src/types.ts` — Type definitions",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(gsd, "tasks", "T02-PLAN.md"),
|
||||
[
|
||||
"# T02: Second",
|
||||
"",
|
||||
"## Description",
|
||||
"Create models.",
|
||||
"",
|
||||
"## Inputs",
|
||||
"",
|
||||
"- `src/schema.json` — Schema file",
|
||||
"",
|
||||
"## Expected Output",
|
||||
"",
|
||||
"- `src/models.ts` — Model definitions",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(gsd, "tasks", "T03-PLAN.md"),
|
||||
[
|
||||
"# T03: Third",
|
||||
"",
|
||||
"## Description",
|
||||
"Create service.",
|
||||
"",
|
||||
"## Inputs",
|
||||
"",
|
||||
"- `src/api.json` — API spec",
|
||||
"",
|
||||
"## Expected Output",
|
||||
"",
|
||||
"- `src/service.ts` — Service layer",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
// Load IO and build graph
|
||||
const basePath = repo;
|
||||
const taskIO = await loadSliceTaskIO(basePath, "M001", "S01");
|
||||
assert.equal(taskIO.length, 3);
|
||||
|
||||
const graph = deriveTaskGraph(taskIO);
|
||||
assert.equal(isGraphAmbiguous(graph), false, "Graph should not be ambiguous");
|
||||
|
||||
// All independent → all should be ready
|
||||
const ready = getReadyTasks(graph, new Set(), new Set());
|
||||
assert.equal(ready.length, 3);
|
||||
|
||||
// Choose subset with max_parallel=2
|
||||
const selected = chooseNonConflictingSubset(ready, graph, 2, new Set());
|
||||
assert.equal(selected.length, 2);
|
||||
assert.deepEqual(selected, ["T01", "T02"]);
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("reactive dispatch falls back when graph is ambiguous (task without IO)", async () => {
|
||||
const repo = mkdtempSync(join(tmpdir(), "gsd-reactive-ambiguous-"));
|
||||
try {
|
||||
const gsd = join(repo, ".gsd", "milestones", "M001", "slices", "S01");
|
||||
mkdirSync(join(gsd, "tasks"), { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
join(gsd, "S01-PLAN.md"),
|
||||
[
|
||||
"# S01: Test",
|
||||
"",
|
||||
"**Goal:** Test",
|
||||
"**Demo:** Test",
|
||||
"",
|
||||
"## Tasks",
|
||||
"",
|
||||
"- [ ] **T01: A** `est:15m`",
|
||||
"- [ ] **T02: B** `est:15m`",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
// T01 has IO, T02 has NO IO sections → ambiguous
|
||||
writeFileSync(
|
||||
join(gsd, "tasks", "T01-PLAN.md"),
|
||||
"# T01: A\n\n## Inputs\n\n- `src/a.ts`\n\n## Expected Output\n\n- `src/b.ts`\n",
|
||||
);
|
||||
writeFileSync(
|
||||
join(gsd, "tasks", "T02-PLAN.md"),
|
||||
"# T02: B\n\n## Description\n\nNo IO sections.\n",
|
||||
);
|
||||
|
||||
const taskIO = await loadSliceTaskIO(repo, "M001", "S01");
|
||||
const graph = deriveTaskGraph(taskIO);
|
||||
assert.equal(isGraphAmbiguous(graph), true, "Graph should be ambiguous");
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("single ready task falls through to sequential", async () => {
|
||||
const repo = mkdtempSync(join(tmpdir(), "gsd-reactive-single-"));
|
||||
try {
|
||||
const gsd = join(repo, ".gsd", "milestones", "M001", "slices", "S01");
|
||||
mkdirSync(join(gsd, "tasks"), { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
join(gsd, "S01-PLAN.md"),
|
||||
[
|
||||
"# S01: Linear",
|
||||
"",
|
||||
"**Goal:** Linear chain",
|
||||
"**Demo:** Sequential",
|
||||
"",
|
||||
"## Tasks",
|
||||
"",
|
||||
"- [ ] **T01: First** `est:15m`",
|
||||
"- [ ] **T02: Second** `est:15m`",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(gsd, "tasks", "T01-PLAN.md"),
|
||||
"# T01: First\n\n## Inputs\n\n- `src/config.json`\n\n## Expected Output\n\n- `src/a.ts`\n",
|
||||
);
|
||||
writeFileSync(
|
||||
join(gsd, "tasks", "T02-PLAN.md"),
|
||||
"# T02: Second\n\n## Inputs\n\n- `src/a.ts`\n\n## Expected Output\n\n- `src/b.ts`\n",
|
||||
);
|
||||
|
||||
const taskIO = await loadSliceTaskIO(repo, "M001", "S01");
|
||||
const graph = deriveTaskGraph(taskIO);
|
||||
const ready = getReadyTasks(graph, new Set(), new Set());
|
||||
// Only T01 is ready (T02 depends on T01)
|
||||
assert.equal(ready.length, 1);
|
||||
assert.deepEqual(ready, ["T01"]);
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── State Persistence ────────────────────────────────────────────────────
|
||||
|
||||
test("saveReactiveState and loadReactiveState round-trip", () => {
|
||||
const repo = mkdtempSync(join(tmpdir(), "gsd-reactive-state-"));
|
||||
mkdirSync(join(repo, ".gsd", "runtime"), { recursive: true });
|
||||
try {
|
||||
const state: ReactiveExecutionState = {
|
||||
sliceId: "S01",
|
||||
completed: ["T01", "T02"],
|
||||
graphSnapshot: { taskCount: 4, edgeCount: 2, readySetSize: 1, ambiguous: false },
|
||||
updatedAt: "2025-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
saveReactiveState(repo, "M001", "S01", state);
|
||||
const loaded = loadReactiveState(repo, "M001", "S01");
|
||||
assert.deepEqual(loaded, state);
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("clearReactiveState removes the file", () => {
|
||||
const repo = mkdtempSync(join(tmpdir(), "gsd-reactive-clear-"));
|
||||
mkdirSync(join(repo, ".gsd", "runtime"), { recursive: true });
|
||||
try {
|
||||
const state: ReactiveExecutionState = {
|
||||
sliceId: "S01",
|
||||
completed: [],
|
||||
graphSnapshot: { taskCount: 2, edgeCount: 0, readySetSize: 2, ambiguous: false },
|
||||
updatedAt: "2025-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
saveReactiveState(repo, "M001", "S01", state);
|
||||
assert.ok(existsSync(join(repo, ".gsd", "runtime", "M001-S01-reactive.json")));
|
||||
|
||||
clearReactiveState(repo, "M001", "S01");
|
||||
assert.ok(!existsSync(join(repo, ".gsd", "runtime", "M001-S01-reactive.json")));
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("loadReactiveState returns null when no file exists", () => {
|
||||
const repo = mkdtempSync(join(tmpdir(), "gsd-reactive-nofile-"));
|
||||
mkdirSync(join(repo, ".gsd", "runtime"), { recursive: true });
|
||||
try {
|
||||
const loaded = loadReactiveState(repo, "M001", "S01");
|
||||
assert.equal(loaded, null);
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("completed tasks are not re-dispatched on next iteration", async () => {
|
||||
const repo = mkdtempSync(join(tmpdir(), "gsd-reactive-reentry-"));
|
||||
try {
|
||||
const gsd = join(repo, ".gsd", "milestones", "M001", "slices", "S01");
|
||||
mkdirSync(join(gsd, "tasks"), { recursive: true });
|
||||
mkdirSync(join(repo, ".gsd", "runtime"), { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
join(gsd, "S01-PLAN.md"),
|
||||
[
|
||||
"# S01: Reentry Test",
|
||||
"",
|
||||
"**Goal:** Test re-entry",
|
||||
"**Demo:** Correct resumption",
|
||||
"",
|
||||
"## Tasks",
|
||||
"",
|
||||
"- [x] **T01: Done** `est:15m`",
|
||||
"- [ ] **T02: Pending** `est:15m`",
|
||||
"- [ ] **T03: Also Pending** `est:15m`",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(gsd, "tasks", "T01-PLAN.md"),
|
||||
"# T01: Done\n\n## Inputs\n\n- `src/config.json`\n\n## Expected Output\n\n- `src/a.ts`\n",
|
||||
);
|
||||
writeFileSync(
|
||||
join(gsd, "tasks", "T02-PLAN.md"),
|
||||
"# T02: Pending\n\n## Inputs\n\n- `src/a.ts`\n\n## Expected Output\n\n- `src/b.ts`\n",
|
||||
);
|
||||
writeFileSync(
|
||||
join(gsd, "tasks", "T03-PLAN.md"),
|
||||
"# T03: Also Pending\n\n## Inputs\n\n- `src/a.ts`\n\n## Expected Output\n\n- `src/c.ts`\n",
|
||||
);
|
||||
|
||||
const taskIO = await loadSliceTaskIO(repo, "M001", "S01");
|
||||
const graph = deriveTaskGraph(taskIO);
|
||||
|
||||
// T01 is done, T02 and T03 depend on T01
|
||||
const completed = new Set(["T01"]);
|
||||
const ready = getReadyTasks(graph, completed, new Set());
|
||||
// Both T02 and T03 should be ready (T01 is complete)
|
||||
assert.deepEqual(ready, ["T02", "T03"]);
|
||||
|
||||
// Simulate T02 completes, re-derive
|
||||
completed.add("T02");
|
||||
const ready2 = getReadyTasks(graph, completed, new Set());
|
||||
// Only T03 should be ready
|
||||
assert.deepEqual(ready2, ["T03"]);
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
299
src/resources/extensions/gsd/tests/reactive-graph.test.ts
Normal file
299
src/resources/extensions/gsd/tests/reactive-graph.test.ts
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
deriveTaskGraph,
|
||||
getReadyTasks,
|
||||
chooseNonConflictingSubset,
|
||||
isGraphAmbiguous,
|
||||
detectDeadlock,
|
||||
graphMetrics,
|
||||
} from "../reactive-graph.ts";
|
||||
import { parseTaskPlanIO } from "../files.ts";
|
||||
import type { TaskIO, DerivedTaskNode } from "../types.ts";
|
||||
|
||||
// ─── parseTaskPlanIO ──────────────────────────────────────────────────────
|
||||
|
||||
test("parseTaskPlanIO extracts backtick-wrapped file paths from Inputs and Expected Output", () => {
|
||||
const content = `---
|
||||
estimated_steps: 3
|
||||
estimated_files: 2
|
||||
---
|
||||
|
||||
# T01: Setup Models
|
||||
|
||||
**Slice:** S01 — Core Setup
|
||||
**Milestone:** M001
|
||||
|
||||
## Description
|
||||
|
||||
Create the core data models.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Create types file
|
||||
2. Create models file
|
||||
|
||||
## Must-Haves
|
||||
|
||||
- [ ] Type definitions complete
|
||||
|
||||
## Verification
|
||||
|
||||
- Run type checker
|
||||
|
||||
## Inputs
|
||||
|
||||
- \`src/types.ts\` — Existing type definitions from prior work
|
||||
- \`src/config.json\` — Configuration schema
|
||||
|
||||
## Expected Output
|
||||
|
||||
- \`src/models.ts\` — New data model definitions
|
||||
- \`src/models.test.ts\` — Unit tests for models
|
||||
`;
|
||||
|
||||
const io = parseTaskPlanIO(content);
|
||||
assert.deepEqual(io.inputFiles, ["src/types.ts", "src/config.json"]);
|
||||
assert.deepEqual(io.outputFiles, ["src/models.ts", "src/models.test.ts"]);
|
||||
});
|
||||
|
||||
test("parseTaskPlanIO returns empty arrays for missing sections", () => {
|
||||
const content = `# T01: Something\n\n## Description\n\nNo IO sections here.\n`;
|
||||
const io = parseTaskPlanIO(content);
|
||||
assert.deepEqual(io.inputFiles, []);
|
||||
assert.deepEqual(io.outputFiles, []);
|
||||
});
|
||||
|
||||
test("parseTaskPlanIO ignores non-file-path backtick tokens", () => {
|
||||
const content = `# T01: Test
|
||||
|
||||
## Inputs
|
||||
|
||||
- \`true\` — a boolean flag
|
||||
- \`src/index.ts\` — main entry
|
||||
- \`npm run test\` — a command, not a file
|
||||
|
||||
## Expected Output
|
||||
|
||||
- \`dist/bundle.js\` — compiled output
|
||||
- \`false\` — not a file
|
||||
`;
|
||||
|
||||
const io = parseTaskPlanIO(content);
|
||||
assert.deepEqual(io.inputFiles, ["src/index.ts"]);
|
||||
assert.deepEqual(io.outputFiles, ["dist/bundle.js"]);
|
||||
});
|
||||
|
||||
test("parseTaskPlanIO handles multiple backtick tokens on one line", () => {
|
||||
const content = `# T01: Multi
|
||||
|
||||
## Inputs
|
||||
|
||||
- \`src/a.ts\` and \`src/b.ts\` — both needed
|
||||
|
||||
## Expected Output
|
||||
|
||||
- \`src/c.ts\` — output
|
||||
`;
|
||||
const io = parseTaskPlanIO(content);
|
||||
assert.deepEqual(io.inputFiles, ["src/a.ts", "src/b.ts"]);
|
||||
assert.deepEqual(io.outputFiles, ["src/c.ts"]);
|
||||
});
|
||||
|
||||
// ─── deriveTaskGraph ──────────────────────────────────────────────────────
|
||||
|
||||
test("deriveTaskGraph: linear chain T01→T02→T03", () => {
|
||||
const tasks: TaskIO[] = [
|
||||
{ id: "T01", title: "First", inputFiles: [], outputFiles: ["src/a.ts"], done: false },
|
||||
{ id: "T02", title: "Second", inputFiles: ["src/a.ts"], outputFiles: ["src/b.ts"], done: false },
|
||||
{ id: "T03", title: "Third", inputFiles: ["src/b.ts"], outputFiles: ["src/c.ts"], done: false },
|
||||
];
|
||||
|
||||
const graph = deriveTaskGraph(tasks);
|
||||
assert.deepEqual(graph[0].dependsOn, []);
|
||||
assert.deepEqual(graph[1].dependsOn, ["T01"]);
|
||||
assert.deepEqual(graph[2].dependsOn, ["T02"]);
|
||||
});
|
||||
|
||||
test("deriveTaskGraph: diamond dependency", () => {
|
||||
const tasks: TaskIO[] = [
|
||||
{ id: "T01", title: "Base", inputFiles: [], outputFiles: ["src/base.ts"], done: false },
|
||||
{ id: "T02", title: "Left", inputFiles: ["src/base.ts"], outputFiles: ["src/left.ts"], done: false },
|
||||
{ id: "T03", title: "Right", inputFiles: ["src/base.ts"], outputFiles: ["src/right.ts"], done: false },
|
||||
{ id: "T04", title: "Merge", inputFiles: ["src/left.ts", "src/right.ts"], outputFiles: ["src/final.ts"], done: false },
|
||||
];
|
||||
|
||||
const graph = deriveTaskGraph(tasks);
|
||||
assert.deepEqual(graph[0].dependsOn, []);
|
||||
assert.deepEqual(graph[1].dependsOn, ["T01"]);
|
||||
assert.deepEqual(graph[2].dependsOn, ["T01"]);
|
||||
assert.deepEqual(graph[3].dependsOn, ["T02", "T03"]);
|
||||
});
|
||||
|
||||
test("deriveTaskGraph: fully independent tasks", () => {
|
||||
const tasks: TaskIO[] = [
|
||||
{ id: "T01", title: "A", inputFiles: [], outputFiles: ["src/a.ts"], done: false },
|
||||
{ id: "T02", title: "B", inputFiles: [], outputFiles: ["src/b.ts"], done: false },
|
||||
{ id: "T03", title: "C", inputFiles: [], outputFiles: ["src/c.ts"], done: false },
|
||||
];
|
||||
|
||||
const graph = deriveTaskGraph(tasks);
|
||||
assert.deepEqual(graph[0].dependsOn, []);
|
||||
assert.deepEqual(graph[1].dependsOn, []);
|
||||
assert.deepEqual(graph[2].dependsOn, []);
|
||||
});
|
||||
|
||||
test("deriveTaskGraph: self-referencing output→input is excluded", () => {
|
||||
const tasks: TaskIO[] = [
|
||||
{ id: "T01", title: "Self", inputFiles: ["src/a.ts"], outputFiles: ["src/a.ts"], done: false },
|
||||
];
|
||||
|
||||
const graph = deriveTaskGraph(tasks);
|
||||
assert.deepEqual(graph[0].dependsOn, []);
|
||||
});
|
||||
|
||||
// ─── getReadyTasks ────────────────────────────────────────────────────────
|
||||
|
||||
test("getReadyTasks: partially completed graph", () => {
|
||||
const tasks: TaskIO[] = [
|
||||
{ id: "T01", title: "Base", inputFiles: [], outputFiles: ["src/a.ts"], done: true },
|
||||
{ id: "T02", title: "Dep", inputFiles: ["src/a.ts"], outputFiles: ["src/b.ts"], done: false },
|
||||
{ id: "T03", title: "Blocked", inputFiles: ["src/b.ts"], outputFiles: ["src/c.ts"], done: false },
|
||||
];
|
||||
const graph = deriveTaskGraph(tasks);
|
||||
const ready = getReadyTasks(graph, new Set(["T01"]), new Set());
|
||||
assert.deepEqual(ready, ["T02"]);
|
||||
});
|
||||
|
||||
test("getReadyTasks: nothing complete → only root tasks ready", () => {
|
||||
const tasks: TaskIO[] = [
|
||||
{ id: "T01", title: "Root", inputFiles: [], outputFiles: ["src/a.ts"], done: false },
|
||||
{ id: "T02", title: "Dep", inputFiles: ["src/a.ts"], outputFiles: ["src/b.ts"], done: false },
|
||||
];
|
||||
const graph = deriveTaskGraph(tasks);
|
||||
const ready = getReadyTasks(graph, new Set(), new Set());
|
||||
assert.deepEqual(ready, ["T01"]);
|
||||
});
|
||||
|
||||
test("getReadyTasks: all complete → empty", () => {
|
||||
const tasks: TaskIO[] = [
|
||||
{ id: "T01", title: "Done", inputFiles: [], outputFiles: ["src/a.ts"], done: true },
|
||||
];
|
||||
const graph = deriveTaskGraph(tasks);
|
||||
const ready = getReadyTasks(graph, new Set(["T01"]), new Set());
|
||||
assert.deepEqual(ready, []);
|
||||
});
|
||||
|
||||
test("getReadyTasks: in-flight tasks excluded", () => {
|
||||
const tasks: TaskIO[] = [
|
||||
{ id: "T01", title: "A", inputFiles: [], outputFiles: ["src/a.ts"], done: false },
|
||||
{ id: "T02", title: "B", inputFiles: [], outputFiles: ["src/b.ts"], done: false },
|
||||
];
|
||||
const graph = deriveTaskGraph(tasks);
|
||||
const ready = getReadyTasks(graph, new Set(), new Set(["T01"]));
|
||||
assert.deepEqual(ready, ["T02"]);
|
||||
});
|
||||
|
||||
// ─── chooseNonConflictingSubset ───────────────────────────────────────────
|
||||
|
||||
test("chooseNonConflictingSubset: output conflicts", () => {
|
||||
const tasks: TaskIO[] = [
|
||||
{ id: "T01", title: "A", inputFiles: [], outputFiles: ["src/shared.ts"], done: false },
|
||||
{ id: "T02", title: "B", inputFiles: [], outputFiles: ["src/shared.ts"], done: false },
|
||||
{ id: "T03", title: "C", inputFiles: [], outputFiles: ["src/other.ts"], done: false },
|
||||
];
|
||||
const graph = deriveTaskGraph(tasks);
|
||||
const selected = chooseNonConflictingSubset(["T01", "T02", "T03"], graph, 3, new Set());
|
||||
// T01 claims shared.ts, T02 conflicts, T03 is fine
|
||||
assert.deepEqual(selected, ["T01", "T03"]);
|
||||
});
|
||||
|
||||
test("chooseNonConflictingSubset: respects maxParallel", () => {
|
||||
const tasks: TaskIO[] = [
|
||||
{ id: "T01", title: "A", inputFiles: [], outputFiles: ["src/a.ts"], done: false },
|
||||
{ id: "T02", title: "B", inputFiles: [], outputFiles: ["src/b.ts"], done: false },
|
||||
{ id: "T03", title: "C", inputFiles: [], outputFiles: ["src/c.ts"], done: false },
|
||||
];
|
||||
const graph = deriveTaskGraph(tasks);
|
||||
const selected = chooseNonConflictingSubset(["T01", "T02", "T03"], graph, 2, new Set());
|
||||
assert.deepEqual(selected, ["T01", "T02"]);
|
||||
});
|
||||
|
||||
test("chooseNonConflictingSubset: respects inFlightOutputs", () => {
|
||||
const tasks: TaskIO[] = [
|
||||
{ id: "T01", title: "A", inputFiles: [], outputFiles: ["src/a.ts"], done: false },
|
||||
{ id: "T02", title: "B", inputFiles: [], outputFiles: ["src/b.ts"], done: false },
|
||||
];
|
||||
const graph = deriveTaskGraph(tasks);
|
||||
const selected = chooseNonConflictingSubset(["T01", "T02"], graph, 4, new Set(["src/a.ts"]));
|
||||
assert.deepEqual(selected, ["T02"]);
|
||||
});
|
||||
|
||||
// ─── isGraphAmbiguous ─────────────────────────────────────────────────────
|
||||
|
||||
test("isGraphAmbiguous: task with no IO → ambiguous", () => {
|
||||
const graph: DerivedTaskNode[] = [
|
||||
{ id: "T01", title: "A", inputFiles: [], outputFiles: [], done: false, dependsOn: [] },
|
||||
{ id: "T02", title: "B", inputFiles: ["src/a.ts"], outputFiles: ["src/b.ts"], done: false, dependsOn: [] },
|
||||
];
|
||||
assert.equal(isGraphAmbiguous(graph), true);
|
||||
});
|
||||
|
||||
test("isGraphAmbiguous: all tasks have IO → not ambiguous", () => {
|
||||
const graph: DerivedTaskNode[] = [
|
||||
{ id: "T01", title: "A", inputFiles: [], outputFiles: ["src/a.ts"], done: false, dependsOn: [] },
|
||||
{ id: "T02", title: "B", inputFiles: ["src/a.ts"], outputFiles: ["src/b.ts"], done: false, dependsOn: ["T01"] },
|
||||
];
|
||||
assert.equal(isGraphAmbiguous(graph), false);
|
||||
});
|
||||
|
||||
test("isGraphAmbiguous: done tasks with no IO are ignored", () => {
|
||||
const graph: DerivedTaskNode[] = [
|
||||
{ id: "T01", title: "A", inputFiles: [], outputFiles: [], done: true, dependsOn: [] },
|
||||
{ id: "T02", title: "B", inputFiles: [], outputFiles: ["src/b.ts"], done: false, dependsOn: [] },
|
||||
];
|
||||
assert.equal(isGraphAmbiguous(graph), false);
|
||||
});
|
||||
|
||||
// ─── detectDeadlock ───────────────────────────────────────────────────────
|
||||
|
||||
test("detectDeadlock: circular dependency detected", () => {
|
||||
// T01 depends on T02, T02 depends on T01 — deadlock
|
||||
const graph: DerivedTaskNode[] = [
|
||||
{ id: "T01", title: "A", inputFiles: ["src/b.ts"], outputFiles: ["src/a.ts"], done: false, dependsOn: ["T02"] },
|
||||
{ id: "T02", title: "B", inputFiles: ["src/a.ts"], outputFiles: ["src/b.ts"], done: false, dependsOn: ["T01"] },
|
||||
];
|
||||
assert.equal(detectDeadlock(graph, new Set(), new Set()), true);
|
||||
});
|
||||
|
||||
test("detectDeadlock: normal blocked-waiting-for-in-flight → not deadlock", () => {
|
||||
const graph: DerivedTaskNode[] = [
|
||||
{ id: "T01", title: "A", inputFiles: [], outputFiles: ["src/a.ts"], done: false, dependsOn: [] },
|
||||
{ id: "T02", title: "B", inputFiles: ["src/a.ts"], outputFiles: ["src/b.ts"], done: false, dependsOn: ["T01"] },
|
||||
];
|
||||
// T01 is in-flight, T02 is waiting → not deadlock
|
||||
assert.equal(detectDeadlock(graph, new Set(), new Set(["T01"])), false);
|
||||
});
|
||||
|
||||
test("detectDeadlock: all complete → not deadlock", () => {
|
||||
const graph: DerivedTaskNode[] = [
|
||||
{ id: "T01", title: "A", inputFiles: [], outputFiles: ["src/a.ts"], done: true, dependsOn: [] },
|
||||
];
|
||||
assert.equal(detectDeadlock(graph, new Set(["T01"]), new Set()), false);
|
||||
});
|
||||
|
||||
// ─── graphMetrics ─────────────────────────────────────────────────────────
|
||||
|
||||
test("graphMetrics computes correct values", () => {
|
||||
const tasks: TaskIO[] = [
|
||||
{ id: "T01", title: "A", inputFiles: [], outputFiles: ["src/a.ts"], done: true },
|
||||
{ id: "T02", title: "B", inputFiles: ["src/a.ts"], outputFiles: ["src/b.ts"], done: false },
|
||||
{ id: "T03", title: "C", inputFiles: [], outputFiles: ["src/c.ts"], done: false },
|
||||
];
|
||||
const graph = deriveTaskGraph(tasks);
|
||||
const metrics = graphMetrics(graph);
|
||||
assert.equal(metrics.taskCount, 3);
|
||||
assert.equal(metrics.edgeCount, 1); // T02 depends on T01
|
||||
assert.equal(metrics.readySetSize, 2); // T02 (T01 done) and T03 (no deps)
|
||||
assert.equal(metrics.ambiguous, false);
|
||||
});
|
||||
|
|
@ -436,3 +436,44 @@ export interface ParallelConfig {
|
|||
merge_strategy: MergeStrategy;
|
||||
auto_merge: AutoMergeMode;
|
||||
}
|
||||
|
||||
// ─── Reactive Task Execution Types ───────────────────────────────────────
|
||||
|
||||
/** IO signature extracted from a single task plan's Inputs/Expected Output sections. */
|
||||
export interface TaskIO {
|
||||
id: string; // e.g. "T01"
|
||||
title: string;
|
||||
inputFiles: string[];
|
||||
outputFiles: string[];
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
/** A task node with derived dependency edges from input/output intersection. */
|
||||
export interface DerivedTaskNode extends TaskIO {
|
||||
/** IDs of tasks whose outputFiles overlap with this task's inputFiles. */
|
||||
dependsOn: string[];
|
||||
}
|
||||
|
||||
/** Configuration for reactive (graph-derived parallel) task execution within a slice. */
|
||||
export interface ReactiveExecutionConfig {
|
||||
enabled: boolean;
|
||||
/** Maximum number of tasks to dispatch in parallel. Clamped to 1–8. */
|
||||
max_parallel: number;
|
||||
/** Isolation mode for parallel tasks within a slice. Currently only "same-tree" is supported. */
|
||||
isolation_mode: "same-tree";
|
||||
}
|
||||
|
||||
/** Per-slice reactive execution runtime state, persisted to disk. */
|
||||
export interface ReactiveExecutionState {
|
||||
sliceId: string;
|
||||
/** Task IDs that have been verified as completed. */
|
||||
completed: string[];
|
||||
/** Snapshot of the graph at last dispatch. */
|
||||
graphSnapshot: {
|
||||
taskCount: number;
|
||||
edgeCount: number;
|
||||
readySetSize: number;
|
||||
ambiguous: boolean;
|
||||
};
|
||||
updatedAt: string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue