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(
|
export function claimOverrideForInjection(
|
||||||
_basePath: string,
|
_basePath: string,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||||
import { join, resolve } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
import { debugCount, debugTime } from "./debug-logger.js";
|
import { debugCount, debugTime } from "./debug-logger.js";
|
||||||
|
import { detectPendingEscalation } from "./escalation.js";
|
||||||
import {
|
import {
|
||||||
isValidTaskSummary,
|
isValidTaskSummary,
|
||||||
loadFile,
|
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 ──────────────────
|
// ── Blocker detection: scan completed task summaries ──────────────────
|
||||||
// If any completed task has blocker_discovered: true and no REPLAN.md
|
// If any completed task has blocker_discovered: true and no REPLAN.md
|
||||||
// exists yet, transition to replanning-slice instead of executing.
|
// exists yet, transition to replanning-slice instead of executing.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue