Skip to content

Project Lifecycle

How a greenlit objective becomes one trackable initiative: the project knows the plan it is executing, the plan's items know the tasks implementing them, and the project's status advances from that work.

This page owns the project side of the graph. Plan Review owns the plan's authoring and review phases; Engine owns the task lifecycle.

The graph

Three entities, linked by scalar foreign keys pointing upward only.

flowchart LR
    Project -->|plan_id| Plan
    Plan -->|project| Project
    Task -->|plan_id| Plan
    Task -->|plan_item_id| PlanItem
    Task -->|project| Project
    Plan --> PlanItem
edge field notes
Project to Plan Project.plan_id the plan currently being executed
Plan to Project Plan.project set at plan creation, immutable
Task to Plan Task.plan_id stamped at dispatch
Task to PlanItem Task.plan_item_id stamped at dispatch
Task to Project Task.project set at intake, immutable

No entity stores a collection of its children. Reverse lookups are indexed queries: TaskFilterSpec(plan=...) for a plan's tasks, TaskFilterSpec(project=...) for a project's tasks, and PlanFilterSpec(project=...) for a project's plan history.

This is a deliberate correction. Project.task_ids previously existed as a stored tuple of child ids and was never populated by anything, so the dashboard showed a task count of zero next to a full task list. A collection embedded in a row has to be written by every actor that creates a child, in the same transaction, forever; a scalar upward key is written once by the actor that already owns the write. The dead field was removed rather than filled in.

Project.plan_id always names the one plan the project is currently working through. Every earlier revision stays reachable through PlanFilterSpec(project=...), which returns superseded plans too.

Re-planning a dispatched initiative

A plan under review is edited in place: same entity, bumped version, back to pending review. Once a plan is dispatched that is no longer possible, because its items are already building and rewriting them would leave running tasks implementing items that no longer exist. Revising a dispatched plan is therefore a re-plan (POST /plans/{id}/replan), which:

  1. retires the current revision to SUPERSEDED;
  2. cancels the in-flight tasks it dispatched, through their audited lifecycle transitions, so no live work points at a withdrawn revision;
  3. opens a successor plan entity carrying the objective and framing forward, entering PENDING_REVIEW because its items hold no approval;
  4. repoints Project.plan_id at the successor.

The ordering protects one invariant: a project never has two live plans. If a later step fails, the recoverable state is an initiative whose plan is superseded and whose successor is missing, which an operator resolves by planning again. The alternative ordering would leave two live plans with Project.plan_id naming one arbitrarily and the rollup deriving status from a revision the operator had already abandoned.

The successor is not dispatched by the re-plan. It goes through review like any other plan, and approval activates the project and repoints it through the same path first dispatch uses, so there is one dispatch path rather than two.

Status lifecycles

Both Plan and Project have a real transition table on the shared core/state_machine.py, exactly as Task does. Illegal jumps are impossible rather than merely unwritten.

stateDiagram-v2
    [*] --> PLANNING
    PLANNING --> ACTIVE: plan approved and dispatched
    ACTIVE --> COMPLETED: plan completed
    ACTIVE --> ON_HOLD: operator pauses
    ON_HOLD --> ACTIVE: operator resumes
    PLANNING --> CANCELLED
    ACTIVE --> CANCELLED
    ON_HOLD --> CANCELLED
    COMPLETED --> [*]
    CANCELLED --> [*]

ON_HOLD has no direct hop to COMPLETED: an operator who paused an initiative must resume it before the rollup can finish it, so work never completes out from under a deliberate hold.

There is no failed project

ProjectStatus has no failure value, and this is a design decision rather than an omission.

Nothing downstream can honestly derive that an initiative is dead. A completion-oracle REJECT routes a task back to IN_PROGRESS for rework, not to failure. A task that does reach FAILED stays reassignable (FAILED -> ASSIGNED in the task state machine). So a derived failure would flap the moment the work was retried, and it would assert a judgement the system is not entitled to make.

Ending an initiative is a human act, and CANCELLED already expresses it. Recovery is a human act too: replan. Failed and blocked work surfaces as derived counts on the progress view, so the operator sees that an initiative needs attention without the system pretending to have decided its fate.

The general rule: statuses are what the organisation decides; "something is wrong" is signal.

Completion

An item is done when:

kind is WORK      ->  its dispatched task reached COMPLETED
kind is DECISION  ->  an option has been chosen

A plan is COMPLETED when it has items and every one is done. A project is COMPLETED when the plan it is executing is. An itemless plan never self-completes: it has delivered nothing, so "every item is done" being vacuously true must not read as success.

Decision items are included deliberately. They never dispatch as tasks (decomposition_from_plan strips them before dispatch), but an unresolved decision is real work the operator still owes, so an initiative cannot complete around one.

How this composes with the verify gate

The rollup reads persisted Task.status, never execution outcomes.

That single choice is what keeps an initiative from completing on unverified work. Under the wired agent runtime a task reaches COMPLETED through ReviewGateService._apply_decision, which runs the full gate chain (build/test oracle, completion-oracle peer review, output policy, red team, vision). Requiring COMPLETED therefore inherits every one of those gates without the rollup making a single oracle call.

This is a property of which writers are wired, not a structural guarantee of the status field, and the distinction matters for anyone reasoning about how strong the guarantee is. Two other paths reach COMPLETED without the oracle chain: LifecycleAdvancingExecutionService (workers/execution_service/_lifecycle.py), the lifecycle-only baseline the app self-constructs when no agent runtime is installed, and the coordination parent rollup below. Both are legitimate in their own context; neither should drive an initiative whose completion is meant to be verified. Requiring COMPLETED is the strongest signal available to a derivation that makes no oracle call of its own.

The contrast is load-bearing. The coordination-level parent rollup (engine/coordination/parent_rollup.py) derives subtask status from DispatchResult outcomes, which report execution success before verification: a task parked in IN_REVIEW awaiting the oracle counts as completed there. That is correct for advancing a parent task within one coordination run, and wrong for deciding whether an initiative delivered. The initiative rollup does not reuse it.

Rollup

ProjectRollupService (engine/initiative/rollup.py) registers as a TaskEngine observer, so it sees every task status write regardless of which path produced it: the review gate's decision, the execution loop's failure handling, an operator cancellation.

It recomputes; it does not accumulate. The event is only a trigger. On each one the service re-queries every task for the plan and derives plan and project status from scratch, then writes under optimistic concurrency.

Two properties follow, and both are the reason for the design:

  • Idempotent, therefore self-healing. TaskEngine observers are explicitly best-effort: a bounded queue, drained at shutdown, so events can be dropped or redelivered. A full recompute means the next event repairs any drift and a duplicate event changes nothing. An incremental counter would be corrupted permanently by a single dropped event, which is why there is no reconciler worker: correctness does not depend on delivery.
  • Verification-derived, not execution-derived, as above.

Writes are version-guarded (expected_version) with a bounded retry, and a per-plan in-process lock serialises same-process recomputes. A losing write re-reads and recomputes rather than clobbering the winner.

Where linkage is written

At dispatch, in _dispatch_approved_plan (api/controllers/_plan_review_resume.py), and before the coordinator runs:

  1. Project.plan_id is repointed and the project goes PLANNING -> ACTIVE.
  2. The plan goes APPROVED -> EXECUTING.
  3. decomposition_from_plan stamps plan_id + plan_item_id onto every child task it builds.

The ordering is load-bearing: coordinate() awaits the whole subtask tree, so a rollup event fired mid-dispatch would otherwise observe a project still PLANNING with its tasks already running.

Task.plan_item_id makes the previously implicit correlation explicit. The child task id is still minted deterministically from the plan item id (subtask_uuid), but reading the graph no longer requires knowing that trick.

Operator surface

GET /projects/{id}/progress returns the initiative view: plan status, every plan item with its task status, derived counts (total / done / failed / blocked), and the critical path.

The critical path is the longest dependency chain through the plan's item DAG (engine/initiative/critical_path.py): the chain that sets the delivery date, so shortening any other chain does not bring the plan in sooner. It is computed server-side, which keeps the dashboard a pure API consumer and makes the same view reachable by any API client rather than existing only in the browser.

The project detail page renders it as the initiative cockpit, subscribing to the plans channel alongside projects and tasks so a plan status change refreshes it live. A project with no plan yet returns the same shape with an empty item list rather than a 404, so the view is stable across an initiative's whole life.

Persistence

projects.plan_id, tasks.plan_id, and tasks.plan_item_id are nullable TEXT columns; tasks.plan_id is indexed because the rollup and the progress endpoint both query by it. The plans status CHECK carries the full enum including executing and completed. SQLite and Postgres are in parity, with one yoyo revision per backend.