workflow — phases, signals and the run lifecycle
Two machines, not one
NPCAgent runs two independent state machines over the same unit of work, and
conflating them is the fastest way to misread the code. The run lifecycle
answers "what should the operator do about this?" — seven statuses in the
runs table. The workflow phase machine answers "where is the
work?" — nine phases in the workflow_state table. They are joined only at the
end, by _finalize_run_after_executor().
| Run lifecycle | Workflow phase machine | |
|---|---|---|
| Module | kernel/lifecycle.py | kernel/workflow_state.py |
| Table | runs | workflow_state |
| States | 7 (RunStatus) | 9 (WorkflowPhase) |
| Driven by | direct calls: start, complete, fail, escalate, cancel, resume | 13 WorkflowEvent values, delivered as rows in workflow_signals |
| Concurrency guard | CAS on the previous status | CAS on an integer version column |
| On conflict | IllegalTransition | CASConflict (retried) or IllegalWorkflowTransition (dropped) |
| Emits | kernel events on the bus | nothing directly; the executor observes and emits |
The phase machine is a declared port. Its docstring says the nine phases "match the C++
WorkflowPhase enum" and that database string values match the C++
phaseToString mapping "so a workflow_state row stays interoperable with the
gpucats schema if we ever need to bridge". The 13 events "match the C++
WorkflowEvent enum 1:1" and are stored in SCREAMING_SNAKE_CASE for log
compatibility. This is why the transition table reads like a switch statement — it
is one.
Phase machine
WORKFLOW_TRANSITIONS, drawn. Green is the happy
path, red is rework. Note the two asymmetries. First, IDLE has exactly one
forward edge — WORKFLOW_STARTED always lands in RESEARCHING,
even for the single-phase quick-fix workflow, so every run traverses the
same machine regardless of how many catalog phases it has. Second,
REVISION_NEEDED returns to PLANNING rather than to
REVIEWING, which forces the revised plan to be re-parsed before it can be
re-reviewed.The catalog workflow definition and the phase enum are different things with
overlapping names. workflow_def.py defines its own
WorkflowPhase Pydantic model — a catalog entry with
id, name, agent_role, prompt,
wait_for and next. The executor maps between them in
_phase_for_state, which is why EXECUTING accepts a catalog
phase called either executing or execute, and
CODE_REVIEWING accepts code-reviewing or
reviewing.
Signals
Phases do not advance because the executor decides they should. They advance because a
row appears in workflow_signals. The provider CLI writes that row by calling
an NPCAgent tool over MCP. This is the mechanism that makes the whole design honest: the
orchestrator cannot fake progress, because progress is a durable row written by the agent
that did the work.
| Tool name | Enqueues | Phase effect |
|---|---|---|
start_workflow | WORKFLOW_STARTED | idle → researching |
complete_research | RESEARCH_COMPLETE | researching → planning |
request_review | PLAN_READY | planning → reviewing |
approve_review | REVIEW_APPROVED | reviewing → executing |
reject_review | REVIEW_REJECTED | reviewing → revision_needed |
mark_step_complete | STEP_COMPLETE | executing → executing |
report_error | ERROR_OCCURRED | any non-terminal → failed |
cancel_workflow | CANCELLED | any non-terminal → failed |
escalate_workflow | ERROR_OCCURRED | as above, with escalated: true in the payload |
enqueue_workflow_signal | any of the 13, by name | generic escape hatch |
get_workflow_state | — | read; returns {found: false} when no row exists |
Two events in the enum — ALL_STEPS_COMPLETE and
CODE_REVIEW_APPROVED — have no dedicated wrapper in this table; they arrive
through enqueue_workflow_signal or through the review tools in
plugins/tools/review.py. The distinction between
report_error and escalate_workflow is only visible downstream:
both enqueue ERROR_OCCURRED, but escalate_workflow stamps
escalated: true and an escalation_reason into
phase_data_json, and _workflow_failure_reason() reads that to
decide whether the run should be parked in needs_user or routed through the
retry orchestrator.
The invariant that keeps it honest
raise RuntimeError( "agent completed without enqueuing a workflow signal; it must " "call an NPCAgent workflow tool such as start_workflow, " "complete_research, request_review, request_code_review, " "code_review_complete, report_error, or escalate_workflow" )
Raised when all three of these hold: no signal was enqueued during
the phase, zero signals were applied, and the workflow_state.version did
not advance. An agent that exits quietly having done nothing therefore fails the run
loudly instead of appearing to succeed.
Signal delivery semantics
- Claiming
- A signal is pending when
applied_at IS NULLand eitherclaimed_at IS NULLorclaimed_atis older than the claim timeout (default 300 s)._try_claimcompare-and-sets onapply_attempts, so two processors racing for the same row cannot both win. - Illegal transitions are dropped, not retried
- An
IllegalWorkflowTransitionmarks the signalappliedand logssignals.illegal_transition_dropped. Retrying would never succeed, so it is retired. It is also not counted as an applied signal, which is what lets the no-progress invariant above still fire. - CAS conflicts are retried
- A
CASConflictleavesapplied_atnull so the next scan picks it up against the newer version. - Poison signals expire
- After
max_attempts(default 5) the signal is marked applied and logged assignals.max_attempts_exceededrather than looping forever. - Missing state has a grace window
- If no
workflow_staterow exists yet, the processor warnssignals.no_workflow_stateand honours a 30-second grace period before giving up — this covers the race where a tool fires before the executor has created the row.
Round trip
One phase, end to end. The important structural fact is that the provider CLI never talks to the executor — everything passes through the database.
write_cao_profile emits an mcpServers.npcagent stanza
pointing at [sys.executable, "-m", "npcagent.plugins.clis.npc_mcp_server",
"--repo-root", <parent repo>]. The --repo-root is deliberately the
parent repository, not the worktree, so signals land in the database the executor is
watching while file tools still write inside the worktree.Run lifecycle
escalate() is what
DeadlineWatcher calls with reason="timeout" when a running
run passes its deadline_at; it also publishes a
NeedsApprovalEvent with options=["resume", "cancel"], which is
what surfaces render as a Needs-You item.Every transition is written with update_run_if_status, a compare-and-set on
the status observed at the start of the call. Losing the race raises
IllegalTransition("run-state transition lost compare-and-set race: …") rather
than silently overwriting. The event is published after the write succeeds, so no
subscriber ever sees a state change that did not land.
Workflow catalog
Four definitions ship in npcagent/catalog/workflows/. A workflow is a JSON
file naming an ordered list of phases, each bound to an agent role and a prompt template,
plus a transitions map for phases whose next is
null.
| id | Phases | wait_for | Selected when the goal contains |
|---|---|---|---|
quick-fix |
execute (developer) |
— | nothing else matches — the default |
bug-hunt |
planning · reviewing · executing · code-reviewing |
plan.md |
bug, fix, debug, regression |
blueprint-build |
researching · planning · reviewing · executing · code-reviewing |
plan.md |
marketing, site, saas, landing |
full-expedition |
same five, phase 3 titled "Spec Review" | spec.md |
roguelike, game, phaser |
full-expedition describes itself as "Complete spec-driven development with
tests, thorough review, full isolation" and lists "All tests pass and coverage is
adequate" among its success criteria — but its phase list is structurally identical to
blueprint-build. The only differences are the display name of phase three
and the file it waits for. There is no distinct testing phase. The criteria are
instructions to the reviewing agent, not gates the executor enforces.
Definitions are loaded by LocalWorkflowProvider from a three-level search
stack where later entries override earlier ones: the bundled
npcagent/catalog/workflows/, then
$NPCAGENT_USER_WORKFLOW_DIR or ~/.npcagent/workflows/, then
./.npcagent/workflows/. A malformed JSON file logs
workflow_provider.invalid_workflow and is skipped rather than aborting
load.