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:
TÂCHES 2026-03-19 23:19:46 -06:00 committed by GitHub
parent 39cd932abb
commit 567751471a
12 changed files with 1306 additions and 1 deletions

View file

@ -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 }) => {

View file

@ -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 {

View file

@ -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,

View file

@ -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).

View file

@ -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 ────────────────────────────────────────────────────
/**

View file

@ -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 {

View file

@ -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)) {

View 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}}

View 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
}
}

View 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 });
}
});

View 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);
});

View file

@ -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 18. */
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;
}