Repository navigation
Epic: converge the start phase into the plan engine #14081
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. → test: unit-lock the imperative start path #14104 (merged) -
fix: dependency wait timeout silently ignored(Epic: make the codebase agent-legible — fix misleading self-description, ambiguous contracts, and legacy leftovers #14074 C) —waitDependencyreturns the error onDeadlineExceeded; user cancellation stays silent; audit of the 4 callers. → fix: dependency wait timeout surfaced only by luck #14105 (merged) -
fix|chore: startMx on the real start path— maintainer decision requested below. → fix: hold startMx on the start path actually exercised #14106 (merged) -
refactor: NewGraph stops mutating the project— pruning of unresolvable optional deps becomes an explicit step; precondition for a canonical project object. → refactor: NewGraph stops mutating the project #14124 (+ introduce Project.WithoutUnresolvedOptionalDependencies 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. → plan engine: reconciler plans the start phase (epic #14081, lot 1) #14200 (merged) -
feat: pre_start hook runners become reconciliation-plan resources— runner creation moves into the create phase (deterministic naming, DAG ordering, purge of stale runners), landing independently of the start-phase plan and setting up hook execution/removal to become start-phase operations next. → pre_start hook runners become reconciliation-plan resources #14221 (merged) -
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. → executor: run start-phase operations (epic #14081, lot 1) #14274 (merged) -
refactor: split create() into preparePlan + execute— pure extraction, givesupaccess to plan/snapshot/canonical project. → refactor: split create() into preparePlan + execute #14289 (merged)
Lot 2 — migration, consumer by consumer (increasing risk)
-
fix(executor): a wait node holds no --parallel slot while it polls— prerequisite of the detached-up switchover, where wait nodes are first scheduled by a production path. → fix(executor): a wait node holds no --parallel slot while it polls #14303 -
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: detached up runs on a single plan #14290 -
feat: scale and watch rebuild use the unified plan— reproduce current behavior (StartOptions.Servicesis dead today; wiring it is a separate decision). → feat: scale and watch rebuild use the unified plan #14291 -
feat: compose start builds a start-only plan— including the label-reconstructed project path (projectFromName);run's dependency startup migrates for free. → feat: compose start builds a start-only plan #14296 -
feat: interactive up on the plan engine— the riskiest step, kept surgical: Create phase → attach/printer/monitor → Start phase underWithoutCancel. No opportunistic refactoring. → feat(up): interactive up runs on a single plan #14305-
test(e2e): lock the behavior of the foreground up— characterization scenarios, green on the current code, so the switchover is reviewed against a fixed specification. → test(e2e): lock the behavior of the foreground up #14300 -
refactor(executor): let a caller drive the create and start phases itself—begin/planRun, inert: a caller can act between the two phases. → refactor(executor): let a caller drive the create and start phases itself #14302 -
fix(monitor): subscribe to events since before the initial listing—Sincetaken before the snapshot, so a container exiting as soon as it starts is not missed; replay is idempotent. → fix(monitor): subscribe to events since before the initial listing #14304
-
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(fix: hold startMx on the start path actually exercised #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 Avoid selecting duplicate host ports for mappings to 0.0.0.0 and specific addresses moby/moby#50054 (Engine 28.3.0, explicitly fixes [BUG] docker engine and desktop expose random ports differently #12846, the issuestartMxwas working around via run ContainerStart sequentially #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.
Activity
executor: pre_start re-checks the daemon before running
ndeloof commentedon Aug 17, 2026
Design decisions validated (see the discussion on #14083), now implemented on the prototype branch:
RunPreStartre-checks the daemon and skips itself if a replica started meanwhile (plan-time placement, runtime guard) — 05258d8.plan.String()(inspectable, snapshot-testable). The asymmetry with attach (execution hook, not node) is deliberate: a wait gates convergence, attach observes it. Dedup of identical waits by(dependency, condition, required)is deferred to theupextension, where fan-in makes it worthwhile.api.EventProcessornow documents the per-resource progressions, the explicitly-not-guaranteed global interleaving, and status texts as stable identifiers (a182931); a recorder test holds the plan executor to it. The one known divergence (skip granularity for optional dependency failures) is fixed: wait failures emit the historical per-container Skipped/Error events (4ea6832).executor: failed dependency waits keep their per-container event gran…
api: write the event contract down, and hold the plan executor to it
reconcile: start-phase scope, wait dedup, and the start-only profile
up -d and start converge through the plan engine
watch: rebuild converges through a scoped create+start plan
reconcile: start-phase scope, wait dedup, and the start-only profile
up -d and start converge through the plan engine
watch: rebuild converges through a scoped create+start plan
ndeloof commentedon Aug 20, 2026
@glours the epic body has been fully rewritten with the selected architecture and a 13-PR breakdown. Lot 0 (characterization tests + standalone bug fixes) is behavior-preserving and will open shortly; before investing in lot 1 (the new plan vocabulary), I'd appreciate a design review of the Target architecture and the four Design decisions sections above — in particular the waits-as-nodes choice and the
startMxquestion.test: unit-lock the imperative start path
fix: dependency wait timeout surfaced only by luck
fix: hold startMx on the start path actually exercised
44 remaining items
fix: execRunPreStart bails out on a canceled context
fix(executor): a failed pre_start no longer lets start fire its own e…
fix(executor): keep dependency-failure comments and wrapping accurate
feat: executor runs start-phase operations
docs: spell out that the ctx.Err() guards narrow the race, not close it
fix: execRunPreStart bails out on a canceled context
fix(executor): a failed pre_start no longer lets start fire its own e…
fix(executor): keep dependency-failure comments and wrapping accurate
ndeloof commentedon Oct 6, 2026
Follow-up noted from #14274's merge review (#14274 (review), glours, non-blocking):
run()'sfailures/failedDependencymechanism (added in #14274 to close the done-before-cancel race for a direct dependency) forwards each node's original, unwrapped error to its own dependents — verified correct by code inspection, including at the point a node relays an already-short-circuited failure further down the chain. But today it's only exercised at one hop (TestExecutePlanFailedPreStartGatesStart, pre_start → start). Nothing currently tests a longer chain (create → pre_start → start → post_start, the shape Lot 1's replica chains actually produce) relaying a failure 2-3 hops deep while keeping the root error legible and unwrapped.Worth a regression test with a 3-4 node chain asserting the final surfaced error is still the original, un-prefixed one — low effort, no design question, just missing coverage for a path a future change to
failedDependency/recordFailurecould silently regress. Picking it up alongside whichever Lot 1/2 PR next touchesrun()'s dependency-wait logic, rather than as its own PR.feat: compose start builds a start-only plan
feat: compose start builds a start-only plan
feat: compose start builds a start-only plan
feat: compose start builds a start-only plan
refactor: split create() into preparePlan + executePlan
refactor: split create() into preparePlan + executePlan