test(sf): full lifecycle coverage for ADR-011 P2 escalation feature

21 vitest tests covering the entire escalation chain shipped this
session. Each contract claim from prior PDD specs gets at least one
verifying test:

buildEscalationArtifact validation (4)
  - option count outside [2,4] → throws
  - duplicate option ids → throws
  - recommendation referencing unknown id → throws
  - happy path → version=1, taskId set, ISO createdAt

writeEscalationArtifact + DB flag flips (3)
  - continueWithDefault=false → escalation_pending=1
  - continueWithDefault=true → escalation_awaiting_review=1
  - two writes flip the pair atomically (mutually exclusive)

detectPendingEscalation (4)
  - empty slice → null
  - paused task → returns task id
  - awaiting_review tasks DO NOT pause
  - resolved (respondedAt set) tasks DO NOT pause

resolveEscalation (5)
  - 'accept' selects recommendation
  - explicit option id resolves with userRationale persisted
  - invalid choice → status=invalid-choice with valid list
  - re-resolve → already-resolved
  - unknown task → not-found

claimOverrideForInjection carry-forward (5)
  - no escalation → null
  - pending (unresolved) → null
  - resolved → returns block + sourceTaskId + sets DB flag=1
  - second claim → null (race-safe idempotent)
  - clearTaskEscalationFlags preserves artifact path (audit trail)

Provides regression protection for the full producer→consumer→
resolution→carry-forward path. All 21 pass against current head.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-02 20:56:12 +02:00
parent 125496ce36
commit aa67c1453c

View file

@ -0,0 +1,301 @@
/**
* Full lifecycle tests for ADR-011 P2 mid-execution escalation (gsd-2 ADR).
*
* Covers:
* - DB schema columns (is_sketch is for ADR-011 P1, not tested here)
* - buildEscalationArtifact validation rules
* - writeEscalationArtifact persists artifact + flips DB flag based on
* continueWithDefault
* - detectPendingEscalation only returns paused tasks (not awaiting-review)
* - resolveEscalation: accept / option-id / invalid-choice / already-resolved /
* not-found contract
* - claimOverrideForInjection: race-safe atomic claim, idempotent, returns
* null for unresolved escalations
*/
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, test } from "vitest";
import {
buildEscalationArtifact,
claimOverrideForInjection,
detectPendingEscalation,
readEscalationArtifact,
resolveEscalation,
writeEscalationArtifact,
} from "../escalation.ts";
import {
clearTaskEscalationFlags,
closeDatabase,
getSliceTasks,
getTask,
insertMilestone,
insertSlice,
insertTask,
openDatabase,
} from "../sf-db.ts";
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "sf-escalation-test-"));
mkdirSync(join(dir, ".sf", "milestones", "M001", "slices", "S01", "tasks"), {
recursive: true,
});
openDatabase(join(dir, ".sf", "sf.db"));
insertMilestone({ id: "M001", title: "Test", status: "pending" });
insertSlice({
milestoneId: "M001",
id: "S01",
title: "Test Slice",
risk: "medium",
});
insertTask({
milestoneId: "M001",
sliceId: "S01",
id: "T01",
title: "Task 1",
});
insertTask({
milestoneId: "M001",
sliceId: "S01",
id: "T02",
title: "Task 2",
});
});
afterEach(() => {
closeDatabase();
rmSync(dir, { recursive: true, force: true });
});
const baseArtifact = {
taskId: "T01",
sliceId: "S01",
milestoneId: "M001",
question: "Overwrite or fail?",
options: [
{ id: "overwrite", label: "Overwrite", tradeoffs: "lose data" },
{ id: "fail", label: "Fail", tradeoffs: "block progress" },
],
recommendation: "fail",
recommendationRationale: "data loss is irreversible",
continueWithDefault: false,
};
describe("buildEscalationArtifact validation", () => {
test("requires 2-4 options", () => {
assert.throws(
() =>
buildEscalationArtifact({
...baseArtifact,
options: [{ id: "a", label: "A", tradeoffs: "" }],
}),
/between 2 and 4/,
);
assert.throws(
() =>
buildEscalationArtifact({
...baseArtifact,
options: [
{ id: "a", label: "A", tradeoffs: "" },
{ id: "b", label: "B", tradeoffs: "" },
{ id: "c", label: "C", tradeoffs: "" },
{ id: "d", label: "D", tradeoffs: "" },
{ id: "e", label: "E", tradeoffs: "" },
],
}),
/between 2 and 4/,
);
});
test("rejects duplicate option ids", () => {
assert.throws(
() =>
buildEscalationArtifact({
...baseArtifact,
options: [
{ id: "a", label: "A", tradeoffs: "" },
{ id: "a", label: "A2", tradeoffs: "" },
],
}),
/unique ids/,
);
});
test("recommendation must reference an option id", () => {
assert.throws(
() =>
buildEscalationArtifact({
...baseArtifact,
recommendation: "nonexistent",
}),
/not one of the option ids/,
);
});
test("happy path returns artifact with version=1 + createdAt", () => {
const art = buildEscalationArtifact(baseArtifact);
assert.equal(art.version, 1);
assert.equal(art.taskId, "T01");
assert.equal(art.recommendation, "fail");
assert.match(art.createdAt, /^\d{4}-\d{2}-\d{2}T/);
});
});
describe("writeEscalationArtifact + DB flag flip", () => {
test("continueWithDefault=false → escalation_pending=1", () => {
const art = buildEscalationArtifact(baseArtifact);
const path = writeEscalationArtifact(dir, art);
assert.match(path, /T01-ESCALATION\.json$/);
const t = getTask("M001", "S01", "T01");
assert.equal(t?.escalation_pending, 1);
assert.equal(t?.escalation_awaiting_review, 0);
assert.equal(t?.escalation_artifact_path, path);
});
test("continueWithDefault=true → awaiting_review (no pause)", () => {
const art = buildEscalationArtifact({
...baseArtifact,
continueWithDefault: true,
});
writeEscalationArtifact(dir, art);
const t = getTask("M001", "S01", "T01");
assert.equal(t?.escalation_pending, 0);
assert.equal(t?.escalation_awaiting_review, 1);
});
test("two writes flip the flag pair atomically (mutually exclusive)", () => {
writeEscalationArtifact(dir, buildEscalationArtifact(baseArtifact));
const t1 = getTask("M001", "S01", "T01");
assert.equal(t1?.escalation_pending, 1);
assert.equal(t1?.escalation_awaiting_review, 0);
writeEscalationArtifact(
dir,
buildEscalationArtifact({ ...baseArtifact, continueWithDefault: true }),
);
const t2 = getTask("M001", "S01", "T01");
assert.equal(t2?.escalation_pending, 0);
assert.equal(t2?.escalation_awaiting_review, 1);
});
});
describe("detectPendingEscalation", () => {
test("empty slice → null", () => {
assert.equal(detectPendingEscalation(getSliceTasks("M001", "S01"), dir), null);
});
test("paused task → returns task id", () => {
writeEscalationArtifact(dir, buildEscalationArtifact(baseArtifact));
assert.equal(
detectPendingEscalation(getSliceTasks("M001", "S01"), dir),
"T01",
);
});
test("awaiting_review does NOT trigger pause", () => {
writeEscalationArtifact(
dir,
buildEscalationArtifact({ ...baseArtifact, continueWithDefault: true }),
);
assert.equal(detectPendingEscalation(getSliceTasks("M001", "S01"), dir), null);
});
test("respondedAt artifact does NOT trigger pause", () => {
writeEscalationArtifact(dir, buildEscalationArtifact(baseArtifact));
resolveEscalation(dir, "M001", "S01", "T01", "fail", "rationale");
assert.equal(detectPendingEscalation(getSliceTasks("M001", "S01"), dir), null);
});
});
describe("resolveEscalation", () => {
beforeEach(() => {
writeEscalationArtifact(dir, buildEscalationArtifact(baseArtifact));
});
test("accept selects the recommended option", () => {
const r = resolveEscalation(dir, "M001", "S01", "T01", "accept", "");
assert.equal(r.status, "resolved");
assert.equal(r.chosenOption?.id, "fail"); // recommendation
const t = getTask("M001", "S01", "T01");
assert.equal(t?.escalation_pending, 0);
});
test("explicit option-id resolution works", () => {
const r = resolveEscalation(
dir,
"M001",
"S01",
"T01",
"overwrite",
"caller knows best",
);
assert.equal(r.status, "resolved");
assert.equal(r.chosenOption?.id, "overwrite");
const art = readEscalationArtifact(
getTask("M001", "S01", "T01")!.escalation_artifact_path!,
);
assert.equal(art?.userChoice, "overwrite");
assert.equal(art?.userRationale, "caller knows best");
});
test("invalid choice → invalid-choice with valid list", () => {
const r = resolveEscalation(dir, "M001", "S01", "T01", "banana", "");
assert.equal(r.status, "invalid-choice");
assert.match(r.message, /accept, overwrite, fail/);
});
test("re-resolve already-resolved → already-resolved status", () => {
resolveEscalation(dir, "M001", "S01", "T01", "fail", "");
const r = resolveEscalation(dir, "M001", "S01", "T01", "overwrite", "");
assert.equal(r.status, "already-resolved");
});
test("unknown task → not-found", () => {
const r = resolveEscalation(dir, "M001", "S01", "T99", "accept", "");
assert.equal(r.status, "not-found");
});
});
describe("claimOverrideForInjection (carry-forward)", () => {
test("no escalation in slice → null", () => {
assert.equal(claimOverrideForInjection(dir, "M001", "S01"), null);
});
test("pending (unresolved) escalation → null (no carry-forward yet)", () => {
writeEscalationArtifact(dir, buildEscalationArtifact(baseArtifact));
assert.equal(claimOverrideForInjection(dir, "M001", "S01"), null);
});
test("resolved escalation → returns block + sourceTaskId, sets DB flag", () => {
writeEscalationArtifact(dir, buildEscalationArtifact(baseArtifact));
resolveEscalation(dir, "M001", "S01", "T01", "fail", "rationale");
const r = claimOverrideForInjection(dir, "M001", "S01");
assert.ok(r);
assert.equal(r?.sourceTaskId, "T01");
assert.match(r?.injectionBlock ?? "", /Escalation Override/);
assert.match(r?.injectionBlock ?? "", /User rationale/);
const t = getTask("M001", "S01", "T01");
assert.equal(t?.escalation_override_applied, 1);
});
test("second claim → null (race-safe idempotent)", () => {
writeEscalationArtifact(dir, buildEscalationArtifact(baseArtifact));
resolveEscalation(dir, "M001", "S01", "T01", "fail", "");
assert.ok(claimOverrideForInjection(dir, "M001", "S01"));
assert.equal(claimOverrideForInjection(dir, "M001", "S01"), null);
});
test("clearTaskEscalationFlags preserves artifact path for audit trail", () => {
writeEscalationArtifact(dir, buildEscalationArtifact(baseArtifact));
const before = getTask("M001", "S01", "T01")!.escalation_artifact_path;
clearTaskEscalationFlags("M001", "S01", "T01");
const after = getTask("M001", "S01", "T01");
assert.equal(after?.escalation_pending, 0);
assert.equal(after?.escalation_awaiting_review, 0);
assert.equal(after?.escalation_artifact_path, before);
});
});