fix: block stale staging task dispatch
This commit is contained in:
parent
120d7deda8
commit
46174c1183
3 changed files with 168 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
94
src/resources/extensions/sf/execution-instruction-guard.ts
Normal file
94
src/resources/extensions/sf/execution-instruction-guard.ts
Normal 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.",
|
||||
};
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue