diff --git a/src/resources/extensions/gsd/auto-dispatch.ts b/src/resources/extensions/gsd/auto-dispatch.ts index 034163aab..429e766ff 100644 --- a/src/resources/extensions/gsd/auto-dispatch.ts +++ b/src/resources/extensions/gsd/auto-dispatch.ts @@ -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 }) => { diff --git a/src/resources/extensions/gsd/auto-post-unit.ts b/src/resources/extensions/gsd/auto-post-unit.ts index 925f94591..efaee75d6 100644 --- a/src/resources/extensions/gsd/auto-post-unit.ts +++ b/src/resources/extensions/gsd/auto-post-unit.ts @@ -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 { diff --git a/src/resources/extensions/gsd/auto-prompts.ts b/src/resources/extensions/gsd/auto-prompts.ts index bf4221466..e7b4a06b5 100644 --- a/src/resources/extensions/gsd/auto-prompts.ts +++ b/src/resources/extensions/gsd/auto-prompts.ts @@ -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 { + 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, diff --git a/src/resources/extensions/gsd/auto-recovery.ts b/src/resources/extensions/gsd/auto-recovery.ts index d9b22817a..637a07093 100644 --- a/src/resources/extensions/gsd/auto-recovery.ts +++ b/src/resources/extensions/gsd/auto-recovery.ts @@ -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). diff --git a/src/resources/extensions/gsd/files.ts b/src/resources/extensions/gsd/files.ts index 568b19ed5..529379332 100644 --- a/src/resources/extensions/gsd/files.ts +++ b/src/resources/extensions/gsd/files.ts @@ -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 ──────────────────────────────────────────────────── /** diff --git a/src/resources/extensions/gsd/preferences-types.ts b/src/resources/extensions/gsd/preferences-types.ts index 7bbb75dff..d4b41139b 100644 --- a/src/resources/extensions/gsd/preferences-types.ts +++ b/src/resources/extensions/gsd/preferences-types.ts @@ -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([ "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 { diff --git a/src/resources/extensions/gsd/preferences-validation.ts b/src/resources/extensions/gsd/preferences-validation.ts index 9732ab369..57643b5fb 100644 --- a/src/resources/extensions/gsd/preferences-validation.ts +++ b/src/resources/extensions/gsd/preferences-validation.ts @@ -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; + const validRe: Record = {}; + + 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)) { diff --git a/src/resources/extensions/gsd/prompts/reactive-execute.md b/src/resources/extensions/gsd/prompts/reactive-execute.md new file mode 100644 index 000000000..71f475daa --- /dev/null +++ b/src/resources/extensions/gsd/prompts/reactive-execute.md @@ -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}} diff --git a/src/resources/extensions/gsd/reactive-graph.ts b/src/resources/extensions/gsd/reactive-graph.ts new file mode 100644 index 000000000..45a278e8c --- /dev/null +++ b/src/resources/extensions/gsd/reactive-graph.ts @@ -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(); + 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(); + 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, + inFlight: Set, +): 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[] { + 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, + inFlight: Set, +): 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 { + 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; + 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 + } +} diff --git a/src/resources/extensions/gsd/tests/reactive-executor.test.ts b/src/resources/extensions/gsd/tests/reactive-executor.test.ts new file mode 100644 index 000000000..35ac074fa --- /dev/null +++ b/src/resources/extensions/gsd/tests/reactive-executor.test.ts @@ -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 }); + } +}); diff --git a/src/resources/extensions/gsd/tests/reactive-graph.test.ts b/src/resources/extensions/gsd/tests/reactive-graph.test.ts new file mode 100644 index 000000000..4cf077056 --- /dev/null +++ b/src/resources/extensions/gsd/tests/reactive-graph.test.ts @@ -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); +}); diff --git a/src/resources/extensions/gsd/types.ts b/src/resources/extensions/gsd/types.ts index c91d500a2..b0498e2df 100644 --- a/src/resources/extensions/gsd/types.ts +++ b/src/resources/extensions/gsd/types.ts @@ -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; +}