gsd-pi 2.37.1-dev.857ac92 → 2.37.1-dev.d3ace49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/resources/extensions/gsd/auto-dispatch.js +14 -1
- package/dist/resources/extensions/gsd/auto-prompts.js +38 -4
- package/dist/resources/extensions/gsd/auto-recovery.js +25 -7
- package/dist/resources/extensions/gsd/reactive-graph.js +1 -1
- package/package.json +1 -1
- package/src/resources/extensions/gsd/auto-dispatch.ts +16 -1
- package/src/resources/extensions/gsd/auto-prompts.ts +59 -5
- package/src/resources/extensions/gsd/auto-recovery.ts +31 -7
- package/src/resources/extensions/gsd/reactive-graph.ts +1 -1
- package/src/resources/extensions/gsd/tests/reactive-executor.test.ts +144 -0
- package/src/resources/extensions/gsd/types.ts +2 -0
|
@@ -262,10 +262,23 @@ const DISPATCH_RULES = [
|
|
|
262
262
|
const metrics = graphMetrics(graph);
|
|
263
263
|
process.stderr.write(`gsd-reactive: ${mid}/${sid} graph — tasks:${metrics.taskCount} edges:${metrics.edgeCount} ` +
|
|
264
264
|
`ready:${metrics.readySetSize} dispatching:${selected.length} ambiguous:${metrics.ambiguous}\n`);
|
|
265
|
+
// Persist dispatched batch so verification and recovery can check
|
|
266
|
+
// exactly which tasks were sent.
|
|
267
|
+
const { saveReactiveState } = await import("./reactive-graph.js");
|
|
268
|
+
saveReactiveState(basePath, mid, sid, {
|
|
269
|
+
sliceId: sid,
|
|
270
|
+
completed: [...completed],
|
|
271
|
+
dispatched: selected,
|
|
272
|
+
graphSnapshot: metrics,
|
|
273
|
+
updatedAt: new Date().toISOString(),
|
|
274
|
+
});
|
|
275
|
+
// Encode selected task IDs in unitId for artifact verification.
|
|
276
|
+
// Format: M001/S01/reactive+T02,T03
|
|
277
|
+
const batchSuffix = selected.join(",");
|
|
265
278
|
return {
|
|
266
279
|
action: "dispatch",
|
|
267
280
|
unitType: "reactive-execute",
|
|
268
|
-
unitId: `${mid}/${sid}/reactive`,
|
|
281
|
+
unitId: `${mid}/${sid}/reactive+${batchSuffix}`,
|
|
269
282
|
prompt: await buildReactiveExecutePrompt(mid, midTitle, sid, sTitle, selected, basePath),
|
|
270
283
|
};
|
|
271
284
|
}
|
|
@@ -414,6 +414,35 @@ export async function getPriorTaskSummaryPaths(mid, sid, currentTid, base) {
|
|
|
414
414
|
})
|
|
415
415
|
.map(f => `${sRel}/tasks/${f}`);
|
|
416
416
|
}
|
|
417
|
+
/**
|
|
418
|
+
* Get carry-forward summary paths scoped to a task's derived dependencies.
|
|
419
|
+
*
|
|
420
|
+
* Instead of all prior tasks (order-based), returns only summaries for task
|
|
421
|
+
* IDs in `dependsOn`. Used by reactive-execute to give each subagent only
|
|
422
|
+
* the context it actually needs — not sibling tasks from a parallel batch.
|
|
423
|
+
*
|
|
424
|
+
* Falls back to order-based when dependsOn is empty (root tasks still get
|
|
425
|
+
* any available prior summaries for continuity).
|
|
426
|
+
*/
|
|
427
|
+
export async function getDependencyTaskSummaryPaths(mid, sid, currentTid, dependsOn, base) {
|
|
428
|
+
// If no dependencies, fall back to order-based for root tasks
|
|
429
|
+
if (dependsOn.length === 0) {
|
|
430
|
+
return getPriorTaskSummaryPaths(mid, sid, currentTid, base);
|
|
431
|
+
}
|
|
432
|
+
const tDir = resolveTasksDir(base, mid, sid);
|
|
433
|
+
if (!tDir)
|
|
434
|
+
return [];
|
|
435
|
+
const summaryFiles = resolveTaskFiles(tDir, "SUMMARY");
|
|
436
|
+
const sRel = relSlicePath(base, mid, sid);
|
|
437
|
+
const depSet = new Set(dependsOn.map((d) => d.toUpperCase()));
|
|
438
|
+
return summaryFiles
|
|
439
|
+
.filter((f) => {
|
|
440
|
+
// Extract task ID from filename: "T02-SUMMARY.md" → "T02"
|
|
441
|
+
const tid = f.replace(/-SUMMARY\.md$/i, "").toUpperCase();
|
|
442
|
+
return depSet.has(tid);
|
|
443
|
+
})
|
|
444
|
+
.map((f) => `${sRel}/tasks/${f}`);
|
|
445
|
+
}
|
|
417
446
|
// ─── Adaptive Replanning Checks ────────────────────────────────────────────
|
|
418
447
|
/**
|
|
419
448
|
* Check if the most recently completed slice needs reassessment.
|
|
@@ -688,8 +717,11 @@ export async function buildPlanSlicePrompt(mid, _midTitle, sid, sTitle, base, le
|
|
|
688
717
|
});
|
|
689
718
|
}
|
|
690
719
|
export async function buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, base, level) {
|
|
691
|
-
const
|
|
692
|
-
|
|
720
|
+
const opts = typeof level === "object" && level !== null && !Array.isArray(level)
|
|
721
|
+
? level
|
|
722
|
+
: { level: level };
|
|
723
|
+
const inlineLevel = opts.level ?? resolveInlineLevel();
|
|
724
|
+
const priorSummaries = opts.carryForwardPaths ?? await getPriorTaskSummaryPaths(mid, sid, tid, base);
|
|
693
725
|
const priorLines = priorSummaries.length > 0
|
|
694
726
|
? priorSummaries.map(p => `- \`${p}\``).join("\n")
|
|
695
727
|
: "- (no prior tasks)";
|
|
@@ -1119,8 +1151,10 @@ export async function buildReactiveExecutePrompt(mid, midTitle, sid, sTitle, rea
|
|
|
1119
1151
|
const node = graph.find((n) => n.id === tid);
|
|
1120
1152
|
const tTitle = node?.title ?? tid;
|
|
1121
1153
|
readyTaskListLines.push(`- **${tid}: ${tTitle}**`);
|
|
1122
|
-
// Build
|
|
1123
|
-
const
|
|
1154
|
+
// Build dependency-scoped carry-forward paths for this task
|
|
1155
|
+
const depPaths = await getDependencyTaskSummaryPaths(mid, sid, tid, node?.dependsOn ?? [], base);
|
|
1156
|
+
// Build a full execute-task prompt with dependency-based carry-forward
|
|
1157
|
+
const taskPrompt = await buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, base, { carryForwardPaths: depPaths });
|
|
1124
1158
|
subagentSections.push([
|
|
1125
1159
|
`### ${tid}: ${tTitle}`,
|
|
1126
1160
|
"",
|
|
@@ -108,20 +108,38 @@ export function verifyExpectedArtifact(unitType, unitId, base) {
|
|
|
108
108
|
const content = readFileSync(overridesPath, "utf-8");
|
|
109
109
|
return !content.includes("**Scope:** active");
|
|
110
110
|
}
|
|
111
|
-
// Reactive-execute: verify that
|
|
112
|
-
// The unitId
|
|
111
|
+
// Reactive-execute: verify that each dispatched task's summary exists.
|
|
112
|
+
// The unitId encodes the batch: "{mid}/{sid}/reactive+T02,T03"
|
|
113
113
|
if (unitType === "reactive-execute") {
|
|
114
114
|
const parts = unitId.split("/");
|
|
115
115
|
const mid = parts[0];
|
|
116
|
-
const
|
|
117
|
-
|
|
116
|
+
const sidAndBatch = parts[1];
|
|
117
|
+
const batchPart = parts[2]; // "reactive+T02,T03"
|
|
118
|
+
if (!mid || !sidAndBatch || !batchPart)
|
|
119
|
+
return false;
|
|
120
|
+
const sid = sidAndBatch;
|
|
121
|
+
const plusIdx = batchPart.indexOf("+");
|
|
122
|
+
if (plusIdx === -1) {
|
|
123
|
+
// Legacy format "reactive" without batch IDs — fall back to "any summary"
|
|
124
|
+
const tDir = resolveTasksDir(base, mid, sid);
|
|
125
|
+
if (!tDir)
|
|
126
|
+
return false;
|
|
127
|
+
const summaryFiles = resolveTaskFiles(tDir, "SUMMARY");
|
|
128
|
+
return summaryFiles.length > 0;
|
|
129
|
+
}
|
|
130
|
+
const batchIds = batchPart.slice(plusIdx + 1).split(",").filter(Boolean);
|
|
131
|
+
if (batchIds.length === 0)
|
|
118
132
|
return false;
|
|
119
133
|
const tDir = resolveTasksDir(base, mid, sid);
|
|
120
134
|
if (!tDir)
|
|
121
135
|
return false;
|
|
122
|
-
const
|
|
123
|
-
//
|
|
124
|
-
|
|
136
|
+
const existingSummaries = new Set(resolveTaskFiles(tDir, "SUMMARY").map((f) => f.replace(/-SUMMARY\.md$/i, "").toUpperCase()));
|
|
137
|
+
// Every dispatched task must have a summary file
|
|
138
|
+
for (const tid of batchIds) {
|
|
139
|
+
if (!existingSummaries.has(tid.toUpperCase()))
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
return true;
|
|
125
143
|
}
|
|
126
144
|
const absPath = resolveExpectedArtifactPath(unitType, unitId, base);
|
|
127
145
|
// For unit types with no verifiable artifact (null path), the parent directory
|
|
@@ -197,7 +197,7 @@ function isReactiveState(data) {
|
|
|
197
197
|
if (!data || typeof data !== "object")
|
|
198
198
|
return false;
|
|
199
199
|
const d = data;
|
|
200
|
-
return typeof d.sliceId === "string" && Array.isArray(d.completed);
|
|
200
|
+
return typeof d.sliceId === "string" && Array.isArray(d.completed) && Array.isArray(d.dispatched);
|
|
201
201
|
}
|
|
202
202
|
/**
|
|
203
203
|
* Load persisted reactive execution state for a slice.
|
package/package.json
CHANGED
|
@@ -367,10 +367,25 @@ const DISPATCH_RULES: DispatchRule[] = [
|
|
|
367
367
|
`ready:${metrics.readySetSize} dispatching:${selected.length} ambiguous:${metrics.ambiguous}\n`,
|
|
368
368
|
);
|
|
369
369
|
|
|
370
|
+
// Persist dispatched batch so verification and recovery can check
|
|
371
|
+
// exactly which tasks were sent.
|
|
372
|
+
const { saveReactiveState } = await import("./reactive-graph.js");
|
|
373
|
+
saveReactiveState(basePath, mid, sid, {
|
|
374
|
+
sliceId: sid,
|
|
375
|
+
completed: [...completed],
|
|
376
|
+
dispatched: selected,
|
|
377
|
+
graphSnapshot: metrics,
|
|
378
|
+
updatedAt: new Date().toISOString(),
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// Encode selected task IDs in unitId for artifact verification.
|
|
382
|
+
// Format: M001/S01/reactive+T02,T03
|
|
383
|
+
const batchSuffix = selected.join(",");
|
|
384
|
+
|
|
370
385
|
return {
|
|
371
386
|
action: "dispatch",
|
|
372
387
|
unitType: "reactive-execute",
|
|
373
|
-
unitId: `${mid}/${sid}/reactive`,
|
|
388
|
+
unitId: `${mid}/${sid}/reactive+${batchSuffix}`,
|
|
374
389
|
prompt: await buildReactiveExecutePrompt(
|
|
375
390
|
mid,
|
|
376
391
|
midTitle,
|
|
@@ -485,6 +485,41 @@ export async function getPriorTaskSummaryPaths(
|
|
|
485
485
|
.map(f => `${sRel}/tasks/${f}`);
|
|
486
486
|
}
|
|
487
487
|
|
|
488
|
+
/**
|
|
489
|
+
* Get carry-forward summary paths scoped to a task's derived dependencies.
|
|
490
|
+
*
|
|
491
|
+
* Instead of all prior tasks (order-based), returns only summaries for task
|
|
492
|
+
* IDs in `dependsOn`. Used by reactive-execute to give each subagent only
|
|
493
|
+
* the context it actually needs — not sibling tasks from a parallel batch.
|
|
494
|
+
*
|
|
495
|
+
* Falls back to order-based when dependsOn is empty (root tasks still get
|
|
496
|
+
* any available prior summaries for continuity).
|
|
497
|
+
*/
|
|
498
|
+
export async function getDependencyTaskSummaryPaths(
|
|
499
|
+
mid: string, sid: string, currentTid: string,
|
|
500
|
+
dependsOn: string[], base: string,
|
|
501
|
+
): Promise<string[]> {
|
|
502
|
+
// If no dependencies, fall back to order-based for root tasks
|
|
503
|
+
if (dependsOn.length === 0) {
|
|
504
|
+
return getPriorTaskSummaryPaths(mid, sid, currentTid, base);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const tDir = resolveTasksDir(base, mid, sid);
|
|
508
|
+
if (!tDir) return [];
|
|
509
|
+
|
|
510
|
+
const summaryFiles = resolveTaskFiles(tDir, "SUMMARY");
|
|
511
|
+
const sRel = relSlicePath(base, mid, sid);
|
|
512
|
+
const depSet = new Set(dependsOn.map((d) => d.toUpperCase()));
|
|
513
|
+
|
|
514
|
+
return summaryFiles
|
|
515
|
+
.filter((f) => {
|
|
516
|
+
// Extract task ID from filename: "T02-SUMMARY.md" → "T02"
|
|
517
|
+
const tid = f.replace(/-SUMMARY\.md$/i, "").toUpperCase();
|
|
518
|
+
return depSet.has(tid);
|
|
519
|
+
})
|
|
520
|
+
.map((f) => `${sRel}/tasks/${f}`);
|
|
521
|
+
}
|
|
522
|
+
|
|
488
523
|
// ─── Adaptive Replanning Checks ────────────────────────────────────────────
|
|
489
524
|
|
|
490
525
|
/**
|
|
@@ -772,13 +807,24 @@ export async function buildPlanSlicePrompt(
|
|
|
772
807
|
});
|
|
773
808
|
}
|
|
774
809
|
|
|
810
|
+
/** Options for customizing execute-task prompt construction. */
|
|
811
|
+
export interface ExecuteTaskPromptOptions {
|
|
812
|
+
level?: InlineLevel;
|
|
813
|
+
/** Override carry-forward paths (dependency-based instead of order-based). */
|
|
814
|
+
carryForwardPaths?: string[];
|
|
815
|
+
}
|
|
816
|
+
|
|
775
817
|
export async function buildExecuteTaskPrompt(
|
|
776
818
|
mid: string, sid: string, sTitle: string,
|
|
777
|
-
tid: string, tTitle: string, base: string,
|
|
819
|
+
tid: string, tTitle: string, base: string,
|
|
820
|
+
level?: InlineLevel | ExecuteTaskPromptOptions,
|
|
778
821
|
): Promise<string> {
|
|
779
|
-
const
|
|
822
|
+
const opts: ExecuteTaskPromptOptions = typeof level === "object" && level !== null && !Array.isArray(level)
|
|
823
|
+
? level
|
|
824
|
+
: { level: level as InlineLevel | undefined };
|
|
825
|
+
const inlineLevel = opts.level ?? resolveInlineLevel();
|
|
780
826
|
|
|
781
|
-
const priorSummaries = await getPriorTaskSummaryPaths(mid, sid, tid, base);
|
|
827
|
+
const priorSummaries = opts.carryForwardPaths ?? await getPriorTaskSummaryPaths(mid, sid, tid, base);
|
|
782
828
|
const priorLines = priorSummaries.length > 0
|
|
783
829
|
? priorSummaries.map(p => `- \`${p}\``).join("\n")
|
|
784
830
|
: "- (no prior tasks)";
|
|
@@ -1272,8 +1318,16 @@ export async function buildReactiveExecutePrompt(
|
|
|
1272
1318
|
const tTitle = node?.title ?? tid;
|
|
1273
1319
|
readyTaskListLines.push(`- **${tid}: ${tTitle}**`);
|
|
1274
1320
|
|
|
1275
|
-
// Build
|
|
1276
|
-
const
|
|
1321
|
+
// Build dependency-scoped carry-forward paths for this task
|
|
1322
|
+
const depPaths = await getDependencyTaskSummaryPaths(
|
|
1323
|
+
mid, sid, tid, node?.dependsOn ?? [], base,
|
|
1324
|
+
);
|
|
1325
|
+
|
|
1326
|
+
// Build a full execute-task prompt with dependency-based carry-forward
|
|
1327
|
+
const taskPrompt = await buildExecuteTaskPrompt(
|
|
1328
|
+
mid, sid, sTitle, tid, tTitle, base,
|
|
1329
|
+
{ carryForwardPaths: depPaths },
|
|
1330
|
+
);
|
|
1277
1331
|
|
|
1278
1332
|
subagentSections.push([
|
|
1279
1333
|
`### ${tid}: ${tTitle}`,
|
|
@@ -152,18 +152,42 @@ export function verifyExpectedArtifact(
|
|
|
152
152
|
return !content.includes("**Scope:** active");
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
// Reactive-execute: verify that
|
|
156
|
-
// The unitId
|
|
155
|
+
// Reactive-execute: verify that each dispatched task's summary exists.
|
|
156
|
+
// The unitId encodes the batch: "{mid}/{sid}/reactive+T02,T03"
|
|
157
157
|
if (unitType === "reactive-execute") {
|
|
158
158
|
const parts = unitId.split("/");
|
|
159
159
|
const mid = parts[0];
|
|
160
|
-
const
|
|
161
|
-
|
|
160
|
+
const sidAndBatch = parts[1];
|
|
161
|
+
const batchPart = parts[2]; // "reactive+T02,T03"
|
|
162
|
+
if (!mid || !sidAndBatch || !batchPart) return false;
|
|
163
|
+
|
|
164
|
+
const sid = sidAndBatch;
|
|
165
|
+
const plusIdx = batchPart.indexOf("+");
|
|
166
|
+
if (plusIdx === -1) {
|
|
167
|
+
// Legacy format "reactive" without batch IDs — fall back to "any summary"
|
|
168
|
+
const tDir = resolveTasksDir(base, mid, sid);
|
|
169
|
+
if (!tDir) return false;
|
|
170
|
+
const summaryFiles = resolveTaskFiles(tDir, "SUMMARY");
|
|
171
|
+
return summaryFiles.length > 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const batchIds = batchPart.slice(plusIdx + 1).split(",").filter(Boolean);
|
|
175
|
+
if (batchIds.length === 0) return false;
|
|
176
|
+
|
|
162
177
|
const tDir = resolveTasksDir(base, mid, sid);
|
|
163
178
|
if (!tDir) return false;
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
179
|
+
|
|
180
|
+
const existingSummaries = new Set(
|
|
181
|
+
resolveTaskFiles(tDir, "SUMMARY").map((f) =>
|
|
182
|
+
f.replace(/-SUMMARY\.md$/i, "").toUpperCase(),
|
|
183
|
+
),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// Every dispatched task must have a summary file
|
|
187
|
+
for (const tid of batchIds) {
|
|
188
|
+
if (!existingSummaries.has(tid.toUpperCase())) return false;
|
|
189
|
+
}
|
|
190
|
+
return true;
|
|
167
191
|
}
|
|
168
192
|
|
|
169
193
|
const absPath = resolveExpectedArtifactPath(unitType, unitId, base);
|
|
@@ -245,7 +245,7 @@ function reactiveStatePath(basePath: string, mid: string, sid: string): string {
|
|
|
245
245
|
function isReactiveState(data: unknown): data is ReactiveExecutionState {
|
|
246
246
|
if (!data || typeof data !== "object") return false;
|
|
247
247
|
const d = data as Record<string, unknown>;
|
|
248
|
-
return typeof d.sliceId === "string" && Array.isArray(d.completed);
|
|
248
|
+
return typeof d.sliceId === "string" && Array.isArray(d.completed) && Array.isArray(d.dispatched);
|
|
249
249
|
}
|
|
250
250
|
|
|
251
251
|
/**
|
|
@@ -266,6 +266,7 @@ test("saveReactiveState and loadReactiveState round-trip", () => {
|
|
|
266
266
|
const state: ReactiveExecutionState = {
|
|
267
267
|
sliceId: "S01",
|
|
268
268
|
completed: ["T01", "T02"],
|
|
269
|
+
dispatched: ["T03"],
|
|
269
270
|
graphSnapshot: { taskCount: 4, edgeCount: 2, readySetSize: 1, ambiguous: false },
|
|
270
271
|
updatedAt: "2025-01-01T00:00:00Z",
|
|
271
272
|
};
|
|
@@ -285,6 +286,7 @@ test("clearReactiveState removes the file", () => {
|
|
|
285
286
|
const state: ReactiveExecutionState = {
|
|
286
287
|
sliceId: "S01",
|
|
287
288
|
completed: [],
|
|
289
|
+
dispatched: ["T01", "T02"],
|
|
288
290
|
graphSnapshot: { taskCount: 2, edgeCount: 0, readySetSize: 2, ambiguous: false },
|
|
289
291
|
updatedAt: "2025-01-01T00:00:00Z",
|
|
290
292
|
};
|
|
@@ -365,3 +367,145 @@ test("completed tasks are not re-dispatched on next iteration", async () => {
|
|
|
365
367
|
rmSync(repo, { recursive: true, force: true });
|
|
366
368
|
}
|
|
367
369
|
});
|
|
370
|
+
|
|
371
|
+
// ─── Batch Verification ───────────────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
test("verifyExpectedArtifact: reactive-execute passes when all dispatched summaries exist", async () => {
|
|
374
|
+
const { verifyExpectedArtifact } = await import("../auto-recovery.ts");
|
|
375
|
+
const repo = mkdtempSync(join(tmpdir(), "gsd-reactive-verify-pass-"));
|
|
376
|
+
try {
|
|
377
|
+
const tasksDir = join(repo, ".gsd", "milestones", "M001", "slices", "S01", "tasks");
|
|
378
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
379
|
+
writeFileSync(join(tasksDir, "T02-SUMMARY.md"), "---\nid: T02\n---\n# T02: Done\n");
|
|
380
|
+
writeFileSync(join(tasksDir, "T03-SUMMARY.md"), "---\nid: T03\n---\n# T03: Done\n");
|
|
381
|
+
|
|
382
|
+
const result = verifyExpectedArtifact("reactive-execute", "M001/S01/reactive+T02,T03", repo);
|
|
383
|
+
assert.equal(result, true, "Should pass when all dispatched task summaries exist");
|
|
384
|
+
} finally {
|
|
385
|
+
rmSync(repo, { recursive: true, force: true });
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("verifyExpectedArtifact: reactive-execute fails when a dispatched summary is missing", async () => {
|
|
390
|
+
const { verifyExpectedArtifact } = await import("../auto-recovery.ts");
|
|
391
|
+
const repo = mkdtempSync(join(tmpdir(), "gsd-reactive-verify-fail-"));
|
|
392
|
+
try {
|
|
393
|
+
const tasksDir = join(repo, ".gsd", "milestones", "M001", "slices", "S01", "tasks");
|
|
394
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
395
|
+
// Only T02 has a summary, T03 does not
|
|
396
|
+
writeFileSync(join(tasksDir, "T02-SUMMARY.md"), "---\nid: T02\n---\n# T02: Done\n");
|
|
397
|
+
|
|
398
|
+
const result = verifyExpectedArtifact("reactive-execute", "M001/S01/reactive+T02,T03", repo);
|
|
399
|
+
assert.equal(result, false, "Should fail when dispatched task T03 summary is missing");
|
|
400
|
+
} finally {
|
|
401
|
+
rmSync(repo, { recursive: true, force: true });
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("verifyExpectedArtifact: reactive-execute fails even with pre-existing summaries from other tasks", async () => {
|
|
406
|
+
const { verifyExpectedArtifact } = await import("../auto-recovery.ts");
|
|
407
|
+
const repo = mkdtempSync(join(tmpdir(), "gsd-reactive-verify-preexisting-"));
|
|
408
|
+
try {
|
|
409
|
+
const tasksDir = join(repo, ".gsd", "milestones", "M001", "slices", "S01", "tasks");
|
|
410
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
411
|
+
// T01 summary exists from before, but T02 and T03 were dispatched
|
|
412
|
+
writeFileSync(join(tasksDir, "T01-SUMMARY.md"), "---\nid: T01\n---\n# T01: Prior\n");
|
|
413
|
+
|
|
414
|
+
const result = verifyExpectedArtifact("reactive-execute", "M001/S01/reactive+T02,T03", repo);
|
|
415
|
+
assert.equal(result, false, "Pre-existing T01 summary should not satisfy T02,T03 batch");
|
|
416
|
+
} finally {
|
|
417
|
+
rmSync(repo, { recursive: true, force: true });
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("verifyExpectedArtifact: reactive-execute legacy format (no batch IDs) falls back", async () => {
|
|
422
|
+
const { verifyExpectedArtifact } = await import("../auto-recovery.ts");
|
|
423
|
+
const repo = mkdtempSync(join(tmpdir(), "gsd-reactive-verify-legacy-"));
|
|
424
|
+
try {
|
|
425
|
+
const tasksDir = join(repo, ".gsd", "milestones", "M001", "slices", "S01", "tasks");
|
|
426
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
427
|
+
writeFileSync(join(tasksDir, "T01-SUMMARY.md"), "---\nid: T01\n---\n# T01\n");
|
|
428
|
+
|
|
429
|
+
// Legacy format without +batch suffix
|
|
430
|
+
const result = verifyExpectedArtifact("reactive-execute", "M001/S01/reactive", repo);
|
|
431
|
+
assert.equal(result, true, "Legacy format should fall back to any-summary check");
|
|
432
|
+
} finally {
|
|
433
|
+
rmSync(repo, { recursive: true, force: true });
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("unitId batch encoding round-trips correctly", () => {
|
|
438
|
+
const mid = "M001";
|
|
439
|
+
const sid = "S01";
|
|
440
|
+
const selected = ["T02", "T03", "T05"];
|
|
441
|
+
const unitId = `${mid}/${sid}/reactive+${selected.join(",")}`;
|
|
442
|
+
|
|
443
|
+
// Parse it back
|
|
444
|
+
const parts = unitId.split("/");
|
|
445
|
+
assert.equal(parts[0], "M001");
|
|
446
|
+
assert.equal(parts[1], "S01");
|
|
447
|
+
const batchPart = parts[2];
|
|
448
|
+
const plusIdx = batchPart.indexOf("+");
|
|
449
|
+
assert.ok(plusIdx > 0, "Should have + separator");
|
|
450
|
+
const batchIds = batchPart.slice(plusIdx + 1).split(",");
|
|
451
|
+
assert.deepEqual(batchIds, ["T02", "T03", "T05"]);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
// ─── Dependency-Based Carry-Forward ───────────────────────────────────────
|
|
455
|
+
|
|
456
|
+
test("getDependencyTaskSummaryPaths returns only dependency summaries", async () => {
|
|
457
|
+
const { getDependencyTaskSummaryPaths } = await import("../auto-prompts.ts");
|
|
458
|
+
const repo = mkdtempSync(join(tmpdir(), "gsd-reactive-depcarry-"));
|
|
459
|
+
try {
|
|
460
|
+
const tasksDir = join(repo, ".gsd", "milestones", "M001", "slices", "S01", "tasks");
|
|
461
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
462
|
+
// T01, T02, T03 all have summaries
|
|
463
|
+
writeFileSync(join(tasksDir, "T01-SUMMARY.md"), "---\nid: T01\n---\n# T01\n");
|
|
464
|
+
writeFileSync(join(tasksDir, "T02-SUMMARY.md"), "---\nid: T02\n---\n# T02\n");
|
|
465
|
+
writeFileSync(join(tasksDir, "T03-SUMMARY.md"), "---\nid: T03\n---\n# T03\n");
|
|
466
|
+
|
|
467
|
+
// T04 depends only on T01 and T03 — should NOT get T02
|
|
468
|
+
const paths = await getDependencyTaskSummaryPaths("M001", "S01", "T04", ["T01", "T03"], repo);
|
|
469
|
+
assert.equal(paths.length, 2, "Should get exactly 2 dependency summaries");
|
|
470
|
+
assert.ok(paths.some((p) => p.includes("T01-SUMMARY")), "Should include T01");
|
|
471
|
+
assert.ok(paths.some((p) => p.includes("T03-SUMMARY")), "Should include T03");
|
|
472
|
+
assert.ok(!paths.some((p) => p.includes("T02-SUMMARY")), "Should NOT include T02");
|
|
473
|
+
} finally {
|
|
474
|
+
rmSync(repo, { recursive: true, force: true });
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("getDependencyTaskSummaryPaths falls back to order-based for root tasks", async () => {
|
|
479
|
+
const { getDependencyTaskSummaryPaths } = await import("../auto-prompts.ts");
|
|
480
|
+
const repo = mkdtempSync(join(tmpdir(), "gsd-reactive-depcarry-root-"));
|
|
481
|
+
try {
|
|
482
|
+
const tasksDir = join(repo, ".gsd", "milestones", "M001", "slices", "S01", "tasks");
|
|
483
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
484
|
+
writeFileSync(join(tasksDir, "T01-SUMMARY.md"), "---\nid: T01\n---\n# T01\n");
|
|
485
|
+
|
|
486
|
+
// T02 has no dependencies (root task) — should fall back to order-based
|
|
487
|
+
const paths = await getDependencyTaskSummaryPaths("M001", "S01", "T02", [], repo);
|
|
488
|
+
assert.equal(paths.length, 1, "Root task should get order-based prior summaries");
|
|
489
|
+
assert.ok(paths[0].includes("T01-SUMMARY"), "Should include T01 via order fallback");
|
|
490
|
+
} finally {
|
|
491
|
+
rmSync(repo, { recursive: true, force: true });
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test("getDependencyTaskSummaryPaths handles missing dependency summaries gracefully", async () => {
|
|
496
|
+
const { getDependencyTaskSummaryPaths } = await import("../auto-prompts.ts");
|
|
497
|
+
const repo = mkdtempSync(join(tmpdir(), "gsd-reactive-depcarry-missing-"));
|
|
498
|
+
try {
|
|
499
|
+
const tasksDir = join(repo, ".gsd", "milestones", "M001", "slices", "S01", "tasks");
|
|
500
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
501
|
+
// Only T01 has a summary, T02 does not
|
|
502
|
+
writeFileSync(join(tasksDir, "T01-SUMMARY.md"), "---\nid: T01\n---\n# T01\n");
|
|
503
|
+
|
|
504
|
+
// T03 depends on T01 and T02, but T02 summary doesn't exist
|
|
505
|
+
const paths = await getDependencyTaskSummaryPaths("M001", "S01", "T03", ["T01", "T02"], repo);
|
|
506
|
+
assert.equal(paths.length, 1, "Should only return existing dependency summaries");
|
|
507
|
+
assert.ok(paths[0].includes("T01-SUMMARY"), "Should include T01 (exists)");
|
|
508
|
+
} finally {
|
|
509
|
+
rmSync(repo, { recursive: true, force: true });
|
|
510
|
+
}
|
|
511
|
+
});
|
|
@@ -468,6 +468,8 @@ export interface ReactiveExecutionState {
|
|
|
468
468
|
sliceId: string;
|
|
469
469
|
/** Task IDs that have been verified as completed. */
|
|
470
470
|
completed: string[];
|
|
471
|
+
/** Task IDs dispatched in the current/most recent reactive batch. */
|
|
472
|
+
dispatched: string[];
|
|
471
473
|
/** Snapshot of the graph at last dispatch. */
|
|
472
474
|
graphSnapshot: {
|
|
473
475
|
taskCount: number;
|