Vancetope — Project Lifecycle
Defines the status set of a Project, Pod ownership, the
bring/suspend/closetransitions, and the relationship to the Workspace. Complements workspace-management.md (what happens within a Project on disk) and session-lifecycle.md (what runs within a Session) — this spec is the overarching framework. Cluster-wide distribution of Projects to Pods (who callsbringwhen on which Pod) is covered in cluster-project-management.md, which sits one layer above.
1. What a Project Is
A Project is the unit of ownership in Vancetope:
- A Pod owns a Project (Pod affinity via
homeNode/claimedAt) - A Project has a Workspace on disk (exactly one RootDir container, see
workspace-management.md) - A Project has 0..N Sessions, which in turn have 0..N Think Processes (see
session-lifecycle.md) - Documents, RAGs, Settings, Skills are all Project-scoped
When a Project is suspended or closed, all subordinate concepts are tied to it. If it migrates to another Pod, the Workspace + Sessions must move with it. This spec defines how.
2. Status Set
Six statuses on ProjectDocument.status:
| Status | Meaning | Workspace Folder | Engines allowed? |
|---|---|---|---|
INIT |
Newly created, never recovered, no Workspace on disk | no | no |
RECOVERING |
Pod is bringing the Project online — Workspace recovery is running. Transient. | currently being built | no |
RUNNING |
Workspace is on disk, Pod is actively servicing | yes | yes |
SUSPENDING |
Suspend is running — Engines are being stopped, Workspace goes off-disk. Transient. | currently being dismantled | should be stopping |
SUSPENDED |
Workspace off-disk, Snapshots in Mongo, resumable | no (Snapshots in Mongo) | no |
CLOSED |
Terminal — no further operations, Snapshots deleted, moved to Archive-Group | no | no |
RECOVERING and SUSPENDING are transient. A healthy Project spends at most seconds to a few minutes there (Workspace recovery/suspend, Engine start/stop). If a Pod crashes in the middle of a transition, the next Pod takes over and re-runs the respective step — both steps are idempotent (see §6).
3. Status Diagram
┌──── bring (resume) ────┐
▼ │
INIT ──bring──► RECOVERING ──► RUNNING ──suspend──► SUSPENDING ──► SUSPENDED
▲ │
└──── bring (after crash) ─────────────────────────┘
│
close (any non-CLOSED status) ──────► CLOSED
bringpulls any non-CLOSED Project to RUNNING (on the current Pod).suspendpulls any non-CLOSED Project to SUSPENDED.closeis terminal from any status.- Pod takeover is orthogonal: another Pod can claim a RUNNING Project (Lease-Takeover); the previous Pod only learns about it on the next write attempt.
4. Pod Affinity ≠ Lifecycle Status
ProjectDocument carries two orthogonal axes:
| Axis | Fields | Who sets? |
|---|---|---|
| Lifecycle Status | status |
ProjectLifecycleService (see §5) |
| Pod Affinity | homeNode, claimedAt |
ProjectService.claim |
| User Intent (distribute?) | lifecycleType (HOMELESS/EPHEMERAL/PERMANENT) |
User on Create; SYSTEM Projects auto-HOMELESS (see cluster-project-management.md §2) |
homeNode is the Cluster Node Name of the owner Pod (e.g., maya-prosser), not an endpoint. Each Brain boot assigns a fresh Node Name from a dictionary (see ClusterNodeNameGenerator) and registers itself in brain_pods. Other Pods resolve the name via ClusterService.resolveEndpoint(homeNode) to host:port whenever needed. This indirection is intentional: a Pod restart with the same IP+Port (typical in K8s with stable service IPs) does not inherit its old claims because its Node Name is different — the stale detection in §5/§6 handles this cleanly.
status and homeNode are independent. Examples:
RUNNING+homeNode=maya-prosser→ Pod “maya-prosser” actively owns the ProjectRUNNING+homeNode=null→ Owner Pod has disappeared;ProjectWakeupTickre-brings the Project ifrequiresOwnerPod=true(see §6.2)SUSPENDED+homeNode=null→ suspended, no owner — any Pod can reclaimSUSPENDED+homeNode=maya-prosser→ suspended, the named Pod last suspended (informational)
The CAS predicate in ProjectService.claim (§9) allows three acceptance cases: homeNode IS NULL (free), homeNode == self (refresh), homeNode ∉ liveClusters (Stale-Takeover). This prevents a RUNNING Project on Pod A from being stolen by Pod B as long as A is alive according to the brain_pods registry — a race between two parallel claims deterministically yields only one winner.
lifecycleType controls which homeNode=null orphans the Cluster Master actively re-brings: PERMANENT yes, EPHEMERAL/HOMELESS no. Details and the score model for distribution are in cluster-project-management.md. Engine lifecycle listeners do not touch lifecycleType — the user intent is static.
5. ProjectLifecycleService — API
ProjectLifecycleService lives in vance-brain.project. It is the only place where lifecycle transitions are orchestrated — ProjectService only knows the individual atomic operations.
ProjectDocument bring(String tenantId, String projectName);
ProjectDocument suspend(String tenantId, String projectName);
ProjectDocument close(String tenantId, String projectName, String closedGroupId);
5.1 bring
INIT|SUSPENDED|RECOVERING|SUSPENDING → RUNNING on the current Pod.
1. claim() # CAS — Pod now owns the Project;
# throws ClaimRejectedException
# if another live Pod holds it
1a. sessionService.unbindAllForProjects # idempotent stale cleanup
# (skipped if Pod is already owner
# and status RUNNING — Claim Refresh)
2. status → RECOVERING # atomic via transitionStatus
3. workspaceService.init(tenant, projectId)# auto-recovers Snapshots if present
4. publish(ProjectEnginesStartRequested) # Spring Event for Engine Listeners
5. status → RUNNING # atomic via transitionStatus
Idempotent: if status is already RUNNING and Pod is already owner, bring is a no-op (Pod claim is refreshed — step 1a is skipped, otherwise an actively connected client would be disconnected).
Step 1a covers the reconnect path after Pod death: stale boundConnectionId values would otherwise reject the first reconnect with 409 Already-Bound. The reclaimer in §6.1 cleans this up on boot only for Projects that have homeNode=self — this does not cover the path where another Pod (or this Pod itself after restart) freshly takes over a Project. Therefore, it is explicitly done again during bring.
From RECOVERING (previous Pod crash mid-recovery), bring proceeds — workspaceService.init is crash-safe (see workspace-management.md §11.4).
claim() acts as a true CAS: two Pods hitting a fresh Project in parallel will see exactly one winner. The second receives Optional.empty(), which the ProjectManagerService.claimForLocalPod wrapper translates into a ClaimRejectedException. The associated claimForLocalPodOrRedirect variant (for connection bind paths) returns a Redirect with the current owner’s endpoint in that case, resolved via ClusterService.resolveEndpoint(homeNode).
5.2 suspend
RUNNING|SUSPENDING → SUSPENDED on the current Pod.
1. status → SUSPENDING # atomic via transitionStatus
2. publish(ProjectEnginesStopRequested) # Spring Event for Engine Listeners
3. workspaceService.suspendAll(projectId) # Snapshots to Mongo, Folder gone
4. status → SUSPENDED # atomic via transitionStatus
Idempotent: SUSPENDED → no-op. From SUSPENDING (crash mid-suspend), it restarts from step 2 — suspendAll and Engine stop are both idempotent.
CLOSED fails with ProjectStatusConflictException. INIT (never recovered) is allowed — goes directly to SUSPENDED without Workspace operations.
5.3 close
Any → CLOSED. Terminal.
1. workspaceService.dispose(projectId) # Folder + Snapshots gone, terminal
2. projectService.close(tenant, name, closedGroupId) # status=CLOSED, projectGroupId=closed-group
close does not emit ProjectEnginesStopRequested — the caller is responsible for stopping Engines beforehand (typically via a preceding suspend). Direct close on a RUNNING Project does not kill Engines; they will learn that the Project is gone on the next Workspace access via WorkspaceQuotaExceededException or similar.
SYSTEM Projects are blocked by the underlying ProjectService.close (Hub protection).
6. Crash Recovery
Every transition is idempotent, every transient state clearly means “try this step again”.
| Crashed in | On next bring |
On next suspend |
|---|---|---|
RECOVERING |
continue with Workspace init + → RUNNING | first complete bring, then suspend |
SUSPENDING |
bring pulls from SUSPENDING → RECOVERING (allowed via transitionStatus) and recovers anew | continue with suspendAll + → SUSPENDED |
workspaceService.init and workspaceService.suspendAll are defined as crash-safe in §11.4 of workspace-management.md: each takes the Snapshot/Folder pair as the source of truth and brings it to the desired state.
bring’s transitionStatus allows transitions from any non-CLOSED status to RECOVERING — including SUSPENDING. This is intentionally allowed: a crashed SUSPENDING is effectively no longer intended to be suspended (otherwise the user would not have called bring), and the recovery path cleans up for recovery.
6.1 ProjectStartupReclaimer
On boot — as an @EventListener(ApplicationReadyEvent.class) with LOWEST_PRECEDENCE, i.e., after ClusterService registration — the Reclaimer cleans up three things:
- Stale-
homeNode-Cleanup: an idempotentupdateMulti({homeNode: $nin: liveClusters}, {$set: {homeNode: null}}). The live set comes fromBrainPodService.listLiveClusterNodeNames(clusterId, staleAfter)and includes its own Node Name (belt-and-suspenders). N Pods during a rolling deploy converge to the same final state — the update operation is deterministic and idempotent. If the live set is empty (defensive guard), it is skipped to prevent misconfiguration from triggering a cluster-wide reset. - Session-Unbind: for every RUNNING Project whose
homeNodepoints to its own Node Name, lingeringboundConnectionIdfields are deleted (SessionService.unbindAllForProjects). Otherwise, the next reconnect would be rejected with409 Already-Bound. - Boot-Self-Pull: the Pod actively pulls
PERMANENTorphans up to its configuredresourcesStartupScoreto itself — see cluster-project-management.md §5.1. This replaces the old “grabs everything available” behavior.
EPHEMERAL Projects remain untouched — they are only brought when someone requests them via the ProjectLocator (see cluster-project-management.md §6).
6.2 Cluster Master Distributor
The former ProjectWakeupTick has been replaced by the rotating Cluster Master and its 60s Distributor Tick — see cluster-project-management.md §4 and §5.2. Only one Pod in the cluster holds the Master role and performs the distribution; the other Pods leave it alone. Race-freedom still comes from the CAS in ProjectService.claim, which remains the guarantee.
Distributor’s selector: lifecycleType=PERMANENT AND status IN (RUNNING, RECOVERING, SUSPENDING, SUSPENDED) AND (homeNode=null OR homeNode NOT IN liveClusters). Targeting based on (currentScore/maxScore) utilization of live Pods.
7. Engine Hooks (Spring Events)
Two records in vance-brain.project:
record ProjectEnginesStartRequested(String tenantId, String projectName) {}
record ProjectEnginesStopRequested(String tenantId, String projectName) {}
Fired by ProjectLifecycleService in the steps above. V1 has no listeners — Engine cleanup is operator- or script-driven. Consequences for V1 tests:
- Before
suspend: manually set existing ThinkProcesses toSTOPPED/CLOSED, otherwise they write to a disappearing Workspace. - After
bring: existing ThinkProcesses are not automatically restarted — Sessions that were running before must be reactivated by the caller.
V2 will implement listeners that enumerate all running Processes via ThinkProcessService.findByProject and shut them down or bring them up via engine.stop/engine.resume. This is related to the suspend cascade from session-lifecycle.md §9.
8. Relationship to Sessions and Workspace
Project (LIFECYCLE status here)
└── Sessions (own Lifecycle, see session-lifecycle.md)
└── Workspace (own mechanics, see workspace-management.md)
└── RootDirs (handler-specific: temp, git, ...)
Project is the outermost shell. Sessions and Workspace are under Project ownership; their status is informational, their lifecycle is controlled by the Project lifecycle in the worst case (Suspend, Close). The Project enforces:
- on
suspend: Workspace goes off-disk (Engines stop via Event hook) - on
close: Workspace is disposed (Snapshots gone, terminal) - on
bring: Workspace recovers (auto viaworkspaceService.init)
Sessions can live independently of the Project status (own lifecycle properties), but if the Project is SUSPENDED, there is no Workspace anymore — Sessions that need Workspace tools will fail. A bring-cascade would have to reactivate Sessions; V2 will do this.
9. ProjectService — Atomic Building Blocks
vance-shared.project.ProjectService contains the individual Mongo operations, without orchestration:
| Method | Effect |
|---|---|
claim(tenant, name, selfCluster, liveClusters) |
CAS update: sets homeNode=selfCluster + claimedAt if homeNode IS NULL OR == selfCluster OR ∉ liveClusters. Returns Optional<ProjectDocument> (empty = another live Pod holds it). Rejects CLOSED. |
transitionStatus(tenant, name, expected, target) |
atomic findAndModify; throws ProjectStatusConflictException if status is not expected |
close(tenant, name, closedGroupId) |
sets status=CLOSED + projectGroupId; rejects SYSTEM Projects |
findRunningByHomeNode(node) |
RUNNING Projects owned by the specified Cluster Node |
findByHomeNode(node) |
all Projects with Owner = Cluster Node (status-independent, for Heartbeat snapshot) |
findPermanentOrphans(limit) |
lifecycleType=PERMANENT + Status non-CLOSED + homeNode IS NULL OR ∉ liveClusters — candidates for Boot-Self-Pull and Master Distributor (see cluster-project-management.md) |
clearStaleHomeNodes(liveClusters) |
idempotent updateMulti-bulk cleanup for the Reclaimer |
setLifecycleType(tenant, name, value) |
atomic switch between EPHEMERAL and PERMANENT; denied for HOMELESS-/SYSTEM Projects |
ProjectLifecycleService builds on these methods and adds the Workspace steps and event emission. ProjectManagerService (vance-brain) is the Brain-side façade — it injects ClusterService for the live set and translates claim’s Optional.empty() into ClaimRejectedException / ClaimResult.Redirect.
10. What This Spec Does Not Govern
- Engine Listeners for
ProjectEngines{Start,Stop}Requestedbeyond theUrsaSchedulerService— V2, together with Session suspend cascade fromsession-lifecycle.md§9. - Auto-Suspend on Inactivity at the Project level (analogous to Session idle sweep) — comes with the Quota Sweeper from
workspace-management.md§9. - Cross-Pod-Process-Migration: a running Think Process remains on its Pod. On Pod death, the Process dies, the Project is re-brought on another Pod, the next trigger may spawn a new Process. No live migration.
- Migrations of old Mongo data with status values
PENDING/ACTIVE/ARCHIVEDor the legacypodIpfield — the old field remains physically in documents but is no longer read by the mapper; an optionaldb.projects.updateMany({}, {$unset: {podIp: ""}})cleans it up after stabilization. The same applies to the formerrequiresOwnerPodfield (see cluster-project-management.md §10). - Distribution of Projects to Pods in the Cluster — who calls
bringwhen on which Pod, score model, Master role: all in cluster-project-management.md. This spec only covers the individualbringon the local Pod.
11. Relation to Other Specs
- cluster-project-management.md — Cluster-wide distribution:
lifecycleType, score model, Master role, three spawn paths. Sits one layer above this spec. - workspace-management.md §11 — Workspace part of the Suspend/Recover/Close flows. ProjectLifecycleService delegates there.
- session-lifecycle.md — Session/Engine lifecycle. The Engine hook events (V2) interlock with the Session suspend cascade.
- architektur-scopes-clients.md — Scope hierarchy Tenant → Project Group → Project → Session.
- vision.md — what a Project is in the product sense.