fix: block stale staging task dispatch

This commit is contained in:
Mikael Hugo 2026-04-29 20:25:39 +02:00
parent 120d7deda8
commit 46174c1183
3 changed files with 168 additions and 0 deletions

View file

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

View file

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

View file

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