godcrm/backend/services/agent-run-dispatcher/state-machine.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

277 lines
10 KiB
JavaScript

/**
* ADR-0042 — Agent liveness FSM (pure module, no I/O).
*
* Drives the per-run liveness state from NDJSON stream events. The dispatcher
* (`index.js`, Task 4) owns persistence + secondary-signal sampling; this
* module just maps `(prev, event) → (next, sideEffects)` deterministically.
*
* States (5): idle, thinking, tool_active, closing, stuck_check.
* `terminal` from §5 is NOT exposed here — it's a post-FSM kill outcome
* decided by the dispatcher tick once secondary signals confirm death.
*
* `stuck_check` is a *hold* from the FSM's perspective: only the dispatcher's
* secondary-signal verdict can release it (alive → restore prior, dead →
* terminal). Events arriving during stuck_check therefore keep state pinned
* but still emit `bump_event` so heartbeat freshness updates.
*
* Event shapes accepted (forward-compat with `--output-format=stream-json`,
* Phase 10 of ADR-0030):
* - `{type:'message_start', ...}`
* - `{type:'content_block_start', content_block:{type:'tool_use'|'text', ...}}`
* - `{type:'content_block_delta'|'content_block_stop'|'message_delta'|'ping', ...}`
* - `{type:'message_stop'}`
* - `{type:'tool_use', name, id, input}` (top-level alias)
* - `{type:'tool_result', tool_use_id, ...}` (top-level alias)
* - `{type:'user', message:{content:[{type:'tool_result', tool_use_id}]}}`
* - `{type:'error', ...}`
*
* Today's runner emits `info|output|result|error` (see scripts/run-claude-on-ticket.sh).
* Those fall through to `BUMP_EVENT`-only — no state churn — until Phase 10.
*
* @see ADR-0042 §5 (states), §6 (transitions), §7 (persisted shape), §8 (signature).
*/
export const STATES = Object.freeze({
IDLE: 'idle',
THINKING: 'thinking',
TOOL_ACTIVE: 'tool_active',
CLOSING: 'closing',
STUCK_CHECK: 'stuck_check',
});
export const SIDE_EFFECTS = Object.freeze({
BUMP_EVENT: 'bump_event',
BUMP_COMPLETION_INTENT: 'bump_completion_intent',
BUMP_MEANINGFUL_COUNT: 'bump_meaningful_count',
TOOL_STARTED: 'tool_started',
TOOL_FINISHED: 'tool_finished',
});
// Defaults match `_workflow_config` schema (ADR-0042 §Config).
export const DEFAULT_COMPLETION_TOOLS = Object.freeze([
'send_chat_message',
'send_widget_message',
'send_ticket_message',
]);
export const DEFAULT_TERMINAL_STATES = Object.freeze([
'Done',
'Closed',
'Resolved',
]);
// Tools whose `input.status` can flip the run to `closing` when the value
// is in `terminalStates`. Status-set is a separate completion-intent path
// from name-based detection.
const STATUS_SETTER_TOOLS = new Set([
'update_ticket_status',
'update_table_row',
'mcp__godcrm__update_table_row',
]);
export const INITIAL = Object.freeze({ state: STATES.IDLE, currentTool: null });
function asSet(maybeSet, fallback) {
if (maybeSet instanceof Set) return maybeSet;
if (Array.isArray(maybeSet)) return new Set(maybeSet);
return new Set(fallback);
}
function extractToolUse(evt) {
if (!evt || typeof evt !== 'object') return null;
// Top-level alias: `{type:'tool_use', name, id, input}`
if (evt.type === 'tool_use' && typeof evt.name === 'string') {
return {
tool_use_id: evt.id ?? evt.tool_use_id ?? null,
name: evt.name,
input: evt.input ?? null,
};
}
// Anthropic SDK shape: content_block_start with tool_use block.
if (
evt.type === 'content_block_start' &&
evt.content_block &&
typeof evt.content_block === 'object' &&
evt.content_block.type === 'tool_use' &&
typeof evt.content_block.name === 'string'
) {
const cb = evt.content_block;
return {
tool_use_id: cb.id ?? null,
name: cb.name,
input: cb.input ?? null,
};
}
return null;
}
function extractToolResult(evt) {
if (!evt || typeof evt !== 'object') return null;
// Top-level alias.
if (evt.type === 'tool_result') {
return { tool_use_id: evt.tool_use_id ?? null };
}
// Anthropic stream-json: tool_result lives inside a synthetic user message.
if (evt.type === 'user' && evt.message && Array.isArray(evt.message.content)) {
const tr = evt.message.content.find(
(c) => c && typeof c === 'object' && c.type === 'tool_result'
);
if (tr) return { tool_use_id: tr.tool_use_id ?? null };
}
return null;
}
function pickStatusValue(input) {
if (!input || typeof input !== 'object') return null;
if (typeof input.status === 'string') return input.status;
if (typeof input.new_status === 'string') return input.new_status;
if (input.data && typeof input.data === 'object' && typeof input.data.status === 'string') {
return input.data.status;
}
return null;
}
function isCompletionIntent(toolUse, completionTools, terminalStates) {
if (!toolUse || typeof toolUse.name !== 'string') return false;
if (completionTools.has(toolUse.name)) return true;
if (STATUS_SETTER_TOOLS.has(toolUse.name)) {
const status = pickStatusValue(toolUse.input);
if (status && terminalStates.has(status)) return true;
}
return false;
}
function nextAttemptIdx(prevTool, toolUse) {
if (prevTool && prevTool.name === toolUse.name) {
const prior = Number.isInteger(prevTool.attempt_idx) ? prevTool.attempt_idx : 0;
return prior + 1;
}
return 0;
}
function makeNext(state, currentTool, sideEffects) {
return { state, currentTool, sideEffects };
}
/**
* Apply one stream event to the FSM.
*
* @param {{state: string, currentTool: object|null}} prev
* @param {object} evt — parsed NDJSON line
* @param {{terminalStates?: Set<string>|string[], completionTools?: Set<string>|string[]}} [opts]
* @returns {{state: string, currentTool: object|null, sideEffects: string[]}}
* `currentTool` shape: `{name, tool_use_id, attempt_idx}` when state=tool_active|closing.
* `sideEffects` ⊂ Object.values(SIDE_EFFECTS).
*/
export function transition(prev, evt, opts = {}) {
const prevState = (prev && typeof prev.state === 'string') ? prev.state : STATES.IDLE;
const prevTool = (prev && prev.currentTool && typeof prev.currentTool === 'object')
? prev.currentTool
: null;
// Malformed / non-object event → unchanged, no throw.
if (!evt || typeof evt !== 'object' || typeof evt.type !== 'string') {
return makeNext(prevState, prevTool, []);
}
const completionTools = asSet(opts.completionTools, DEFAULT_COMPLETION_TOOLS);
const terminalStates = asSet(opts.terminalStates, DEFAULT_TERMINAL_STATES);
// stuck_check is a hold — only the dispatcher's secondary-signal verdict
// can transition out. Events still bump the heartbeat counter.
if (prevState === STATES.STUCK_CHECK) {
return makeNext(STATES.STUCK_CHECK, prevTool, [SIDE_EFFECTS.BUMP_EVENT]);
}
// tool_result — closes a tool_active or closing leg.
const toolResult = extractToolResult(evt);
if (toolResult) {
const sideEffects = [SIDE_EFFECTS.BUMP_EVENT, SIDE_EFFECTS.BUMP_MEANINGFUL_COUNT];
if (
prevTool &&
(toolResult.tool_use_id == null || toolResult.tool_use_id === prevTool.tool_use_id)
) {
sideEffects.push(SIDE_EFFECTS.TOOL_FINISHED);
return makeNext(STATES.IDLE, null, sideEffects);
}
// Non-matching id (or no current tool) → state unchanged. The orphan
// result is logged for observability but doesn't unblock the run.
return makeNext(prevState, prevTool, sideEffects);
}
// tool_use — starts a tool, or transitions to closing on completion intent.
const toolUse = extractToolUse(evt);
if (toolUse) {
const sideEffects = [SIDE_EFFECTS.BUMP_EVENT, SIDE_EFFECTS.BUMP_MEANINGFUL_COUNT];
const closing = isCompletionIntent(toolUse, completionTools, terminalStates);
// closing-state stickiness: once we're in closing, a subsequent NON-
// completion tool_use is part of the handoff — keep state, don't reset
// `currentTool` (otherwise the closing tool's tool_result wouldn't match).
if (prevState === STATES.CLOSING && !closing) {
return makeNext(STATES.CLOSING, prevTool, sideEffects);
}
const newTool = {
name: toolUse.name,
tool_use_id: toolUse.tool_use_id,
attempt_idx: nextAttemptIdx(prevTool, toolUse),
};
sideEffects.push(SIDE_EFFECTS.TOOL_STARTED);
if (closing) {
// `bump_completion_intent` fires only on the entry edge into closing.
if (prevState !== STATES.CLOSING) {
sideEffects.push(SIDE_EFFECTS.BUMP_COMPLETION_INTENT);
}
return makeNext(STATES.CLOSING, newTool, sideEffects);
}
return makeNext(STATES.TOOL_ACTIVE, newTool, sideEffects);
}
// content_block_start with text → thinking (only when arriving from idle).
if (
evt.type === 'content_block_start' &&
evt.content_block &&
typeof evt.content_block === 'object' &&
evt.content_block.type === 'text'
) {
const sideEffects = [SIDE_EFFECTS.BUMP_EVENT];
if (prevState === STATES.IDLE) {
return makeNext(STATES.THINKING, prevTool, sideEffects);
}
return makeNext(prevState, prevTool, sideEffects);
}
// message_start → thinking (only from idle).
if (evt.type === 'message_start') {
const sideEffects = [SIDE_EFFECTS.BUMP_EVENT, SIDE_EFFECTS.BUMP_MEANINGFUL_COUNT];
if (prevState === STATES.IDLE) {
return makeNext(STATES.THINKING, prevTool, sideEffects);
}
return makeNext(prevState, prevTool, sideEffects);
}
// message_stop → idle (unless a tool is still in flight: tool_active
// without tool_result yet, stay in tool_active waiting).
if (evt.type === 'message_stop') {
const sideEffects = [SIDE_EFFECTS.BUMP_EVENT, SIDE_EFFECTS.BUMP_MEANINGFUL_COUNT];
if (prevState === STATES.TOOL_ACTIVE && prevTool) {
return makeNext(STATES.TOOL_ACTIVE, prevTool, sideEffects);
}
return makeNext(STATES.IDLE, null, sideEffects);
}
// Any other recognized stream event (message_delta, content_block_delta,
// content_block_stop, ping, error, info, output, result, ...) — non-
// structural, just bump_event so heartbeat stays fresh.
return makeNext(prevState, prevTool, [SIDE_EFFECTS.BUMP_EVENT]);
}
export default {
STATES,
SIDE_EFFECTS,
DEFAULT_COMPLETION_TOOLS,
DEFAULT_TERMINAL_STATES,
INITIAL,
transition,
};