fix: execute capture resolutions after triage instead of just classifying
Captures classified as inject, replan, or quick-task were marked "resolved" in CAPTURES.md but their resolution actions were never executed — tasks were never injected into plans, replan triggers were never written, and quick-tasks were never dispatched. This wires up the existing resolution executor functions that were defined but never called: - After triage-captures unit completes, executeTriageResolutions() reads actionable captures and executes their resolutions: - inject: calls executeInject() to add tasks to the slice plan - replan: calls executeReplan() to write REPLAN-TRIGGER.md - quick-task: queues for dispatch as a new unit type - Quick-task dispatch block dispatches queued captures one at a time using buildQuickTaskPrompt(), with proper session/timeout handling - New markCaptureExecuted() and loadActionableCaptures() functions track execution state, preventing double-execution on retries - Quick-task unit type excluded from post-unit hooks (lightweight one-offs don't need hook chains) Closes #701
This commit is contained in:
parent
da25c0b692
commit
c46a4ec484
6 changed files with 589 additions and 3 deletions
|
|
@ -297,6 +297,9 @@ let currentUnit: { type: string; id: string; startedAt: number } | null = null;
|
|||
/** Track dynamic routing decision for the current unit (for metrics) */
|
||||
let currentUnitRouting: { tier: string; modelDowngraded: boolean } | null = null;
|
||||
|
||||
/** Queue of quick-task captures awaiting dispatch after triage resolution */
|
||||
let pendingQuickTasks: import("./captures.js").CaptureEntry[] = [];
|
||||
|
||||
/**
|
||||
* Model captured at auto-mode start. Used to prevent model bleed between
|
||||
* concurrent GSD instances sharing the same global settings.json (#650).
|
||||
|
|
@ -629,6 +632,7 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi
|
|||
currentMilestoneId = null;
|
||||
originalBasePath = "";
|
||||
completedUnits = [];
|
||||
pendingQuickTasks = [];
|
||||
clearSliceProgressCache();
|
||||
clearActivityLogState();
|
||||
resetProactiveHealing();
|
||||
|
|
@ -998,6 +1002,7 @@ export async function startAuto(
|
|||
autoStartTime = Date.now();
|
||||
resourceSyncedAtOnStart = readResourceSyncedAt();
|
||||
completedUnits = [];
|
||||
pendingQuickTasks = [];
|
||||
currentUnit = null;
|
||||
currentMilestoneId = state.activeMilestone?.id ?? null;
|
||||
originalModelId = ctx.model?.id ?? null;
|
||||
|
|
@ -1297,6 +1302,53 @@ export async function handleAgentEnd(
|
|||
}
|
||||
}
|
||||
|
||||
// ── Post-triage: execute actionable resolutions (inject, replan, queue quick-tasks) ──
|
||||
// After a triage-captures unit completes, the LLM has classified captures and
|
||||
// updated CAPTURES.md. Now we execute those classifications: inject tasks into
|
||||
// the plan, write replan triggers, and queue quick-tasks for dispatch.
|
||||
if (currentUnit.type === "triage-captures") {
|
||||
try {
|
||||
const { executeTriageResolutions } = await import("./triage-resolution.js");
|
||||
const state = await deriveState(basePath);
|
||||
const mid = state.activeMilestone?.id;
|
||||
const sid = state.activeSlice?.id;
|
||||
|
||||
if (mid && sid) {
|
||||
const triageResult = executeTriageResolutions(basePath, mid, sid);
|
||||
|
||||
if (triageResult.injected > 0) {
|
||||
ctx.ui.notify(
|
||||
`Triage: injected ${triageResult.injected} task${triageResult.injected === 1 ? "" : "s"} into ${sid} plan.`,
|
||||
"info",
|
||||
);
|
||||
}
|
||||
if (triageResult.replanned > 0) {
|
||||
ctx.ui.notify(
|
||||
`Triage: replan trigger written for ${sid} — next dispatch will enter replanning.`,
|
||||
"info",
|
||||
);
|
||||
}
|
||||
if (triageResult.quickTasks.length > 0) {
|
||||
// Queue quick-tasks for dispatch. They'll be picked up by the
|
||||
// quick-task dispatch block below the triage check.
|
||||
for (const qt of triageResult.quickTasks) {
|
||||
pendingQuickTasks.push(qt);
|
||||
}
|
||||
ctx.ui.notify(
|
||||
`Triage: ${triageResult.quickTasks.length} quick-task${triageResult.quickTasks.length === 1 ? "" : "s"} queued for execution.`,
|
||||
"info",
|
||||
);
|
||||
}
|
||||
for (const action of triageResult.actions) {
|
||||
process.stderr.write(`gsd-triage: ${action}\n`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Non-fatal — triage resolution failure shouldn't block dispatch
|
||||
process.stderr.write(`gsd-triage: resolution execution failed: ${(err as Error).message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Path A fix: verify artifact and persist completion before re-entering dispatch ──
|
||||
// After doctor + rebuildState, check whether the just-completed unit actually
|
||||
// produced its expected artifact. If so, persist the completion key now so the
|
||||
|
|
@ -1551,6 +1603,85 @@ export async function handleAgentEnd(
|
|||
}
|
||||
}
|
||||
|
||||
// ── Quick-task dispatch: execute queued quick-tasks from triage resolution ──
|
||||
// Quick-tasks are self-contained one-off tasks that don't modify the plan.
|
||||
// They're queued during post-triage resolution and dispatched here one at a time.
|
||||
if (
|
||||
!stepMode &&
|
||||
pendingQuickTasks.length > 0 &&
|
||||
currentUnit &&
|
||||
currentUnit.type !== "quick-task"
|
||||
) {
|
||||
try {
|
||||
const capture = pendingQuickTasks.shift()!;
|
||||
const { buildQuickTaskPrompt } = await import("./triage-resolution.js");
|
||||
const { markCaptureExecuted } = await import("./captures.js");
|
||||
const prompt = buildQuickTaskPrompt(capture);
|
||||
|
||||
ctx.ui.notify(
|
||||
`Executing quick-task: ${capture.id} — "${capture.text}"`,
|
||||
"info",
|
||||
);
|
||||
|
||||
// Close out previous unit metrics
|
||||
if (currentUnit) {
|
||||
const modelId = ctx.model?.id ?? "unknown";
|
||||
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
|
||||
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
||||
}
|
||||
|
||||
// Dispatch quick-task as a new unit
|
||||
const qtUnitType = "quick-task";
|
||||
const qtUnitId = `${currentMilestoneId}/${capture.id}`;
|
||||
const qtStartedAt = Date.now();
|
||||
currentUnit = { type: qtUnitType, id: qtUnitId, startedAt: qtStartedAt };
|
||||
writeUnitRuntimeRecord(basePath, qtUnitType, qtUnitId, qtStartedAt, {
|
||||
phase: "dispatched",
|
||||
wrapupWarningSent: false,
|
||||
timeoutAt: null,
|
||||
lastProgressAt: qtStartedAt,
|
||||
progressCount: 0,
|
||||
lastProgressKind: "dispatch",
|
||||
});
|
||||
const state = await deriveState(basePath);
|
||||
updateProgressWidget(ctx, qtUnitType, qtUnitId, state);
|
||||
|
||||
const result = await cmdCtx!.newSession();
|
||||
if (result.cancelled) {
|
||||
await stopAuto(ctx, pi);
|
||||
return;
|
||||
}
|
||||
const sessionFile = ctx.sessionManager.getSessionFile();
|
||||
writeLock(lockBase(), qtUnitType, qtUnitId, completedUnits.length, sessionFile);
|
||||
|
||||
// Mark capture as executed now that the unit is dispatched
|
||||
markCaptureExecuted(basePath, capture.id);
|
||||
|
||||
// Start unit timeout for quick-task
|
||||
clearUnitTimeout();
|
||||
const supervisor = resolveAutoSupervisorConfig();
|
||||
const qtTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000;
|
||||
unitTimeoutHandle = setTimeout(async () => {
|
||||
unitTimeoutHandle = null;
|
||||
if (!active) return;
|
||||
ctx.ui.notify(
|
||||
`Quick-task ${capture.id} exceeded timeout. Pausing auto-mode.`,
|
||||
"warning",
|
||||
);
|
||||
await pauseAuto(ctx, pi);
|
||||
}, qtTimeoutMs);
|
||||
|
||||
if (!active) return;
|
||||
pi.sendMessage(
|
||||
{ customType: "gsd-auto", content: prompt, display: verbose },
|
||||
{ triggerTurn: true },
|
||||
);
|
||||
return; // handleAgentEnd will fire again when quick-task session completes
|
||||
} catch {
|
||||
// Non-fatal — proceed to normal dispatch
|
||||
}
|
||||
}
|
||||
|
||||
// In step mode, pause and show a wizard instead of immediately dispatching
|
||||
if (stepMode) {
|
||||
await showStepWizard(ctx, pi);
|
||||
|
|
@ -3168,6 +3299,7 @@ export async function dispatchHookUnit(
|
|||
autoStartTime = Date.now();
|
||||
currentUnit = null;
|
||||
completedUnits = [];
|
||||
pendingQuickTasks = [];
|
||||
}
|
||||
|
||||
const hookUnitType = `hook/${hookName}`;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export interface CaptureEntry {
|
|||
resolution?: string;
|
||||
rationale?: string;
|
||||
resolvedAt?: string;
|
||||
executed?: boolean;
|
||||
}
|
||||
|
||||
export interface TriageResult {
|
||||
|
|
@ -211,6 +212,52 @@ export function markCaptureResolved(
|
|||
writeFileSync(filePath, updated, "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a resolved capture as executed — its resolution action was carried out.
|
||||
* Appends `**Executed:** <timestamp>` to the capture's section in CAPTURES.md.
|
||||
*/
|
||||
export function markCaptureExecuted(basePath: string, captureId: string): void {
|
||||
const filePath = resolveCapturesPath(basePath);
|
||||
if (!existsSync(filePath)) return;
|
||||
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const executedAt = new Date().toISOString();
|
||||
|
||||
const sectionRegex = new RegExp(
|
||||
`(### ${escapeRegex(captureId)}\\n(?:(?!### ).)*?)(?=### |$)`,
|
||||
"s",
|
||||
);
|
||||
const match = sectionRegex.exec(content);
|
||||
if (!match) return;
|
||||
|
||||
let section = match[1];
|
||||
|
||||
// Remove any existing Executed field (in case of re-execution)
|
||||
section = section.replace(/\*\*Executed:\*\*\s*.+\n?/g, "");
|
||||
|
||||
// Append Executed timestamp
|
||||
section = section.trimEnd() + "\n" + `**Executed:** ${executedAt}` + "\n";
|
||||
|
||||
const updated = content.replace(sectionRegex, section);
|
||||
writeFileSync(filePath, updated, "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load resolved captures that have actionable classifications (inject, replan,
|
||||
* quick-task) but have NOT yet been executed.
|
||||
* These are captures whose resolutions need to be carried out.
|
||||
*/
|
||||
export function loadActionableCaptures(basePath: string): CaptureEntry[] {
|
||||
return loadAllCaptures(basePath).filter(
|
||||
c =>
|
||||
c.status === "resolved" &&
|
||||
!c.executed &&
|
||||
(c.classification === "inject" ||
|
||||
c.classification === "replan" ||
|
||||
c.classification === "quick-task"),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Parser ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
@ -235,6 +282,7 @@ function parseCapturesContent(content: string): CaptureEntry[] {
|
|||
const resolution = extractBoldField(body, "Resolution");
|
||||
const rationale = extractBoldField(body, "Rationale");
|
||||
const resolvedAt = extractBoldField(body, "Resolved");
|
||||
const executedAt = extractBoldField(body, "Executed");
|
||||
|
||||
if (!text || !timestamp) continue;
|
||||
|
||||
|
|
@ -251,6 +299,7 @@ function parseCapturesContent(content: string): CaptureEntry[] {
|
|||
...(resolution ? { resolution } : {}),
|
||||
...(rationale ? { rationale } : {}),
|
||||
...(resolvedAt ? { resolvedAt } : {}),
|
||||
...(executedAt ? { executed: true } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,7 +60,8 @@ export function checkPostUnitHooks(
|
|||
|
||||
// Don't trigger hooks for other hook units (prevent hook-on-hook chains)
|
||||
// Don't trigger hooks for triage units (prevent hook-on-triage chains)
|
||||
if (completedUnitType.startsWith("hook/") || completedUnitType === "triage-captures") return null;
|
||||
// Don't trigger hooks for quick-task units (lightweight one-offs from captures)
|
||||
if (completedUnitType.startsWith("hook/") || completedUnitType === "triage-captures" || completedUnitType === "quick-task") return null;
|
||||
|
||||
// Check if any hooks are configured for this unit type
|
||||
const hooks = resolvePostUnitHooks().filter(h =>
|
||||
|
|
|
|||
|
|
@ -222,3 +222,123 @@ test("dashboard: overlay labels triage-captures and quick-task unit types", () =
|
|||
"unitLabel should handle quick-task",
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Post-triage resolution execution ─────────────────────────────────────────
|
||||
|
||||
test("dispatch: post-triage resolution executor fires after triage-captures unit", () => {
|
||||
const triageCompletionBlock = autoSrc.slice(
|
||||
autoSrc.indexOf("Post-triage: execute actionable resolutions"),
|
||||
autoSrc.indexOf("Path A fix: verify artifact"),
|
||||
);
|
||||
assert.ok(
|
||||
triageCompletionBlock.includes('currentUnit.type === "triage-captures"'),
|
||||
"should check for triage-captures unit completion",
|
||||
);
|
||||
assert.ok(
|
||||
triageCompletionBlock.includes("executeTriageResolutions"),
|
||||
"should call executeTriageResolutions",
|
||||
);
|
||||
});
|
||||
|
||||
test("dispatch: post-triage executor handles inject results", () => {
|
||||
const triageCompletionBlock = autoSrc.slice(
|
||||
autoSrc.indexOf("Post-triage: execute actionable resolutions"),
|
||||
autoSrc.indexOf("Path A fix: verify artifact"),
|
||||
);
|
||||
assert.ok(
|
||||
triageCompletionBlock.includes("triageResult.injected"),
|
||||
"should check injected count",
|
||||
);
|
||||
});
|
||||
|
||||
test("dispatch: post-triage executor handles replan results", () => {
|
||||
const triageCompletionBlock = autoSrc.slice(
|
||||
autoSrc.indexOf("Post-triage: execute actionable resolutions"),
|
||||
autoSrc.indexOf("Path A fix: verify artifact"),
|
||||
);
|
||||
assert.ok(
|
||||
triageCompletionBlock.includes("triageResult.replanned"),
|
||||
"should check replanned count",
|
||||
);
|
||||
});
|
||||
|
||||
test("dispatch: post-triage executor queues quick-tasks", () => {
|
||||
const triageCompletionBlock = autoSrc.slice(
|
||||
autoSrc.indexOf("Post-triage: execute actionable resolutions"),
|
||||
autoSrc.indexOf("Path A fix: verify artifact"),
|
||||
);
|
||||
assert.ok(
|
||||
triageCompletionBlock.includes("pendingQuickTasks"),
|
||||
"should push quick-tasks to pendingQuickTasks queue",
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Quick-task dispatch ──────────────────────────────────────────────────────
|
||||
|
||||
test("dispatch: quick-task dispatch block exists after triage check", () => {
|
||||
const quickTaskBlock = autoSrc.indexOf("Quick-task dispatch: execute queued quick-tasks");
|
||||
const triageBlock = autoSrc.indexOf("Triage check: dispatch triage unit");
|
||||
const stepModeBlock = autoSrc.indexOf("In step mode, pause and show a wizard");
|
||||
|
||||
assert.ok(quickTaskBlock > 0, "quick-task dispatch block should exist");
|
||||
assert.ok(
|
||||
quickTaskBlock > triageBlock,
|
||||
"quick-task dispatch should come after triage check",
|
||||
);
|
||||
assert.ok(
|
||||
quickTaskBlock < stepModeBlock,
|
||||
"quick-task dispatch should come before step mode check",
|
||||
);
|
||||
});
|
||||
|
||||
test("dispatch: quick-task dispatch uses buildQuickTaskPrompt", () => {
|
||||
const quickTaskSection = autoSrc.slice(
|
||||
autoSrc.indexOf("Quick-task dispatch: execute queued quick-tasks"),
|
||||
autoSrc.indexOf("In step mode, pause and show a wizard"),
|
||||
);
|
||||
assert.ok(
|
||||
quickTaskSection.includes("buildQuickTaskPrompt"),
|
||||
"should call buildQuickTaskPrompt for quick-task dispatch",
|
||||
);
|
||||
});
|
||||
|
||||
test("dispatch: quick-task dispatch marks capture as executed", () => {
|
||||
const quickTaskSection = autoSrc.slice(
|
||||
autoSrc.indexOf("Quick-task dispatch: execute queued quick-tasks"),
|
||||
autoSrc.indexOf("In step mode, pause and show a wizard"),
|
||||
);
|
||||
assert.ok(
|
||||
quickTaskSection.includes("markCaptureExecuted"),
|
||||
"should mark capture as executed after dispatch",
|
||||
);
|
||||
});
|
||||
|
||||
test("dispatch: quick-task dispatch uses early-return pattern", () => {
|
||||
const quickTaskSection = autoSrc.slice(
|
||||
autoSrc.indexOf("Quick-task dispatch: execute queued quick-tasks"),
|
||||
autoSrc.indexOf("In step mode, pause and show a wizard"),
|
||||
);
|
||||
assert.ok(
|
||||
quickTaskSection.includes("return; // handleAgentEnd will fire again when quick-task session completes"),
|
||||
"quick-task dispatch should return after sending message",
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Post-unit hook exclusion for quick-task ──────────────────────────────────
|
||||
|
||||
test("dispatch: quick-task excluded from post-unit hook triggering", () => {
|
||||
assert.ok(
|
||||
hooksSrc.includes('"quick-task"'),
|
||||
"post-unit-hooks.ts should reference quick-task",
|
||||
);
|
||||
});
|
||||
|
||||
// ─── pendingQuickTasks queue lifecycle ────────────────────────────────────────
|
||||
|
||||
test("dispatch: pendingQuickTasks queue is reset on auto-mode start/stop", () => {
|
||||
const resetMatches = autoSrc.match(/pendingQuickTasks = \[\]/g);
|
||||
assert.ok(
|
||||
resetMatches && resetMatches.length >= 3,
|
||||
"pendingQuickTasks should be reset in at least 3 places (start, stop, manual hook)",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ import assert from "node:assert/strict";
|
|||
import { mkdirSync, readFileSync, writeFileSync, rmSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { appendCapture, markCaptureResolved, loadAllCaptures } from "../captures.ts";
|
||||
import { appendCapture, markCaptureResolved, markCaptureExecuted, loadAllCaptures, loadActionableCaptures } from "../captures.ts";
|
||||
// Import only the functions that don't depend on @gsd/pi-coding-agent
|
||||
// (triage-ui.ts imports next-action-ui.ts which imports the unavailable package)
|
||||
import { executeInject, executeReplan, detectFileOverlap, loadDeferredCaptures, loadReplanCaptures, buildQuickTaskPrompt } from "../triage-resolution.ts";
|
||||
import { executeInject, executeReplan, detectFileOverlap, loadDeferredCaptures, loadReplanCaptures, buildQuickTaskPrompt, executeTriageResolutions } from "../triage-resolution.ts";
|
||||
|
||||
function makeTempDir(prefix: string): string {
|
||||
const dir = join(
|
||||
|
|
@ -213,3 +213,204 @@ test("resolution: buildQuickTaskPrompt includes capture text and ID", () => {
|
|||
assert.ok(prompt.includes("Quick Task"), "should have Quick Task header");
|
||||
assert.ok(prompt.includes("Do NOT modify"), "should warn about plan files");
|
||||
});
|
||||
|
||||
// ─── markCaptureExecuted ─────────────────────────────────────────────────────
|
||||
|
||||
test("resolution: markCaptureExecuted adds Executed field to capture", () => {
|
||||
const tmp = makeTempDir("res-executed");
|
||||
try {
|
||||
const id = appendCapture(tmp, "fix the button");
|
||||
markCaptureResolved(tmp, id, "quick-task", "execute as quick-task", "small fix");
|
||||
|
||||
markCaptureExecuted(tmp, id);
|
||||
|
||||
const all = loadAllCaptures(tmp);
|
||||
assert.strictEqual(all.length, 1);
|
||||
assert.strictEqual(all[0].executed, true, "should be marked as executed");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolution: markCaptureExecuted is idempotent", () => {
|
||||
const tmp = makeTempDir("res-executed-idem");
|
||||
try {
|
||||
const id = appendCapture(tmp, "fix something");
|
||||
markCaptureResolved(tmp, id, "inject", "inject task", "needed");
|
||||
|
||||
markCaptureExecuted(tmp, id);
|
||||
markCaptureExecuted(tmp, id); // call again — should not duplicate
|
||||
|
||||
const filePath = join(tmp, ".gsd", "CAPTURES.md");
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const executedMatches = content.match(/\*\*Executed:\*\*/g);
|
||||
assert.strictEqual(executedMatches?.length, 1, "should have exactly one Executed field");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── loadActionableCaptures ──────────────────────────────────────────────────
|
||||
|
||||
test("resolution: loadActionableCaptures returns only unexecuted actionable captures", () => {
|
||||
const tmp = makeTempDir("res-actionable");
|
||||
try {
|
||||
const id1 = appendCapture(tmp, "inject this task");
|
||||
const id2 = appendCapture(tmp, "quick fix");
|
||||
const id3 = appendCapture(tmp, "just a note");
|
||||
const id4 = appendCapture(tmp, "replan needed");
|
||||
const id5 = appendCapture(tmp, "already executed inject");
|
||||
|
||||
markCaptureResolved(tmp, id1, "inject", "add task", "needed");
|
||||
markCaptureResolved(tmp, id2, "quick-task", "quick fix", "small");
|
||||
markCaptureResolved(tmp, id3, "note", "acknowledged", "info");
|
||||
markCaptureResolved(tmp, id4, "replan", "replan triggered", "approach changed");
|
||||
markCaptureResolved(tmp, id5, "inject", "add task", "needed");
|
||||
markCaptureExecuted(tmp, id5); // mark as executed
|
||||
|
||||
const actionable = loadActionableCaptures(tmp);
|
||||
assert.strictEqual(actionable.length, 3, "should have 3 actionable captures");
|
||||
assert.deepStrictEqual(
|
||||
actionable.map(c => c.id),
|
||||
[id1, id2, id4],
|
||||
"should include inject, quick-task, replan but not note or executed inject",
|
||||
);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── executeTriageResolutions ────────────────────────────────────────────────
|
||||
|
||||
test("resolution: executeTriageResolutions executes inject captures", () => {
|
||||
const tmp = makeTempDir("res-exec-inject");
|
||||
try {
|
||||
setupPlanFile(tmp, "M001", "S01", SAMPLE_PLAN);
|
||||
const id1 = appendCapture(tmp, "add error handling");
|
||||
const id2 = appendCapture(tmp, "add retry logic");
|
||||
markCaptureResolved(tmp, id1, "inject", "add task", "needed");
|
||||
markCaptureResolved(tmp, id2, "inject", "add task", "also needed");
|
||||
|
||||
const result = executeTriageResolutions(tmp, "M001", "S01");
|
||||
|
||||
assert.strictEqual(result.injected, 2, "should inject 2 tasks");
|
||||
assert.strictEqual(result.replanned, 0);
|
||||
assert.strictEqual(result.quickTasks.length, 0);
|
||||
|
||||
// Verify tasks were added to plan
|
||||
const planPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md");
|
||||
const planContent = readFileSync(planPath, "utf-8");
|
||||
assert.ok(planContent.includes("**T04:"), "should have T04");
|
||||
assert.ok(planContent.includes("**T05:"), "should have T05");
|
||||
|
||||
// Verify captures marked as executed
|
||||
const all = loadAllCaptures(tmp);
|
||||
assert.strictEqual(all[0].executed, true, "first capture should be executed");
|
||||
assert.strictEqual(all[1].executed, true, "second capture should be executed");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolution: executeTriageResolutions executes replan captures", () => {
|
||||
const tmp = makeTempDir("res-exec-replan");
|
||||
try {
|
||||
setupPlanFile(tmp, "M001", "S01", SAMPLE_PLAN);
|
||||
const id = appendCapture(tmp, "approach is wrong");
|
||||
markCaptureResolved(tmp, id, "replan", "replan triggered", "wrong approach");
|
||||
|
||||
const result = executeTriageResolutions(tmp, "M001", "S01");
|
||||
|
||||
assert.strictEqual(result.injected, 0);
|
||||
assert.strictEqual(result.replanned, 1, "should trigger 1 replan");
|
||||
assert.strictEqual(result.quickTasks.length, 0);
|
||||
|
||||
// Verify trigger file was written
|
||||
const triggerPath = join(
|
||||
tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-REPLAN-TRIGGER.md",
|
||||
);
|
||||
assert.ok(existsSync(triggerPath), "replan trigger should exist");
|
||||
|
||||
// Verify capture marked as executed
|
||||
const all = loadAllCaptures(tmp);
|
||||
assert.strictEqual(all[0].executed, true, "capture should be executed");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolution: executeTriageResolutions queues quick-tasks without executing inline", () => {
|
||||
const tmp = makeTempDir("res-exec-qt");
|
||||
try {
|
||||
const id = appendCapture(tmp, "fix typo in readme");
|
||||
markCaptureResolved(tmp, id, "quick-task", "execute as quick-task", "small fix");
|
||||
|
||||
const result = executeTriageResolutions(tmp, "M001", "S01");
|
||||
|
||||
assert.strictEqual(result.injected, 0);
|
||||
assert.strictEqual(result.replanned, 0);
|
||||
assert.strictEqual(result.quickTasks.length, 1, "should queue 1 quick-task");
|
||||
assert.strictEqual(result.quickTasks[0].id, id);
|
||||
|
||||
// Quick-tasks should NOT be marked as executed yet (caller marks after dispatch)
|
||||
const all = loadAllCaptures(tmp);
|
||||
assert.ok(!all[0].executed, "quick-task should not be executed yet");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolution: executeTriageResolutions handles mixed classifications", () => {
|
||||
const tmp = makeTempDir("res-exec-mixed");
|
||||
try {
|
||||
setupPlanFile(tmp, "M001", "S01", SAMPLE_PLAN);
|
||||
const id1 = appendCapture(tmp, "inject a task");
|
||||
const id2 = appendCapture(tmp, "quick fix typo");
|
||||
const id3 = appendCapture(tmp, "just a note");
|
||||
const id4 = appendCapture(tmp, "defer to later");
|
||||
|
||||
markCaptureResolved(tmp, id1, "inject", "add task", "needed");
|
||||
markCaptureResolved(tmp, id2, "quick-task", "quick fix", "small");
|
||||
markCaptureResolved(tmp, id3, "note", "acknowledged", "info");
|
||||
markCaptureResolved(tmp, id4, "defer", "deferred", "later");
|
||||
|
||||
const result = executeTriageResolutions(tmp, "M001", "S01");
|
||||
|
||||
assert.strictEqual(result.injected, 1, "should inject 1 task");
|
||||
assert.strictEqual(result.replanned, 0);
|
||||
assert.strictEqual(result.quickTasks.length, 1, "should queue 1 quick-task");
|
||||
assert.strictEqual(result.actions.length, 2, "should have 2 action entries (note/defer excluded)");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolution: executeTriageResolutions skips already-executed captures", () => {
|
||||
const tmp = makeTempDir("res-exec-skip");
|
||||
try {
|
||||
setupPlanFile(tmp, "M001", "S01", SAMPLE_PLAN);
|
||||
const id = appendCapture(tmp, "already done");
|
||||
markCaptureResolved(tmp, id, "inject", "add task", "needed");
|
||||
markCaptureExecuted(tmp, id); // already executed
|
||||
|
||||
const result = executeTriageResolutions(tmp, "M001", "S01");
|
||||
|
||||
assert.strictEqual(result.injected, 0, "should not inject again");
|
||||
assert.strictEqual(result.actions.length, 0, "should have no actions");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolution: executeTriageResolutions returns empty result when no actionable captures", () => {
|
||||
const tmp = makeTempDir("res-exec-empty");
|
||||
try {
|
||||
const result = executeTriageResolutions(tmp, "M001", "S01");
|
||||
assert.strictEqual(result.injected, 0);
|
||||
assert.strictEqual(result.replanned, 0);
|
||||
assert.strictEqual(result.quickTasks.length, 0);
|
||||
assert.strictEqual(result.actions.length, 0);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ import type { Classification, CaptureEntry } from "./captures.js";
|
|||
import {
|
||||
loadPendingCaptures,
|
||||
loadAllCaptures,
|
||||
loadActionableCaptures,
|
||||
markCaptureResolved,
|
||||
markCaptureExecuted,
|
||||
} from "./captures.js";
|
||||
|
||||
// ─── Resolution Executors ─────────────────────────────────────────────────────
|
||||
|
|
@ -198,3 +200,84 @@ export function buildQuickTaskPrompt(capture: CaptureEntry): string {
|
|||
`5. When done, say: "Quick task complete."`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
// ─── Post-Triage Resolution Executor ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Result of executing triage resolutions after a triage-captures unit completes.
|
||||
*/
|
||||
export interface TriageExecutionResult {
|
||||
/** Number of inject resolutions executed (tasks added to plan) */
|
||||
injected: number;
|
||||
/** Number of replan triggers written */
|
||||
replanned: number;
|
||||
/** Captures classified as quick-task that need dispatch */
|
||||
quickTasks: CaptureEntry[];
|
||||
/** Details of each action taken, for logging */
|
||||
actions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute pending triage resolutions.
|
||||
*
|
||||
* Called after a triage-captures unit completes. Reads CAPTURES.md for
|
||||
* resolved captures that have actionable classifications (inject, replan,
|
||||
* quick-task) but haven't been executed yet, then:
|
||||
*
|
||||
* - inject: calls executeInject() to add a task to the current slice plan
|
||||
* - replan: calls executeReplan() to write the REPLAN-TRIGGER.md marker
|
||||
* - quick-task: collects for dispatch (caller handles dispatching quick-task units)
|
||||
*
|
||||
* Each capture is marked as executed after its resolution action succeeds,
|
||||
* preventing double-execution on retries or restarts.
|
||||
*/
|
||||
export function executeTriageResolutions(
|
||||
basePath: string,
|
||||
mid: string,
|
||||
sid: string,
|
||||
): TriageExecutionResult {
|
||||
const result: TriageExecutionResult = {
|
||||
injected: 0,
|
||||
replanned: 0,
|
||||
quickTasks: [],
|
||||
actions: [],
|
||||
};
|
||||
|
||||
const actionable = loadActionableCaptures(basePath);
|
||||
if (actionable.length === 0) return result;
|
||||
|
||||
for (const capture of actionable) {
|
||||
switch (capture.classification) {
|
||||
case "inject": {
|
||||
const newTaskId = executeInject(basePath, mid, sid, capture);
|
||||
if (newTaskId) {
|
||||
markCaptureExecuted(basePath, capture.id);
|
||||
result.injected++;
|
||||
result.actions.push(`Injected ${newTaskId} from ${capture.id}: "${capture.text}"`);
|
||||
} else {
|
||||
result.actions.push(`Failed to inject ${capture.id}: "${capture.text}" (no plan file or parse error)`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "replan": {
|
||||
const success = executeReplan(basePath, mid, sid, capture);
|
||||
if (success) {
|
||||
markCaptureExecuted(basePath, capture.id);
|
||||
result.replanned++;
|
||||
result.actions.push(`Replan triggered from ${capture.id}: "${capture.text}"`);
|
||||
} else {
|
||||
result.actions.push(`Failed to trigger replan from ${capture.id}: "${capture.text}"`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "quick-task": {
|
||||
// Quick-tasks are collected for dispatch, not executed inline
|
||||
result.quickTasks.push(capture);
|
||||
result.actions.push(`Quick-task queued from ${capture.id}: "${capture.text}"`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue