Merge pull request #714 from jeremymcs/fix/701-capture-resolution-execution

fix: execute capture resolutions after triage (#701)
This commit is contained in:
TÂCHES 2026-03-16 16:07:08 -06:00 committed by GitHub
commit 73b7b0d540
6 changed files with 589 additions and 3 deletions

View file

@ -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}`;

View file

@ -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 } : {}),
});
}

View file

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

View file

@ -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)",
);
});

View file

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

View file

@ -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;
}