singularity-forge/src/resources/extensions/sf/tools/complete-task.ts
Mikael Hugo f98a1e360e batch: codex-rescue session output (multiple in-flight tasks)
Combined output of multiple parallel codex-rescue runs that produced
working-tree edits but didn't commit. Tasks contributing:

- prefs: per-provider model allow-list (provider_model_allow) — manual
- TUI scroll + unresponsive (a7884d1a / bt3fpn4y2)
- planningMeeting required (aa09e904 / br127l763)
- Logs UX 4-pack (a5c65314 / btcplhu7f)
- Gate auto-resolve + completion nudge (ae4c8b64 / bw1w1fjkp)
- sf_task_complete atomic + retry (a7a079b4 / b20cy5owv)
- Multi-model meeting + minimax M2.7 + draft promotion (a756faac / task-moifjknd-lwjc98)
- Per-role slice prompts (a94c3e1a)
- Per-role vision-meeting prompts (afd165a0 / task-moifple5-lcwtjl)
- Schema sweep (ac994b1e / task-moifq7pu-83coqz)
- Flow audit (ad26ecfd / bttj4vrqm)

Typecheck passes. Tests not run as a full suite — spot-check after merge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: OpenAI Codex <noreply@openai.com>
2026-04-28 11:52:42 +02:00

361 lines
14 KiB
TypeScript

/**
* complete-task handler — the core operation behind sf_complete_task.
*
* Validates inputs, atomically renders SUMMARY.md to disk, then writes the
* task row to DB in a transaction, toggles the plan checkbox, and invalidates
* caches.
*/
import { dirname, join } from "node:path";
import { constants as fsConstants, mkdirSync, promises as fs } from "node:fs";
import type { CompleteTaskParams } from "../types.js";
import { isClosedStatus } from "../status-guards.js";
import {
transaction,
insertMilestone,
insertSlice,
insertTask,
insertVerificationEvidence,
getMilestone,
getSlice,
getTask,
setTaskSummaryMd,
saveGateResult,
getPendingGatesForTurn,
} from "../sf-db.js";
import { getGatesForTurn } from "../gate-registry.js";
import { resolveSliceFile, resolveTasksDir, clearPathCache } from "../paths.js";
import { checkOwnership, taskUnitKey } from "../unit-ownership.js";
import { clearParseCache } from "../files.js";
import { atomicWriteAsync } from "../atomic-write.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, logError } from "../workflow-logger.js";
export interface CompleteTaskResult {
taskId: string;
sliceId: string;
milestoneId: string;
summaryPath: string;
}
import type { TaskRow } from "../sf-db.js";
/**
* Map an execute-task-owned gate id to the CompleteTaskParams field whose
* presence drives `pass` vs. `omitted`. Keep in lockstep with the gates
* declared in gate-registry.ts under ownerTurn "execute-task".
*/
function taskGateFieldForId(
id: string,
params: CompleteTaskParams,
): string | undefined {
switch (id) {
case "Q5":
return params.failureModes;
case "Q6":
return params.loadProfile;
case "Q7":
return params.negativeTests;
default:
return undefined;
}
}
/**
* Normalize a list parameter that may arrive as a string (newline-delimited
* bullet list from the LLM) into a string array (#3361).
*/
function normalizeListParam(value: unknown): string[] {
if (Array.isArray(value)) return value.map(String);
if (typeof value === "string" && value.trim()) {
return value.split(/\n/).map(s => s.replace(/^[\s\-*•]+/, "").trim()).filter(Boolean);
}
return [];
}
async function ensureWritableParent(filePath: string): Promise<void> {
const parentDir = dirname(filePath);
await fs.mkdir(parentDir, { recursive: true });
await fs.access(parentDir, fsConstants.W_OK);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function writeSummaryBeforeDb(filePath: string, content: string): Promise<void> {
try {
await ensureWritableParent(filePath);
await atomicWriteAsync(filePath, content);
} catch (error) {
throw new Error(`SUMMARY.md write failed at ${filePath}: ${errorMessage(error)}`);
}
}
/**
* 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 ?? false,
deviations: params.deviations ?? "",
known_issues: params.knownIssues ?? "",
key_files: normalizeListParam(params.keyFiles),
key_decisions: normalizeListParam(params.keyDecisions),
full_summary_md: "",
description: "",
estimate: "",
files: [],
verify: "",
inputs: [],
expected_output: [],
observability_impact: "",
full_plan_md: "",
sequence: 0,
verification_status: "",
};
}
/**
* Handle the complete_task operation end-to-end.
*
* 1. Validate required fields
* 2. Validate and atomically render SUMMARY.md to disk
* 3. Write DB in a transaction (milestone, slice, task, verification evidence)
* 4. Toggle plan checkbox
* 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 before filesystem work ──────────────────────────────────────
const completedAt = new Date().toISOString();
const milestone = getMilestone(params.milestoneId);
if (milestone && isClosedStatus(milestone.status)) {
return { error: `cannot complete task in a closed milestone: ${params.milestoneId} (status: ${milestone.status})` };
}
const slice = getSlice(params.milestoneId, params.sliceId);
if (slice && isClosedStatus(slice.status)) {
return { error: `cannot complete task in a closed slice: ${params.sliceId} (status: ${slice.status})` };
}
const existingTask = getTask(params.milestoneId, params.sliceId, params.taskId);
if (existingTask && isClosedStatus(existingTask.status)) {
return { error: `task ${params.taskId} is already complete — use sf_task_reopen first if you need to redo it` };
}
// 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 sfDir = join(basePath, ".sf");
const manualTasksDir = join(sfDir, "milestones", params.milestoneId, "slices", params.sliceId, "tasks");
mkdirSync(manualTasksDir, { recursive: true });
summaryPath = join(manualTasksDir, `${params.taskId}-SUMMARY.md`);
}
// ── Filesystem commit before DB status mutation ────────────────────────
// SUMMARY.md is the artifact downstream agents read. If this write fails,
// do not mutate the DB into a completed state.
try {
await writeSummaryBeforeDb(summaryPath, summaryMd);
} catch (writeErr) {
logWarning("tool", `complete_task — SUMMARY.md write failed before DB update: ${errorMessage(writeErr)}`);
invalidateStateCache();
return { error: errorMessage(writeErr) };
}
// ── DB commit only after SUMMARY.md exists on disk ─────────────────────
try {
transaction(() => {
insertMilestone({ id: params.milestoneId, title: params.milestoneId });
insertSlice({ id: params.sliceId, milestoneId: params.milestoneId, title: params.sliceId });
const evidence = params.verificationEvidence ?? [];
const verificationStatus = evidence.length === 0 ? "" :
evidence.every((c) => c.exitCode === 0) ? "all_pass" :
evidence.some((c) => c.exitCode === 0) ? "partial" : "all_fail";
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 ?? false,
deviations: params.deviations ?? "None.",
knownIssues: params.knownIssues ?? "None.",
keyFiles: params.keyFiles ?? [],
keyDecisions: params.keyDecisions ?? [],
fullSummaryMd: summaryMd,
verificationStatus,
});
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,
});
}
setTaskSummaryMd(params.milestoneId, params.sliceId, params.taskId, summaryMd);
});
} catch (dbErr) {
const msg = errorMessage(dbErr);
logError("tool", `complete_task — DB update failed after SUMMARY.md write succeeded; keeping ${summaryPath}`, { error: msg });
invalidateStateCache();
return { error: `database update failed after SUMMARY.md write succeeded at ${summaryPath}: ${msg}. SUMMARY.md was kept; retry sf_task_complete after fixing the DB.` };
}
// Toggle plan checkbox via renderer module after DB status is updated.
try {
const planPath = resolveSliceFile(basePath, params.milestoneId, params.sliceId, "PLAN");
if (planPath) {
await renderPlanCheckboxes(basePath, params.milestoneId, params.sliceId);
} else {
process.stderr.write(
`sf-db: complete_task — could not find plan file for ${params.sliceId}/${params.milestoneId}, skipping checkbox toggle\n`,
);
}
} catch (renderErr) {
logWarning("tool", `complete_task — plan checkbox render failed after DB update: ${errorMessage(renderErr)}`);
}
// ── Close gates owned by execute-task (Q5/Q6/Q7) for this task ────────
// Each gate id maps to a specific params field via taskGateFieldForId.
// When the model populates the field, record `pass`; when it's empty,
// record `omitted`. Task-scoped rows are filtered by taskId so a single
// task's completion doesn't touch sibling tasks' gate rows.
try {
const pendingGates = getPendingGatesForTurn(
params.milestoneId,
params.sliceId,
"execute-task",
params.taskId,
);
if (pendingGates.length > 0) {
const ownedDefs = new Map(getGatesForTurn("execute-task").map((g) => [g.id, g] as const));
for (const row of pendingGates) {
const def = ownedDefs.get(row.gate_id);
if (!def) continue;
const field = taskGateFieldForId(def.id, params);
const hasContent = typeof field === "string" && field.trim().length > 0;
let verdict: import("../types.js").GateVerdict = hasContent ? "pass" : "omitted";
let rationale = hasContent
? `${def.promptSection} section populated in task summary`
: `${def.promptSection} section left empty — recorded as omitted`;
if (verdict === "omitted" && def.minOmissionWords > 0) {
const wordCount = rationale.trim().split(/\s+/).filter(Boolean).length;
if (wordCount < def.minOmissionWords) {
verdict = "flag";
rationale = `[⚠ Rationale too short — ${wordCount} words, ${def.minOmissionWords} required for omission] ${rationale}`;
}
}
saveGateResult({
milestoneId: params.milestoneId,
sliceId: params.sliceId,
taskId: params.taskId,
gateId: def.id,
verdict,
rationale,
findings: hasContent ? (field as string).trim() : "",
});
}
}
} catch (gateErr) {
logWarning(
"tool",
`complete-task gate close warning for ${params.milestoneId}/${params.sliceId}/${params.taskId}: ${(gateErr as Error).message}`,
);
}
// Invalidate all caches
invalidateStateCache();
clearPathCache();
clearParseCache();
// ── Post-mutation hook: projections, manifest, event log ───────────────
// Separate try/catch per step so a projection failure doesn't prevent
// the event log entry (critical for worktree reconciliation).
try {
await renderAllProjections(basePath, params.milestoneId);
} catch (projErr) {
logWarning("tool", `complete-task projection warning: ${(projErr as Error).message}`);
}
try {
writeManifest(basePath);
} catch (mfErr) {
logWarning("tool", `complete-task manifest warning: ${(mfErr as Error).message}`);
}
try {
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 (eventErr) {
logError("tool", `complete-task event log FAILED — completion invisible to reconciliation`, { error: (eventErr as Error).message });
}
return {
taskId: params.taskId,
sliceId: params.sliceId,
milestoneId: params.milestoneId,
summaryPath,
};
}