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>
361 lines
14 KiB
TypeScript
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,
|
|
};
|
|
}
|