feat(sf): wire escalation detection into state derivation (PDD)

State derivation now emits phase='escalating-task' when a task in the
active slice is paused waiting for a user decision. Builds on the
type+DDL foundation in 62dacb627. Together they get the loop to STOP
when there's a pending escalation rather than carrying past an
undocumented decision.

PDD spec for this change:

Purpose: pause auto-mode at the state-derivation layer when any task
  in the active slice has escalation_pending=1 with an unresolved
  escalation artifact. The dispatcher (next fire) sees phase=
  'escalating-task' and returns 'stop' rather than dispatching new
  work over a pending decision.
Consumer: state.ts deriveStateFromDb() callers — the auto-loop, the
  /sf status dashboard, the future /sf escalate command.
Contract:
  1. Empty tasks list → null (no pause). Verified.
  2. Task without escalation_pending → null. Verified.
  3. escalation_pending=1 but no artifact path → null (treats as
     not actionable). Verified.
  4. escalation_pending=1 + valid artifact + no respondedAt → returns
     task id; state.phase = 'escalating-task' with task id in
     blockers and a /sf escalate hint in nextAction. Verified.
  5. respondedAt set → null (already resolved, fall through).
     Verified.
Failure boundary: any read/parse failure on the artifact returns null
  from detectPendingEscalation — state derivation falls through to
  existing behavior. Strict schema validation in readEscalationArtifact
  treats malformed artifacts as 'no actionable escalation here.'
Evidence: smoke test exercises all 5 contract conditions end-to-end
  with real filesystem artifacts. Typecheck clean. Existing state
  derivation paths unchanged when no task is paused (early continue
  on escalation_pending !== 1 in detectPendingEscalation's loop).
Non-goals:
  - Dispatch rule that returns 'stop' on phase='escalating-task'
    (next fire — needs no DB changes, just an auto-dispatch.ts edit)
  - Escalation artifact creation tools (gsd-2 has writeEscalation-
    Artifact + buildEscalationArtifact + setTaskEscalationPending —
    those land when a task agent needs to file an escalation)
  - /sf escalate user command (later fire)
Invariants:
  - Safety: no escalation pending → 0 file system reads (loop early-
    continues), zero behavior change vs current.
  - Liveness: if a task IS paused, state.phase becomes 'escalating-
    task' immediately — no race with dispatch ordering.
Assumptions verified:
  - SF's EscalationArtifact + EscalationOption types match gsd-2's
    schema (verified earlier this session).
  - TaskRow has escalation_pending and escalation_artifact_path
    fields (added in 62dacb627).
  - getSliceTasks() returns DB rows that include those fields after
    the v23 migration ran.
  - state.ts has the slice-level scope I need (activeMilestone +
    activeSlice + registry + requirements + progress all visible at
    the insertion point).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-02 20:06:29 +02:00
parent d3574f3c4d
commit ea8819906d
2 changed files with 110 additions and 1 deletions

View file

@ -1,4 +1,82 @@
// ADR-011 Phase 2 Mid-Execution Escalation — stub pending full DB schema port.
// SF Extension — ADR-011 Phase 2 Mid-Execution Escalation (gsd-2 ADR)
//
// Currently scoped to detection only. The wider gsd-2 module (build/write/
// resolve/list) requires several DB helpers SF doesn't yet have; those land
// in subsequent fires. This file covers what state derivation + dispatch
// need today: reading an artifact and detecting whether any task in a slice
// is paused waiting for a user response.
import { existsSync, readFileSync } from "node:fs";
import type { TaskRow } from "./sf-db.js";
import type { EscalationArtifact, EscalationOption } from "./types.js";
/** Read an escalation artifact by path. Returns null when missing or malformed.
*
* Schema validation is strict (matches the eventual buildEscalationArtifact)
* so a hand-edited artifact cannot be weaker than what the writer would emit.
* Downstream callers can treat null as "no actionable escalation here." */
export function readEscalationArtifact(path: string): EscalationArtifact | null {
if (!existsSync(path)) return null;
try {
const raw = readFileSync(path, "utf-8");
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object") return null;
const art = parsed as Partial<EscalationArtifact>;
if (art.version !== 1) return null;
if (typeof art.taskId !== "string" || art.taskId.length === 0) return null;
if (typeof art.sliceId !== "string" || art.sliceId.length === 0) return null;
if (typeof art.milestoneId !== "string" || art.milestoneId.length === 0) {
return null;
}
if (typeof art.question !== "string" || art.question.length === 0) {
return null;
}
if (
!Array.isArray(art.options) ||
art.options.length < 2 ||
art.options.length > 4
) {
return null;
}
const optionIds = new Set<string>();
for (const opt of art.options) {
if (!opt || typeof opt !== "object") return null;
const o = opt as Partial<EscalationOption>;
if (typeof o.id !== "string" || o.id.length === 0) return null;
if (typeof o.label !== "string") return null;
if (typeof o.tradeoffs !== "string") return null;
if (optionIds.has(o.id)) return null;
optionIds.add(o.id);
}
if (typeof art.recommendation !== "string") return null;
if (!art.options.some((o) => o.id === art.recommendation)) return null;
if (typeof art.continueWithDefault !== "boolean") return null;
if (typeof art.createdAt !== "string") return null;
return art as EscalationArtifact;
} catch {
return null;
}
}
/** Returns the task id of the first task with an un-resolved pause-escalation
* (escalation_pending=1, artifact present, no respondedAt). Returns null when
* nothing in the slice is paused caller should treat that as "carry on."
*
* O(n) over the slice's tasks, with an early continue when escalation_pending
* isn't set, so the common no-escalation path costs almost nothing. */
export function detectPendingEscalation(
tasks: TaskRow[],
_basePath: string,
): string | null {
for (const t of tasks) {
if (t.escalation_pending !== 1) continue;
if (!t.escalation_artifact_path) continue;
const art = readEscalationArtifact(t.escalation_artifact_path);
if (art && !art.respondedAt) return t.id;
}
return null;
}
export function claimOverrideForInjection(
_basePath: string,

View file

@ -5,6 +5,7 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { debugCount, debugTime } from "./debug-logger.js";
import { detectPendingEscalation } from "./escalation.js";
import {
isValidTaskSummary,
loadFile,
@ -2167,6 +2168,36 @@ export async function _deriveStateImpl(basePath: string): Promise<SFState> {
}
}
// ── Mid-execution escalation (ADR-011 P2 — gsd-2 ADR) ────────────────
// Pause the loop if any task in the active slice has escalation_pending=1
// and an unresolved escalation artifact. The user must run /sf escalate
// resolve before auto-mode will continue. Falls through (returns null
// from detectPendingEscalation) when nothing is paused — no perf cost
// in the common path.
{
const dbTasks = getSliceTasks(activeMilestone.id, activeSlice.id);
const escalatingTaskId = detectPendingEscalation(dbTasks, basePath);
if (escalatingTaskId) {
return {
activeMilestone,
activeSlice,
activeTask: { id: escalatingTaskId, title: "" },
phase: "escalating-task",
recentDecisions: [],
blockers: [
`Task ${escalatingTaskId} requires a user decision before the loop can proceed`,
],
nextAction: `Run \`/sf escalate show ${escalatingTaskId}\` to review the options, then \`/sf escalate resolve ${escalatingTaskId} <choice>\` to proceed.`,
registry,
requirements,
progress: {
milestones: milestoneProgress,
slices: sliceProgress,
},
};
}
}
// ── Blocker detection: scan completed task summaries ──────────────────
// If any completed task has blocker_discovered: true and no REPLAN.md
// exists yet, transition to replanning-slice instead of executing.