Replace raw process.stderr.write(), console.error(), and empty catch blocks across 50 GSD files with structured logWarning/logError calls from the centralized workflow-logger system. Add 13 new LogComponent types to cover all subsystems: recovery, session, prompt, dashboard, timer, worktree, command, parallel, fs, bootstrap, guided, registry, renderer. Every migrated catch block now automatically: - Shows in terminal (stderr) with component tag - Gets buffered for auto-loop stuck-detection summary - Persists to .gsd/audit-log.jsonl for post-mortem analysis Update regression test to verify catch blocks use workflow-logger instead of raw stderr/console, covering auto-mode files and all explicitly migrated infrastructure files. Closes #3506 Supersedes the approach in #3496
254 lines
9.3 KiB
TypeScript
254 lines
9.3 KiB
TypeScript
/**
|
|
* complete-task handler — the core operation behind gsd_complete_task.
|
|
*
|
|
* Validates inputs, writes task row to DB in a transaction, then (outside
|
|
* the transaction) renders SUMMARY.md to disk, toggles the plan checkbox,
|
|
* stores the rendered markdown in the DB for D004 recovery, and invalidates
|
|
* caches.
|
|
*/
|
|
|
|
import { join } from "node:path";
|
|
import { mkdirSync, existsSync } from "node:fs";
|
|
|
|
import type { CompleteTaskParams } from "../types.js";
|
|
import { isClosedStatus } from "../status-guards.js";
|
|
import {
|
|
transaction,
|
|
insertMilestone,
|
|
insertSlice,
|
|
insertTask,
|
|
insertVerificationEvidence,
|
|
getMilestone,
|
|
getSlice,
|
|
getTask,
|
|
updateTaskStatus,
|
|
setTaskSummaryMd,
|
|
deleteVerificationEvidence,
|
|
} from "../gsd-db.js";
|
|
import { resolveSliceFile, resolveTasksDir, clearPathCache } from "../paths.js";
|
|
import { checkOwnership, taskUnitKey } from "../unit-ownership.js";
|
|
import { saveFile, clearParseCache } from "../files.js";
|
|
import { invalidateStateCache } from "../state.js";
|
|
import { renderPlanCheckboxes } from "../markdown-renderer.js";
|
|
import { renderAllProjections, renderSummaryContent } from "../workflow-projections.js";
|
|
import { writeManifest } from "../workflow-manifest.js";
|
|
import { appendEvent } from "../workflow-events.js";
|
|
import { logWarning } from "../workflow-logger.js";
|
|
|
|
export interface CompleteTaskResult {
|
|
taskId: string;
|
|
sliceId: string;
|
|
milestoneId: string;
|
|
summaryPath: string;
|
|
}
|
|
|
|
import type { TaskRow } from "../gsd-db.js";
|
|
|
|
/**
|
|
* Build a TaskRow-shaped object from CompleteTaskParams so the unified
|
|
* renderSummaryContent() can be used at completion time (#2720).
|
|
*/
|
|
function paramsToTaskRow(params: CompleteTaskParams, completedAt: string): TaskRow {
|
|
return {
|
|
milestone_id: params.milestoneId,
|
|
slice_id: params.sliceId,
|
|
id: params.taskId,
|
|
title: params.oneLiner || params.taskId,
|
|
status: "complete",
|
|
one_liner: params.oneLiner,
|
|
narrative: params.narrative,
|
|
verification_result: params.verification,
|
|
duration: "",
|
|
completed_at: completedAt,
|
|
blocker_discovered: params.blockerDiscovered,
|
|
deviations: params.deviations,
|
|
known_issues: params.knownIssues,
|
|
key_files: params.keyFiles,
|
|
key_decisions: params.keyDecisions,
|
|
full_summary_md: "",
|
|
description: "",
|
|
estimate: "",
|
|
files: [],
|
|
verify: "",
|
|
inputs: [],
|
|
expected_output: [],
|
|
observability_impact: "",
|
|
full_plan_md: "",
|
|
sequence: 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Handle the complete_task operation end-to-end.
|
|
*
|
|
* 1. Validate required fields
|
|
* 2. Write DB in a transaction (milestone, slice, task, verification evidence)
|
|
* 3. Render SUMMARY.md to disk
|
|
* 4. Toggle plan checkbox
|
|
* 5. Store rendered markdown back in DB (for D004 recovery)
|
|
* 6. Invalidate caches
|
|
*/
|
|
export async function handleCompleteTask(
|
|
params: CompleteTaskParams,
|
|
basePath: string,
|
|
): Promise<CompleteTaskResult | { error: string }> {
|
|
// ── Validate required fields ────────────────────────────────────────────
|
|
if (!params.taskId || typeof params.taskId !== "string" || params.taskId.trim() === "") {
|
|
return { error: "taskId is required and must be a non-empty string" };
|
|
}
|
|
if (!params.sliceId || typeof params.sliceId !== "string" || params.sliceId.trim() === "") {
|
|
return { error: "sliceId is required and must be a non-empty string" };
|
|
}
|
|
if (!params.milestoneId || typeof params.milestoneId !== "string" || params.milestoneId.trim() === "") {
|
|
return { error: "milestoneId is required and must be a non-empty string" };
|
|
}
|
|
|
|
// ── Ownership check (opt-in: only enforced when claim file exists) ──────
|
|
const ownershipErr = checkOwnership(
|
|
basePath,
|
|
taskUnitKey(params.milestoneId, params.sliceId, params.taskId),
|
|
params.actorName,
|
|
);
|
|
if (ownershipErr) {
|
|
return { error: ownershipErr };
|
|
}
|
|
|
|
// ── Guards + DB writes inside a single transaction (prevents TOCTOU) ───
|
|
const completedAt = new Date().toISOString();
|
|
let guardError: string | null = null;
|
|
|
|
transaction(() => {
|
|
// State machine preconditions (inside txn for atomicity).
|
|
// Milestone/slice not existing is OK — insertMilestone/insertSlice below will auto-create.
|
|
// Only block if they exist and are closed.
|
|
const milestone = getMilestone(params.milestoneId);
|
|
if (milestone && isClosedStatus(milestone.status)) {
|
|
guardError = `cannot complete task in a closed milestone: ${params.milestoneId} (status: ${milestone.status})`;
|
|
return;
|
|
}
|
|
|
|
const slice = getSlice(params.milestoneId, params.sliceId);
|
|
if (slice && isClosedStatus(slice.status)) {
|
|
guardError = `cannot complete task in a closed slice: ${params.sliceId} (status: ${slice.status})`;
|
|
return;
|
|
}
|
|
|
|
const existingTask = getTask(params.milestoneId, params.sliceId, params.taskId);
|
|
if (existingTask && isClosedStatus(existingTask.status)) {
|
|
guardError = `task ${params.taskId} is already complete — use gsd_task_reopen first if you need to redo it`;
|
|
return;
|
|
}
|
|
|
|
// All guards passed — perform writes
|
|
insertMilestone({ id: params.milestoneId });
|
|
insertSlice({ id: params.sliceId, milestoneId: params.milestoneId });
|
|
insertTask({
|
|
id: params.taskId,
|
|
sliceId: params.sliceId,
|
|
milestoneId: params.milestoneId,
|
|
title: params.oneLiner,
|
|
status: "complete",
|
|
oneLiner: params.oneLiner,
|
|
narrative: params.narrative,
|
|
verificationResult: params.verification,
|
|
duration: "",
|
|
blockerDiscovered: params.blockerDiscovered,
|
|
deviations: params.deviations,
|
|
knownIssues: params.knownIssues,
|
|
keyFiles: params.keyFiles,
|
|
keyDecisions: params.keyDecisions,
|
|
});
|
|
|
|
for (const evidence of params.verificationEvidence) {
|
|
insertVerificationEvidence({
|
|
taskId: params.taskId,
|
|
sliceId: params.sliceId,
|
|
milestoneId: params.milestoneId,
|
|
command: evidence.command,
|
|
exitCode: evidence.exitCode,
|
|
verdict: evidence.verdict,
|
|
durationMs: evidence.durationMs,
|
|
});
|
|
}
|
|
});
|
|
|
|
if (guardError) {
|
|
return { error: guardError };
|
|
}
|
|
|
|
// ── Filesystem operations (outside transaction) ─────────────────────────
|
|
// If disk render fails, roll back the DB status so deriveState() and
|
|
// verifyExpectedArtifact() stay consistent (both say "not done").
|
|
|
|
// Render summary markdown via the single source of truth (#2720)
|
|
const taskRow = paramsToTaskRow(params, completedAt);
|
|
const summaryMd = renderSummaryContent(taskRow, params.sliceId, params.milestoneId, params.verificationEvidence);
|
|
|
|
// Resolve and write summary to disk
|
|
let summaryPath: string;
|
|
const tasksDir = resolveTasksDir(basePath, params.milestoneId, params.sliceId);
|
|
if (tasksDir) {
|
|
summaryPath = join(tasksDir, `${params.taskId}-SUMMARY.md`);
|
|
} else {
|
|
// Tasks dir doesn't exist on disk yet — build path manually and ensure dirs
|
|
const gsdDir = join(basePath, ".gsd");
|
|
const manualTasksDir = join(gsdDir, "milestones", params.milestoneId, "slices", params.sliceId, "tasks");
|
|
mkdirSync(manualTasksDir, { recursive: true });
|
|
summaryPath = join(manualTasksDir, `${params.taskId}-SUMMARY.md`);
|
|
}
|
|
|
|
try {
|
|
await saveFile(summaryPath, summaryMd);
|
|
|
|
// Toggle plan checkbox via renderer module
|
|
const planPath = resolveSliceFile(basePath, params.milestoneId, params.sliceId, "PLAN");
|
|
if (planPath) {
|
|
await renderPlanCheckboxes(basePath, params.milestoneId, params.sliceId);
|
|
} else {
|
|
process.stderr.write(
|
|
`gsd-db: complete_task — could not find plan file for ${params.sliceId}/${params.milestoneId}, skipping checkbox toggle\n`,
|
|
);
|
|
}
|
|
} catch (renderErr) {
|
|
// Disk render failed — roll back DB status so state stays consistent
|
|
logWarning("tool", `complete_task — disk render failed, rolling back DB status: ${(renderErr as Error).message}`);
|
|
// Delete orphaned verification_evidence rows first (FK constraint
|
|
// references tasks, so evidence must go before status change).
|
|
// Without this, retries accumulate duplicate evidence rows (#2724).
|
|
deleteVerificationEvidence(params.milestoneId, params.sliceId, params.taskId);
|
|
updateTaskStatus(params.milestoneId, params.sliceId, params.taskId, 'pending');
|
|
invalidateStateCache();
|
|
return { error: `disk render failed: ${(renderErr as Error).message}` };
|
|
}
|
|
|
|
// Store rendered markdown in DB for D004 recovery
|
|
setTaskSummaryMd(params.milestoneId, params.sliceId, params.taskId, summaryMd);
|
|
|
|
// Invalidate all caches
|
|
invalidateStateCache();
|
|
clearPathCache();
|
|
clearParseCache();
|
|
|
|
// ── Post-mutation hook: projections, manifest, event log ───────────────
|
|
try {
|
|
await renderAllProjections(basePath, params.milestoneId);
|
|
writeManifest(basePath);
|
|
appendEvent(basePath, {
|
|
cmd: "complete-task",
|
|
params: { milestoneId: params.milestoneId, sliceId: params.sliceId, taskId: params.taskId },
|
|
ts: new Date().toISOString(),
|
|
actor: "agent",
|
|
actor_name: params.actorName,
|
|
trigger_reason: params.triggerReason,
|
|
});
|
|
} catch (hookErr) {
|
|
logWarning("tool", `complete-task post-mutation hook warning: ${(hookErr as Error).message}`);
|
|
}
|
|
|
|
return {
|
|
taskId: params.taskId,
|
|
sliceId: params.sliceId,
|
|
milestoneId: params.milestoneId,
|
|
summaryPath,
|
|
};
|
|
}
|