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 in62dacb627. 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 in62dacb627). - 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:
parent
d3574f3c4d
commit
ea8819906d
2 changed files with 110 additions and 1 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue