You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
"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.ReconcileOptions gains a scope (Create, Start, or both); PlanNode gains a Phase field. up -d builds a single Create+Start plan; start/scale/watch-rebuild use scope Start (never recreating); compose create keeps 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 (a waitNodes map, like networkNodes). required: false is absorbed locally: Skipped event, node succeeds — same pattern as the existing BestEffort. condition: service_started needs no node: a plain DAG edge expresses it. Health is re-observed at execution time (the node runs today's waitDependency polling); ObservedState deliberately does not grow a Health field — it would be stale by construction.
OpRunPreStart — per service. Emitted at plan time only when no replica was running at observation (today's rule in startService), targeting the lowest-numbered replica (lowestNumberedContainer).
OpRunPostStart — per container, after its start.
OpStartContainer enriched: secret/config injection folds into execStartContainer (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 the CreateNodeID of a create in the same plan (the reconciliationContext mechanism already used by OpRenameContainer). Side effect by construction: every ContainerStart in the codebase now goes through the one call site that holds startMx.
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 a service_started dependent waits for the whole service, matching InDependencyOrder semantics.
Events: on converged paths the executor is the only emitter. Exact parity of observable sequences (Waiting→Healthy|Exited|Skipped, Starting→Started with Started emitted after post_start) via a start:<svc>:<n> group on the existing groupTracker.
Interactive up: prepare the plan once; execute the Create phase; set up attach/printer/monitor (upSession unchanged); execute the Start phase under context.WithoutCancel with the printer as listener. The phase boundary replaces today's create/start seam without reintroducing a second snapshot.
--wait stays post-plan: it is a final verification with a global timeout and synthetic conditions (getDependencyCondition), running on the shared waitDependency primitive.
Shared primitives, no decisions inside: waitDependency, injectSecrets/injectConfigs, runHook, createMobyContainer — consumed by the executor and by what stays imperative (restart, run, --wait). startService/startServiceContainer/waitDependencies are deleted at the end of the series.
Executor constraint (to document in code): never add errgroup.SetLimit to 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 — down without 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: ContainerRestart is 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.
run one-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:false skip, getDependencyCondition, restart restart:true). The imperative engine is currently locked only by e2e.
fix|chore: startMx on the real start path — maintainer decision requested below.
refactor: NewGraph stops mutating the project — pruning of unresolvable optional deps becomes an explicit step; precondition for a canonical project object.
fix: containers stopped for network recreation are restarted — real bug on main: the plan stops containers to recreate a diverged network and never restarts them.
Lot 1 — vocabulary (the plan learns to start; inert code, no consumer)
feat: reconciler plans the start phase — new ops, Phase field, planStartPhase (deduplicated waits reproducing shouldWaitForDependency, 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 — execWaitCondition delegates to waitDependency (no polling rewrite), enriched execStartContainer, listener plumbing for hook logs, start:* event groups with word-for-word event parity.
refactor: split create() into preparePlan + execute — pure extraction, gives up access 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 of isNotRunning today); 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.Services is 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 under WithoutCancel. No opportunistic refactoring.
Lot 3 — demolition
chore: remove the imperative start path — delete startService/startServiceContainer, the InDependencyOrder start path; move waitDependency helpers to a dedicated file (remaining clients: restart, run, --wait). Separate from PR 12 so its revert stays trivial.
Critical path: 6 → 7 → 9 → 12. Up to and including PR 9, 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 the Waiting→Healthy events users see and would evaluate conditions once per dependent.
Phase boundary mechanism: single bi-phase plan executed in two steps (proposed) vs two separate plans over the same snapshot.
scale start scope: today scale db=3 also 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; PR 1 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.
"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 pointcreate.go) is pure, deterministic and golden-tested — but create-only: containers leave the plan increatedstate, andOpStartContaineris 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_starthooks.upchains the two with two different daemon snapshots, no canonicalprojectobject across the phases, two event-emission systems, and latent bugs at the seam (startMxnot held on the path actually exercised; the start-phaseContainerListskips theconfig-hashfilter; dependency-wait timeouts swallowed byctx.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
Planis the pivot execution format of Compose; the reconciler is just one producer of plans.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.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.serviceNodes[svc]points at the end of the chain, so aservice_starteddependent waits for the whole service, matchingInDependencyOrdersemantics.Waiting→Healthy|Exited|Skipped,Starting→Startedwith Started emitted after post_start) via astart:<svc>:<n>group on the existinggroupTracker.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.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.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.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.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|chore: startMx on the real start path— maintainer decision requested below.refactor: NewGraph stops mutating the project— pruning of unresolvable optional deps becomes an explicit step; precondition for a canonical project object.fix: containers stopped for network recreation are restarted— real bug on main: the plan stops containers to recreate a diverged network and never restarts them.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 PR 12 so its revert stays trivial.Critical path: 6 → 7 → 9 → 12. Up to and including PR 9, 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
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(PR 3): 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.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 testand 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,--waithonoringservice_completed_successfully, …) serve as the non-regression checklist for lot 2; PR 1 locks the unit-testable part. Event parity is checked against the e2echecks.govocabulary, 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 currentmain.