feat: moderator recognizes $SUSPEND as pseudo-role target

- Add GraphPseudoRole type ($END | $SUSPEND) to workflow-protocol
- Add 'suspended' to ThreadStatus
- evaluate() returns EvaluateSuspendResult for $SUSPEND targets
- Thread show/list derive suspended status from moderator evaluation
- validate-semantic treats $SUSPEND like $END (valid target, no outgoing edges)
- Tests: routing to $SUSPEND, mustache rendering, thread status display

Closes #588
This commit is contained in:
2026-06-02 04:39:29 +00:00
parent a335471cc7
commit b0ef9c55a9
12 changed files with 316 additions and 32 deletions
@@ -2,7 +2,8 @@ import type { WorkflowPayload } from "@uncaged/workflow-protocol";
type SchemaObj = Record<string, unknown>;
const RESERVED_NAMES = new Set(["$START", "$END"]);
const RESERVED_NAMES = new Set(["$START", "$END", "$SUSPEND"]);
const PSEUDO_TARGETS = new Set(["$END", "$SUSPEND"]);
/** Extract mustache variable names from a prompt string. */
function extractMustacheVars(prompt: string): string[] {
@@ -110,9 +111,13 @@ function checkGraphStructure(payload: WorkflowPayload, errors: string[]): void {
errors.push("$END must not have outgoing edges");
}
if (graphNodes.has("$SUSPEND")) {
errors.push("$SUSPEND must not have outgoing edges");
}
for (const [node, statusMap] of Object.entries(payload.graph)) {
for (const [status, target] of Object.entries(statusMap)) {
if (target.role !== "$END" && !roleNames.has(target.role)) {
if (!PSEUDO_TARGETS.has(target.role) && !roleNames.has(target.role)) {
errors.push(`edge ${node}${status}: unknown target role "${target.role}"`);
}
}
@@ -129,7 +134,7 @@ function collectReachableRoles(graph: WorkflowPayload["graph"]): Set<string> {
const queue: string[] = [];
for (const target of Object.values(startEdges)) {
if (target.role !== "$END" && !reachable.has(target.role)) {
if (!PSEUDO_TARGETS.has(target.role) && !reachable.has(target.role)) {
reachable.add(target.role);
queue.push(target.role);
}
@@ -140,7 +145,7 @@ function collectReachableRoles(graph: WorkflowPayload["graph"]): Set<string> {
const edges = graph[current];
if (!edges) continue;
for (const target of Object.values(edges)) {
if (target.role !== "$END" && !reachable.has(target.role)) {
if (!PSEUDO_TARGETS.has(target.role) && !reachable.has(target.role)) {
reachable.add(target.role);
queue.push(target.role);
}