Epic: converge the start phase into the plan engine
@ndeloof is already working on this.
Since Aug 25, 2026.
- Dominant language
- Go
- Stars
- 38.2k
- Forks
- 5.8k
- Avg merge
- 2d 14h
- Merged PRs (30d)
- 55
Description
"This issue was filed by an AI agent on a human's behalf. The human submitter may not have independently verified the report."
Context
Two lifecycle engines coexist today (#14074, section C). The plan-based reconciler (reconcile.go → executor.go, single entry point create.go) is pure, deterministic and golden-tested — but create-only: containers leave the plan in created state, and OpStartContainer is only ever emitted for exotic states (paused/dead). Everything that makes an application actually start lives in the second, imperative engine (InDependencyOrder): waitDependencies (health/completion polling), secret/config injection, pre_start/post_start hooks. up chains the two with two different daemon snapshots, no canonical project object across the phases, two event-emission systems, and latent bugs at the seam (startMx not held on the path actually exercised; the start-phase ContainerList skips the config-hash filter; dependency-wait timeouts swallowed by ctx.Done() → nil).
This epic tracks converging the start phase into the plan engine, one reviewable PR at a time.
Target architecture
Guiding split: decision vs execution — not "plan vs imperative". The Plan is the pivot execution format of Compose; the reconciler is just one producer of plans.
- One snapshot, one project, one plan with two phases.
ReconcileOptionsgains a scope (Create, Start, or both);PlanNodegains aPhasefield.up -dbuilds a single Create+Start plan;start/scale/watch-rebuild use scope Start (never recreating);compose createkeeps scope Create unchanged — the existing golden tests do not change. - New operations (explicit numbering, 40+):
OpWaitCondition— one node per (awaited service, condition ∈ healthy / completed_successfully / running_or_healthy), deduplicated across dependents (awaitNodesmap, likenetworkNodes).required: falseis absorbed locally: Skipped event, node succeeds — same pattern as the existingBestEffort.condition: service_startedneeds no node: a plain DAG edge expresses it. Health is re-observed at execution time (the node runs today'swaitDependencypolling);ObservedStatedeliberately does not grow aHealthfield — it would be stale by construction.OpRunPreStart— per service. Emitted at plan time only when no replica was running at observation (today's rule instartService), targeting the lowest-numbered replica (lowestNumberedContainer).OpRunPostStart— per container, after its start.
OpStartContainerenriched: secret/config injection folds intoexecStartContainer(they always run as a pair right before start — a separate node would be noise), and the target resolves either from an observed container or from theCreateNodeIDof a create in the same plan (thereconciliationContextmechanism already used byOpRenameContainer). Side effect by construction: everyContainerStartin the codebase now goes through the one call site that holdsstartMx.- Replica chains: inject→start→post_start of replica n+1 depends on the end of replica n's chain — today's sequential start order, preserved and now visible in golden plans.
serviceNodes[svc]points at the end of the chain, so aservice_starteddependent waits for the whole service, matchingInDependencyOrdersemantics. - Events: on converged paths the executor is the only emitter. Exact parity of observable sequences (
Waiting→Healthy|Exited|Skipped,Starting→Startedwith Started emitted after post_start) via astart:<svc>:<n>group on the existinggroupTracker. - Interactive
up: prepare the plan once; execute the Create phase; set up attach/printer/monitor (upSessionunchanged); execute the Start phase undercontext.WithoutCancelwith the printer as listener. The phase boundary replaces today's create/start seam without reintroducing a second snapshot. --waitstays post-plan: it is a final verification with a global timeout and synthetic conditions (getDependencyCondition), running on the sharedwaitDependencyprimitive.- Shared primitives, no decisions inside:
waitDependency,injectSecrets/injectConfigs,runHook,createMobyContainer— consumed by the executor and by what stays imperative (restart, run,--wait).startService/startServiceContainer/waitDependenciesare deleted at the end of the series. - Executor constraint (to document in code): never add
errgroup.SetLimitto the plan executor while it schedules one blocking goroutine per node — instant deadlock. A concurrency cap requires moving to a ready-queue scheduler first.
Non-goals (explicit)
stop/down: reverse-order teardown converges later as a plan-builder producing a Plan directly (no reconciler —downwithout a compose file has no desired state to diff;--rmi, anonymous volumes and duplicate-named networks don't fit a diff model). Separate epic.restart:ContainerRestartis atomic on the daemon side; decomposing it into Stop+Start changes semantics. Stays imperative on the shared primitives.kill/pause/unpause: unordered by design; a plan would introduce an ordering that doesn't exist.runone-off container: by definition outside desired state (number=-1, AutoRemove, unique slug). Its dependencies converge for free (they go through Create/Start); its own creation stays imperative on the shared primitives.- Parallel replica starts (relaxing today's sequential order): becomes a trivial edge change after this series; not mixed into it.
PR breakdown
Lot 0 — foundations (no behavior change except deliberate bug fixes; independent, can start immediately)
-
test: unit-lock the imperative start path— characterization tests only (start idempotence, "no container to start", pre_start gating ×3, inject→start→post_start order,required:falseskip,getDependencyCondition, restartrestart:true). The imperative engine is currently locked only by e2e. → #14104 (merged) -
fix: dependency wait timeout silently ignored(#14074 C) —waitDependencyreturns the error onDeadlineExceeded; user cancellation stays silent; audit of the 4 callers. → #14105 (merged) -
fix|chore: startMx on the real start path— maintainer decision requested below. → #14106 (merged) -
refactor: NewGraph stops mutating the project— pruning of unresolvable optional deps becomes an explicit step; precondition for a canonical project object. → #14124 (+ compose-spec/compose-go#922 for the upstreamProject.WithoutUnresolvedOptionalDependencies)
Lot 1 — vocabulary (the plan learns to start; inert code, no consumer)
-
feat: reconciler plans the start phase— new ops,Phasefield,planStartPhase(deduplicated waits reproducingshouldWaitForDependency, started-edges, plan-time pre_start gating, replica chains, exited/created → start chain under scope Start). Golden tests only; enabled by an option nobody passes yet. -
feat: executor runs start-phase operations—execWaitConditiondelegates towaitDependency(no polling rewrite), enrichedexecStartContainer, listener plumbing for hook logs,start:*event groups with word-for-word event parity. -
refactor: split create() into preparePlan + execute— pure extraction, givesupaccess to plan/snapshot/canonical project.
Lot 2 — migration, consumer by consumer (increasing risk)
-
feat: detached up runs on a single plan— the semantic switchover: the Start phase now emits starts for exited/created containers (the role ofisNotRunningtoday); the second snapshot disappears. Best e2e coverage in the repo backs this path. -
feat: scale and watch rebuild use the unified plan— reproduce current behavior (StartOptions.Servicesis dead today; wiring it is a separate decision). -
feat: compose start builds a start-only plan— including the label-reconstructed project path (projectFromName);run's dependency startup migrates for free. -
feat: interactive up on the plan engine— the riskiest step, kept surgical: Create phase → attach/printer/monitor → Start phase underWithoutCancel. No opportunistic refactoring.
Lot 3 — demolition
-
chore: remove the imperative start path— deletestartService/startServiceContainer, theInDependencyOrderstart path; movewaitDependencyhelpers to a dedicated file (remaining clients: restart, run,--wait). Separate from the interactive-up switchover so its revert stays trivial.
Critical path: reconciler → executor → detached up → interactive up. Up to and including the detached-up switchover, abandoning the effort still leaves the repo strictly better off (bugs fixed, start path unit-locked, vocabulary tested but inert).
Design decisions where maintainer input is requested before Lot 1
- Waits as plan nodes (
OpWaitCondition, deduplicated, golden-testable) vs conditional edges — this epic proposes nodes; edges cannot emit theWaiting→Healthyevents users see and would evaluate conditions once per dependent. startMx(#14106): the global mutex serializingContainerStart(engine port-range race) is currently only held on a dead code path. Take it on the real path, or drop it entirely? The engine-side fix is moby/moby#50054 (Engine 28.3.0, explicitly fixes docker/compose#12846, the issuestartMxwas working around via #12851) — and the moby networking maintainer assessed the original problem was not a start race, so the mutex may never have protected anything. This also gates future replica parallelism.- Phase boundary mechanism: single bi-phase plan executed in two steps (proposed) vs two separate plans over the same snapshot.
scalestart scope: todayscale db=3also restarts any stopped container of the project (StartOptions.Services is never read). Reproduce first; changing it would be a separate PR.
Verification
Every PR keeps make test and the e2e suites green. The ~48 observable behaviors inventoried from the imperative engine (silent start idempotence, one-offs untouched, leaf/root ordering, event sequences, integer-second timeouts, --wait honoring service_completed_successfully, …) serve as the non-regression checklist for lot 2; #14104 locks the unit-testable part. Event parity is checked against the e2e checks.go vocabulary, which greps actual output.
Supersedes the first exploration in #14082 and #14083 (closed): this design keeps their wait-as-node and soft-fail ideas, but replaces the startWithPlan/listener-based split with explicit plan phases, folds injection into the start operation, and re-sequences the work so every step is independently mergeable on current main.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.