Skip to content

Pipeline Lifecycle Contract

Status: Implemented — All gaps have been resolved. pipeline_next_action calls sm.PhaseStart() and emits phase-start events; pipeline_report_result emits phase-complete events; checkpoint events are emitted when setting awaiting_human.

Purpose

This document defines the mandatory state-transition contract for pipeline phases. Every phase must follow a symmetric PhaseStart / PhaseComplete lifecycle. No component may bypass this contract.

The Problem This Solves

The pipeline has two execution paths:

  1. Standalone handlers (phase_start, phase_complete MCP tools) — call sm.PhaseStart() and sm.PhaseComplete() with proper state updates and event emission
  2. Pipeline engine (pipeline_next_action + pipeline_report_result) — drives the main loop but historically bypassed sm.PhaseStart(), causing:
    • CurrentPhaseStatus stuck at "pending" instead of "in_progress"
    • Timestamps.PhaseStarted never set
    • phase-start event never emitted (dashboard shows nothing until completion)
    • phase-complete event never emitted from pipeline_report_result

This contract eliminates the inconsistency by requiring all paths to honour the same lifecycle.

Phase Lifecycle

Every phase transition follows this sequence. No step may be skipped.

pending ──[PhaseStart]──> in_progress ──[PhaseComplete]──> (next phase: pending)
   │                           │
   │                           ├──[PhaseFail]──> failed
   │                           └──[Checkpoint]──> awaiting_human

   └──[PhaseCompleteSkipped]──> (next phase: pending)

State Mutations

TransitionMethodSets CurrentPhaseStatusSets Timestamps.PhaseStartedEmits Event
Startsm.PhaseStart(workspace, phase)"in_progress"nowISO()phase-start
Completesm.PhaseComplete(workspace, phase)"pending" (next) or "completed"nilphase-complete
Failsm.PhaseFail(workspace, msg)"failed"(unchanged)phase-fail
Checkpointsm.Checkpoint(workspace, phase, ...)"awaiting_human"(unchanged)checkpoint
Skipsm.PhaseCompleteSkipped(workspace, phase)"pending" (next)nil(none)

Note on event emission: sm.PhaseStart() and sm.PhaseComplete() are pure state mutations — they do not emit events themselves. The caller is responsible for calling publishEvent() after a successful state mutation. This keeps StateManager free of EventBus dependencies (see Design Decisions).

Invariants

  1. Symmetric start/complete: Every PhaseStart must be followed by exactly one PhaseComplete, PhaseFail, or Abandon. No phase may complete without first being started.
  2. Single writer: Only one component transitions a given phase. In the pipeline loop, pipeline_next_action owns PhaseStart and pipeline_report_result owns PhaseComplete.
  3. Event-state consistency: Events are emitted after the corresponding state mutation succeeds, never before. If the mutation fails, no event is emitted.
  4. Idempotency: Engine.NextAction() is read-only — it never mutates state. It returns a signal; the caller (pipeline_next_action) is responsible for state transitions.

Execution Paths

Path 1: Pipeline Engine (primary)

The main execution loop. Used for all automated pipeline runs via /forge.

pipeline_next_action
  ├── eng.NextAction() → Action        [read-only decision]
  ├── sm.PhaseStart(workspace, phase)  [state: pending → in_progress]
  ├── publishEvent("phase-start")      [dashboard notification]
  └── return Action to orchestrator    [orchestrator executes it]

[orchestrator executes action: Agent, exec, write_file]

pipeline_report_result
  ├── sm.PhaseLog(...)                 [record metrics]
  ├── determineTransition()
  │   └── sm.PhaseComplete(...)        [state: in_progress → pending (next)]
  ├── publishEvent("phase-complete")   [dashboard notification]
  └── return next_action_hint

Action-type variations:

Action typephase-start emitted?agent-dispatch emitted?Reported via
spawn_agentYesYes (with agent name)pipeline_report_result
execYesNopipeline_report_result (P5 embedded path)
write_fileYesNopipeline_report_result (P5 embedded path)
checkpointNo (see Path 3)NoCheckpoint flow
done (skip)NoNoP1 skip loop (internal)

P1 skip loop: When Engine.NextAction() returns ActionDone with SkipSummaryPrefix, pipeline_next_action absorbs it internally — calls sm.PhaseCompleteSkipped() and re-invokes eng.NextAction() in a bounded loop (max 20 iterations). No phase-start or phase-complete events are emitted for skipped phases.

Path 2: Standalone Handlers (debug / manual)

Individual phase_start / phase_complete MCP tools. Used for manual state manipulation and debugging.

PhaseStartHandler
  ├── guard checks (e.g., tasks non-empty for phase-5)
  ├── sm.PhaseStart(workspace, phase)
  └── publishEvent("phase-start")

PhaseCompleteHandler
  ├── guard checks (artifact exists, not awaiting human, no pending revision)
  ├── sm.PhaseComplete(workspace, phase)
  └── publishEvent("phase-complete")

Path 3: Checkpoint Flow

Human-review gates. pipeline_next_action detects checkpoint phases and sets awaiting_human via sm.Update() (not sm.Checkpoint() — the standalone checkpoint handler is a separate MCP tool). The orchestrator presents the checkpoint to the user and passes the response back.

pipeline_next_action (checkpoint action detected)
  ├── sm.Update(): CurrentPhaseStatus = "awaiting_human"
  └── return Action{type: "checkpoint"} to orchestrator

[user reviews and responds]

pipeline_next_action (with user_response)
  ├── "proceed" → sm.PhaseComplete(workspace, phase)
  ├── "revise"  → sm.Update() to rewind state
  └── "abandon" → sm.Abandon()

Note: The standalone CheckpointHandler (handlers.go) calls sm.Checkpoint() and emits a checkpoint event. The pipeline engine path uses sm.Update() directly. Both result in CurrentPhaseStatus = "awaiting_human" but through different code paths.

Event Taxonomy

Events are the dashboard's view of pipeline state. They must form a coherent timeline.

EventEmitterWhenOutcome
pipeline-initpipeline_init_with_contextWorkspace createdin_progress
phase-startpipeline_next_action or PhaseStartHandlerPhase beginsin_progress
agent-dispatchpipeline_next_actionAgent spawned (detail: agent name)dispatched
action-completepipeline_next_action (P5 embedded report path)Agent/exec finished (detail: model)completed
phase-completepipeline_next_action (P5 path), pipeline_report_result, or PhaseCompleteHandlerPhase donecompleted
phase-failPhaseFailHandlerPhase failedfailed
checkpointpipeline_next_action or CheckpointHandlerAwaiting humanawaiting_human
revision-requiredpipeline_next_actionReview verdict REVISEfailed
pipeline-completepipeline_next_actionAll phases donecompleted
abandonAbandonHandlerPipeline abandonedabandoned

Expected Event Sequence per Phase

A normal spawn_agent phase produces:

phase-start (in_progress)
  → agent-dispatch (dispatched)
  → action-complete (completed)
  → phase-complete (completed)

An exec or write_file phase produces:

phase-start (in_progress)
  → action-complete (completed)
  → phase-complete (completed)

Design Decisions

Why PhaseStart lives in pipeline_next_action

pipeline_next_action is the single entry point for phase transitions in the pipeline loop. Placing PhaseStart here (rather than in the Engine or StateManager) ensures:

  • Locality: The start transition is adjacent to the dispatch decision, making the code easy to audit
  • Symmetry: pipeline_next_action starts phases; pipeline_report_result completes them
  • Engine purity: Engine.NextAction() remains a pure function of state — no side effects
  • Layer compliance: The tools → orchestrator → state import direction is preserved

Why standalone handlers are retained

phase_start and phase_complete MCP tools remain available for:

  • Manual state recovery after interruptions
  • Debugging pipeline state in development
  • Future CLI tooling that operates outside the pipeline loop

They follow the same contract and must not conflict with the pipeline engine path.

Why events are emitted at the handler level, not in StateManager

StateManager is a pure state-persistence layer with no external dependencies. Adding EventBus would violate the tools → orchestrator → state layering:

tools (publishEvent + sm.PhaseStart)
  → orchestrator (Engine — read-only)
    → state (StateManager — persistence only)

Events are a presentation concern (dashboard, Slack). They belong at the handler level where the bus is available.

Released under the MIT License.