diff --git a/src/resources/extensions/sf/auto-dispatch.ts b/src/resources/extensions/sf/auto-dispatch.ts index 07cacfc01..d4755a8fa 100644 --- a/src/resources/extensions/sf/auto-dispatch.ts +++ b/src/resources/extensions/sf/auto-dispatch.ts @@ -32,6 +32,7 @@ import { checkNeedsRunUat, } from "./auto-prompts.js"; import { hasImplementationArtifacts } from "./auto-recovery.js"; +import { getExecuteTaskInstructionConflict } from "./execution-instruction-guard.js"; import { extractUatType, loadActiveOverrides, @@ -912,6 +913,20 @@ export const DISPATCH_RULES: DispatchRule[] = [ const tid = state.activeTask.id; const tTitle = state.activeTask.title; const unitId = `${mid}/${sid}/${tid}`; + const instructionConflict = getExecuteTaskInstructionConflict( + basePath, + mid, + sid, + tid, + tTitle, + ); + if (instructionConflict) { + return { + action: "stop", + reason: instructionConflict.reason, + level: "error", + }; + } const prompt = await buildExecuteTaskPrompt( mid, sid, diff --git a/src/resources/extensions/sf/execution-instruction-guard.ts b/src/resources/extensions/sf/execution-instruction-guard.ts new file mode 100644 index 000000000..c59a94056 --- /dev/null +++ b/src/resources/extensions/sf/execution-instruction-guard.ts @@ -0,0 +1,94 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { resolveTaskFile } from "./paths.js"; + +export interface ExecutionInstructionConflict { + reason: string; +} + +const REPO_INSTRUCTION_FILES = [ + "AGENTS.md", + "CLAUDE.md", + "CONTRIBUTING.md", + "README.md", +] as const; + +function readIfExists(path: string): string { + try { + return existsSync(path) ? readFileSync(path, "utf-8") : ""; + } catch { + return ""; + } +} + +function loadRepoInstructionText(basePath: string): string { + return REPO_INSTRUCTION_FILES.map((file) => + readIfExists(join(basePath, file)), + ) + .filter(Boolean) + .join("\n\n"); +} + +function hasLegacyStagingConstraint(instructions: string): boolean { + const text = instructions.toLowerCase(); + const marksStagingHistorical = + text.includes("legacy staging artifacts") || + /deploy\/staging\/?.{0,160}historical/s.test(text) || + text.includes("there is no staging environment"); + const forbidsStagingAsTarget = + text.includes("do not treat them as the deploy target") || + text.includes("do not treat them as deploy target") || + text.includes("do not assume docker-compose") || + /unless.{0,80}local compose validation/s.test(text); + + return marksStagingHistorical && forbidsStagingAsTarget; +} + +function taskTargetsLocalComposeStaging(taskText: string): boolean { + const text = taskText.toLowerCase(); + const hasCompose = /\bdocker(?:\s+compose|-compose)\b/.test(text); + const hasStagingTarget = + text.includes("deploy/staging") || + text.includes("staging stack") || + text.includes("staging environment") || + text.includes("local-compose"); + const asksToRunCompose = + /\b(validate|start|starts|smoke|poll|health|shut down|up|down)\b.{0,120}\bdocker(?:\s+compose|-compose)\b/s.test( + text, + ) || + /\bdocker(?:\s+compose|-compose)\b.{0,120}\b(up|-d|start|starts|run|validate|health|down)\b/s.test( + text, + ); + + return hasStagingTarget && (hasCompose || asksToRunCompose); +} + +function taskRecordsExplicitLocalComposeRequest(taskText: string): boolean { + return /(?:user|human)\s+explicitly\s+(?:asked|requested).{0,120}(?:local compose|docker(?:\s+compose|-compose)|deploy\/staging)/is.test( + taskText, + ); +} + +export function getExecuteTaskInstructionConflict( + basePath: string, + mid: string, + sid: string, + tid: string, + taskTitle: string, +): ExecutionInstructionConflict | null { + const instructions = loadRepoInstructionText(basePath); + if (!hasLegacyStagingConstraint(instructions)) return null; + + const taskPlanPath = resolveTaskFile(basePath, mid, sid, tid, "PLAN"); + const taskPlanContent = taskPlanPath ? readIfExists(taskPlanPath) : ""; + const taskText = [taskTitle, taskPlanContent].filter(Boolean).join("\n\n"); + if (!taskTargetsLocalComposeStaging(taskText)) return null; + if (taskRecordsExplicitLocalComposeRequest(taskText)) return null; + + return { + reason: + `Cannot dispatch execute-task ${mid}/${sid}/${tid}: task plan targets Docker Compose staging, ` + + "but current repo instructions mark deploy/staging as historical and say not to treat it as the deploy target unless explicitly requested. " + + "Replan or skip this stale task, and use repo-appropriate verification instead.", + }; +} diff --git a/src/resources/extensions/sf/tests/dispatch-missing-task-plans.test.ts b/src/resources/extensions/sf/tests/dispatch-missing-task-plans.test.ts index 1da054069..fe9480f04 100644 --- a/src/resources/extensions/sf/tests/dispatch-missing-task-plans.test.ts +++ b/src/resources/extensions/sf/tests/dispatch-missing-task-plans.test.ts @@ -48,6 +48,14 @@ function makeContext( // ─── Scaffold helpers ────────────────────────────────────────────────────── function scaffoldSlicePlan(basePath: string, mid: string, sid: string): void { + const milestoneDir = join(basePath, ".sf", "milestones", mid); + mkdirSync(milestoneDir, { recursive: true }); + writeFileSync( + join(milestoneDir, `${mid}-CONTEXT.md`), + ["# Test Milestone Context", "", "Existing test fixture context.", ""].join( + "\n", + ), + ); const dir = join(basePath, ".sf", "milestones", mid, "slices", sid); mkdirSync(dir, { recursive: true }); writeFileSync( @@ -121,6 +129,57 @@ test("dispatch: present task plan proceeds to execute-task normally", async (t) ); }); +test("dispatch: stale Docker Compose staging task is blocked by current repo instructions", async (t) => { + const tmp = mkdtempSync(join(tmpdir(), "sf-stale-staging-")); + t.after(() => rmSync(tmp, { recursive: true, force: true })); + + writeFileSync( + join(tmp, "AGENTS.md"), + [ + "## Pilot Test Infrastructure", + "There is no staging environment.", + "`deploy/staging/` Docker Compose is a historical artifact.", + "Do not treat them as the deploy target unless explicitly asked for local compose validation.", + "Do not assume docker-compose is the only or primary option.", + "", + ].join("\n"), + ); + scaffoldSlicePlan(tmp, "M002", "S03"); + scaffoldTaskPlan(tmp, "M002", "S03", "T01"); + writeFileSync( + join( + tmp, + ".sf", + "milestones", + "M002", + "slices", + "S03", + "tasks", + "T01-PLAN.md", + ), + [ + "# T01: Validate docker-compose staging stack starts cleanly", + "", + "Start the full staging stack via docker-compose, poll for portal health, verify postgres connectivity, then shut down cleanly.", + "", + "verify: docker compose up -d && poll for health and then docker compose down", + "", + ].join("\n"), + ); + + const result = await resolveDispatch(makeContext(tmp)); + + assert.equal(result.action, "stop"); + assert.match( + result.action === "stop" ? result.reason : "", + /Docker Compose staging/, + ); + assert.match( + result.action === "stop" ? result.reason : "", + /deploy\/staging as historical/, + ); +}); + test("dispatch: plan-slice recovery loop — second call after plan-slice still recovers cleanly", async (t) => { // Simulate: plan-slice ran but T01-PLAN.md is still missing (e.g. agent crashed mid-write). // Dispatch should still re-dispatch plan-slice, not hard-stop.