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
docker compose up -d now runs its Create and Start phases as a single plan over a single daemon snapshot, instead of the old create() + start() sequence — two separate snapshots, the second blind to what the first had just done.
Context
Detached up has always run two unrelated things back to back: create() converges the project (networks, volumes, containers) and plans a Plan, then start() re-lists containers from the daemon from scratch and runs the older imperative engine to start them. That second listing can't see anything create()'s plan changed in the same call — it only sees whatever the daemon looked like before this invocation of up began, modulo its own separate converge from the first listing's point of view.
Lot 1 of epic #14081 already taught the reconciler and executor to plan and execute a Start phase in the same Plan as the Create phase (ReconcileOptions.Scope: ScopeCreateStart), precisely so a caller like detached up could stop needing a second pass. Until this PR, nothing actually requested that scope — it shipped as inert code with no consumer.
What the PR brings
preparePlan takes an explicit scope now (create() passes ScopeCreate, byte-identical to its previous implicit behavior). A new upDetached runs preparePlan(..., ScopeCreateStart) + executePlan once for up -d; --wait stays a final, post-plan verification (re-listing containers once execution is done, since the plan may have just created or recreated some) on the same waitDependencies primitive start() already uses. Interactive up is untouched — its own create/start phase boundary is a separate, riskier item of the same lot.
Making ScopeCreateStart a real, exercised path for the first time surfaced two latent bugs in the recreate machinery that nothing had ever driven end to end: a recreated container's Start-phase progress events (and a dependent's health-wait events) kept reporting the container's internal temporary rename-dance name instead of its final one. Both are fixed here, each pinned by a regression test — see the commit message for the detail; caught and confirmed via the full e2e suite (TestRestartWithDependencies) against a locally built binary, not just unit tests.
The switchover is also where the wait semantics of the Start phase get exercised for real, so a few guardrails are pinned by tests: --wait-timeout gives each dependency wait its own window and still reports a depends_on timeout as such (and an inherited deadline is never blamed on --wait-timeout); a required: false dependency that times out is skipped rather than failing up (this applies to the shared waitDependency primitive, so the imperative callers — restart, run, start, --wait — share it); Create is a hard barrier before Start, and a failure in one phase never cancels unrelated in-flight work of the other.
The reason will be displayed to describe this comment to others. Learn more.
Assessment: 🟢 APPROVE
The PR is a clean, well-scoped refactor: upDetached now runs a single preparePlan(…, ScopeCreateStart) + executePlan pass instead of the old two-snapshot create()/start() sequence. The two latent rename-dance bugs (progress events reporting the temporary name) are properly fixed with regression tests in executor_start_test.go. The executor's thread-safety (containersMu guards all containersByService accesses; reconciliationContext.mu guards pctx) is maintained correctly. The interactive-up path is untouched and behaviourally equivalent to the old code.
Lower-confidence findings (not posted inline)
[low] pkg/compose/executor_ops.go:407 — silent no-op when renamed container is absent from containersByService slice (unverified low; informational only)
[low] pkg/compose/up.go:117 — DeadlineExceeded error message may cite wrong timeout when WaitTimeout==0 and parent ctx carries a deadline (unverified low; informational only)
The reason will be displayed to describe this comment to others. Learn more.
Assessment: 🟡 NEEDS ATTENTION
This review covers only the commits pushed since d39bcc914df9 — the two fixes in commit 0a007fc07 (warning log in execRenameContainer and DeadlineExceeded translation around getContainers).
Both changes from the previous review are correctly addressed. One new finding from the latest commit.
The reason will be displayed to describe this comment to others. Learn more.
ℹ️ This review covers only the commits pushed since 0a007fc07210 (the previous completed review).
Assessment: 🟡 NEEDS ATTENTION
One medium-severity finding in the new deadline-scoping logic.
Lower-confidence findings (not posted inline)
[low] pkg/compose/executor.go:253 — All OpWaitCondition nodes share the same absolute waitDeadline; if preceding create/start/hook ops consume most of the budget, health-wait nodes get little or no effective time (confidence: weak 34/100). This is documented as intentional ("one overall budget") and mirrors the old code's behavior under the previous WithTimeout-wrapped plan; flagged for awareness.
The reason will be displayed to describe this comment to others. Learn more.
This review covers only the commits pushed since b6b0d598100d2a593a3364969391cc0b979a6fa2.
Assessment: 🟢 APPROVE
The single new commit (570e0944a) introduces applyRemainingDeadline — a well-structured helper that correctly handles all three deadline states: zero (returns a no-op cancel and the unmodified context), already-elapsed (returns a user-friendly error directly, before any daemon call can produce a confusing bare DeadlineExceeded), and future (delegates to context.WithDeadline as normal). The call site in upDetached is wired correctly: the defer cancel() is registered only after a nil-check via the if err != nil { return err } guard, so the nil cancel returned in the error path is never invoked.
This is exactly the fix that was requested in the prior review thread (PRRT_kwDOAOWUd86pi1aP, now resolved), and the implementation matches the suggested pattern while extracting the logic into a named, tested, documented function — an improvement over an inline block.
No bugs were found in the changes introduced by this commit.
The reason will be displayed to describe this comment to others. Learn more.
This review covers only the commits since 570e0944a75c.
Assessment: 🟢 APPROVE
The incremental diff makes a single targeted fix to planProviderStart in pkg/compose/reconcile.go: the old code included the create-phase RunProvider node in startChainEnds only as a fallback (when no dependency nodes existed); the new code always includes it, guarded by !slices.Contains to prevent duplicates. This correctly ensures that consumers wait for both the provider's upstream dependencies and the provider's own deployment node — plugging the race where a consumer could start while the provider's plugin/relay was still deploying.
All edge cases check out: provider with no deps and no node in serviceNodes (ends stays empty, same as before); provider with deps but no node (ends = depNodes only, same as before); provider with both deps and a node (new behavior: ends includes both, fixing the race). No new issues introduced.
This still makes --wait-timeout expire because of non-wait work: the absolute deadline starts before exec.run, and this branch fails immediately if slow create/start/hook nodes finish after it, even when no health wait timed out (or all services are already healthy). That effectively reintroduces the timeout on operations that exec.waitDeadline was designed not to bound, and differs from the previous flow where the final --wait timeout began after startup. Keep a duration for per-condition waits (derive their timeout when each wait starts) and start a separate timeout for the final post-plan verification rather than rejecting elapsed plan time here.
…deadline
Copilot review finding on docker#14290: waitDependency decided whether a
DeadlineExceeded on an optional dependency wait was safe to tolerate
using a bare boolean -- whether the caller had configured a timeout
for this wait at all (exec.waitTimeout > 0, or legacy waitDependencies'
timeout > 0). That can't distinguish THIS call's own timeout actually
firing from an earlier deadline inherited from the caller's own ctx
also firing: context.WithTimeout(parent, waitTimeout) still makes both
parent and child report DeadlineExceeded once the parent's earlier
deadline elapses, even though this wait's own timer never got there.
An unsatisfied optional wait could therefore swallow a real external
deadline (the process's own context, a test harness timeout) and
report success instead of propagating it.
Replaced the bool with an origCtx snapshot -- ctx as it stood right
before this call's own conditional context.WithTimeout wrap -- threaded
from executor.go's OpWaitCondition dispatch through execWaitCondition
down to waitDependency, and from waitDependencies' own wrap in
service_containers.go. waitDependency now checks origCtx.Err() == nil
at the moment ctx.Done() fires: only when the pre-wrap context hasn't
failed on its own is this wait's own timeout the one responsible.
TestExecutePlanOptionalWaitDoesNotSwallowInheritedDeadline pins the
negative case (inherited deadline must still propagate even though
exec.waitTimeout is set and the dependency is optional) and
TestExecutePlanOptionalWaitTimeoutIsToleratedWithoutInheritedDeadline
pins the positive one (this wait's own timeout, no inherited deadline,
still tolerated as before) -- both verified by ablation.
Verified: go build, go test ./pkg/compose/... (full suite, not just the
new tests) and golangci-lint clean. Local Copilot CLI review confirmed
the origCtx threading is correct and race-free across all three call
sites; its one actionable finding (missing positive-path coverage for
the plan-executor route specifically) is addressed by the second new
test above.
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
…-wait
Copilot review finding (low) on docker#14290: with --wait-timeout supplied but
not --wait, a dependency timeout in upDetached returned the raw "context
deadline exceeded" instead of the actionable "timeout waiting for
dependencies" the legacy start()+waitDependencies path already reports
for the exact same failure. The --wait-specific "application not healthy
after %s" translation only ever covered the Wait=true case, leaving the
Wait=false, WaitTimeout>0 case (a legal CLI combination -- --wait-timeout
alone is a hang guard) with no translation at all.
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
Copilot flagged a behavior-parity regression on docker#14290's Create->Start
barrier: a service's OpWaitCondition node only depended on its own
dependency's chain end, so its exec.waitTimeout clock could start
ticking earlier than the old imperative engine's single
waitDependencies(X) call. That call only begins once EVERY direct
dependency of X has itself finished starting (confirmed by reading
InDependencyOrder's graphTraversal.run: a service's visitorFn only
runs once none of its dependency children are still ServiceStopped).
A slow sibling with no wait node of its own (service_started is a
plain edge, nothing to poll) could silently eat into a health check's
effective grace period compared to the old engine.
startPhaseDependencies now computes allTargets, the union of every
declared dependency's own chain-end for the requesting service, and
passes that into waitConditionNode instead of just the one dependency
being checked.
Because wait nodes are shared/deduplicated across consuming services
by (dep, condition) alone, this widening reopened a second, more
serious finding from the same review pass: two consumers of the same
shared node can have different sibling sets, and merging a sibling
that already (transitively) depends on the shared node -- an entirely
ordinary diamond dependency, or a service whose own chain end
collapses onto the wait node itself when it has nothing left to start
-- creates a dependency cycle. The executor has no cycle detection and
no timeout on the dependency-wait phase itself (exec.waitTimeout only
applies once a node starts executing), so this was a guaranteed
permanent hang, not a correctness nicety.
Fixed with wouldCreateCycle: a wait node's merge now skips any
candidate that already (transitively) depends on it. This is provably
safe, not a workaround -- if a candidate already depends on the wait
node, the wait node is necessarily already done by the time the
candidate's own chain completes, so there is nothing to gain from also
adding the reverse edge; the candidate's own direct dependency on the
shared node (or on an intermediate sibling) already encodes everything
the consumer's depends_on requires.
Five new regression tests in reconcile_start_test.go, all verified by
ablation (reverting the relevant guard reliably reproduces the
original bug, 3-5/5 runs depending on the test):
- TestPlanStart_WaitNodeGatedOnAllSiblingDependencies: the original
parity fix (db:service_healthy + migrate:service_started siblings).
- TestPlanStart_DiamondDependencyNeverCreatesWaitCycle and
TestPlanStart_ExceptionalChainCollapseNeverCreatesWaitCycle: the two
concrete cycle shapes found in review.
- TestPlanStart_TransitiveChainNeverCreatesWaitCycle: exercises
wouldCreateCycle's BFS two hops deep, not just a direct edge.
- assertNoCycle, a plan-wide helper used by the cycle tests above.
Verified: go build, go test ./pkg/compose/..., and golangci-lint all
clean; full e2e suite re-run clean against a locally built binary
(same 3 pre-existing, environment-specific failures as the stack's
baseline: TestWatch, TestIPC, TestComposeModel). Two rounds of local
Copilot CLI review: the first caught the cycle risk in the initial
allTargets widening, the second confirmed wouldCreateCycle's BFS is
correct and complete, the "skipping is safe" reasoning holds in every
case, and no other call site in reconcile.go has the same unguarded
sharing risk.
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
Reverts 365f002, a behavior-parity fix for a Copilot finding that the
plan-based start engine's per-(dependency,condition) wait node could
start its exec.waitTimeout clock earlier than the old imperative
engine's single waitDependencies(X) call -- which only begins once
EVERY direct dependency of X has itself finished starting, so a slow
sibling with no wait node of its own could silently extend another
dependency's effective grace period.
On reflection that old-engine coupling is itself the bug: a service's
declared --wait-timeout for a given dependency should bound how long
THAT dependency takes to satisfy its condition, period -- not be
inflated by how long an unrelated sibling dependency happens to take
to start. Letting db's health-check timeout silently grow because
migrate's own dependency chain is slow means --wait-timeout no longer
means what it says. Restoring the narrower, per-dependency gating is a
deliberate, accepted behavior change from the old engine, not a parity
regression to chase.
This also removes the dependency-cycle risk the sibling-widening
introduced (an ordinary diamond dependency, or a service whose own
chain end collapses onto a shared wait node, could merge a node back
into its own transitive dependents -- see 365f002's description) and
the wouldCreateCycle machinery and five regression tests that existed
solely to guard against it, now dead weight with no reachable scenario
to protect against.
Verified: go build, go test ./pkg/compose/..., and golangci-lint all
clean.
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
The final readiness check opened its --wait-timeout window before
re-listing the project's containers, so a slow daemon listing could
exhaust the budget and report "application not healthy" before
waitDependencies had polled once. List first on the caller's context,
then derive the timed one, as the imperative path did.
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
…m DeadlineExceeded
up translated any DeadlineExceeded surfacing from the plan into "timeout
waiting for dependencies" or "application not healthy" whenever
--wait-timeout was set, even one coming from an engine call, a hook or
the secret injection, none of which is a dependency-readiness failure.
A wait node whose own --wait-timeout window expired now returns
errWaitTimeout (still satisfying DeadlineExceeded), and up keys its
message on that. A deadline inherited from the caller is never marked.
The test that checks --wait-timeout does not leak into non-wait nodes now
inspects the context those nodes receive, instead of a mock that would
have succeeded either way.
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
…th --wait too
Copilot review finding on docker#14296: a wait node's own timeout is a
depends_on condition that did not become satisfied during the plan, not
the final --wait readiness check, which runs after the plan and has its
own "application not healthy" translation. The imperative path reported
"timeout waiting for dependencies" for the former regardless of --wait,
but translateWaitTimeout rewrote it as "application not healthy" when
--wait was set. It now always reports the dependency timeout.
Also mark the test nodes that stand for the Start phase as PhaseStart
(the recreate-then-start plan and the wait whose timeout survives a
skipped dependent), so they exercise the Create->Start barrier the way
the reconciler's plans do instead of running everything in one phase.
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
Copilot review finding on docker#14296: planServiceStart returned before any
dependency planning for every service whose scale is zero. The imperative
startService only treats deploy.replicas: 0 as an unconditional no-op: a
service with scale: 0 still has its depends_on conditions evaluated first,
so an unhealthy or missing required dependency fails `up`/`start`, and
whatever is ordered after the service stays ordered after those
prerequisites. It is simply never an error for it to have no container to
start, and under scope Start the replicas it already owns are started
(CreateStart removes them all in the create phase).
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
Copilot review finding on docker#14300: if a sibling Create-phase failure ever
stopped canceling the other node, VolumeCreate's mock blocks on its ctx
while exec.run waits for it, so the test's own five-second guard could
never fire and the regression would hang until the suite-wide timeout.
Give run a deadline of its own and assert the cancellation is the
sibling's (context.Canceled), not that fallback.
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
…ilure
A node that leaves its dependency wait because its phase was canceled
closes its done channel (deferred) without recording any failure. In
X -> A -> B with X failing, B could select done[A] before it saw the
cancellation, find no failure recorded for A, emit its "Starting" event
and enter executeNode on a canceled context, leaving the row never
finalized.
Record the cancellation as the node's failure at that exit, so a
dependent that wins the race is skipped like any other dependent of a
failed node. The regression test pins GOMAXPROCS to 1, which makes the
bad interleaving the usual one.
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
…ency
The wiring of --wait-timeout into the plan's dependency waits was only
covered by executor tests that set the timeout by hand, and by an e2e
without depends_on. The scenario has a dependency that never gets
healthy and checks that up -d --wait-timeout fails with the dependency
timeout message (without the raw deadline error) and leaves the
dependent service created but not started.
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
The reason will be displayed to describe this comment to others. Learn more.
This review covers only the commits pushed since 8e4868f430be1ca9a38a255771ee25aad0a43a03.
Assessment: 🟢 APPROVE
The two-file incremental diff is clean. The executor.go fix correctly calls rs.recordFailure(node.ID, err) in the nodeCtx.Done() branch of the dependency-wait loop before returning — using the right node ID and the right error (nodeCtx.Err()). This completes the failure-recording contract for every exit path in runNode, so a downstream dependent that races past the phase-cancellation check will find the failure recorded and skip its own execution rather than entering executeNode on an already-cancelled context.
The regression test (TestExecutePlanCanceledDependencyIsRecordedAsFailure) is well-constructed: runtime.GOMAXPROCS(1) is set and properly restored via t.Cleanup; AnyTimes() on NetworkCreate correctly covers all 200 loop iterations; VolumeCreate.Times(0) is the precise assertion that would catch a regression; t.Context() is used as required by project conventions; and for range 200 is valid Go 1.22+ range-over-int (project uses Go 1.26.9). No issues found in any added lines.
The reason will be displayed to describe this comment to others. Learn more.
Assessment: 🟢 APPROVE
The PR is well-engineered. It correctly implements the single-plan detached up by sequencing Create and Start phases through runPhase barriers, fixes the rename-dance progress-event name bug, and introduces per-wait-node timeout via execBoundedWaitCondition. The phaseCancel + sync.Once pattern correctly resolves the errgroup first-error race. The origCtx pattern in waitDependency correctly distinguishes the call's own timeout from an inherited parent deadline. Test coverage is thorough.
Copilot review (High) on #14290: run() dispatched every node -- Create
and Start phase alike -- under one errgroup.WithContext(ctx), so any
node failing canceled the shared context for every other goroutine
still in flight. A required OpWaitCondition node hitting
--wait-timeout is a Start-phase failure that can now happen while
unrelated Create-phase work (a sibling service's container or network
create) is still legitimately running -- and that work got aborted
too, with nothing to do with the dependency that timed out. The old
create()-then-start() sequence never had this failure mode: the whole
create phase always finished completely before any dependency wait
even began.
run() now derives two contexts instead of one shared errgroup-context:
createCtx at the root, startCtx as its child. A node picks nodeCtx
(and the matching cancelPhase) from its own node.Phase and calls
cancelPhase() only on its own genuine failure. A Start-phase failure
cancels only startCtx, leaving createCtx and everything still running
under it untouched. A Create-phase failure still cancels the whole
plan, Start phase included, via startCtx's derivation from createCtx
-- matching the old sequence, where start() was never even invoked
once create() failed.
The node-goroutine body moved out of run()'s loop into a runNode
method (golangci-lint's cognitive-complexity limit after this change)
taking a new runState struct bundling the per-run bookkeeping every
node shares.
Two smaller, unrelated fixes bundled in:
- upDetached (up.go) keeps an origCtx snapshot so its two
DeadlineExceeded-to-friendly-message checks can tell its own
WaitTimeout window expiring apart from an inherited, unrelated
parent deadline propagating through the derived context -- both
report DeadlineExceeded on Err(), only the former earned the
"application not healthy" framing.
- waitDependency (service_containers.go), shared by the old
waitDependencies and the new execWaitCondition -- the latter can now
produce a real deadline for the first time via exec.waitTimeout --
tolerates a timeout on an optional (BestEffort) dependency the same
way it already tolerates every other definitive failure: skipped
with a warning, not a hard failure.
Two regression tests pin both cancellation directions:
TestExecutePlanStartPhaseFailureDoesNotCancelCreatePhase (a
Start-phase wait timing out must not cancel a concurrent, unrelated
Create-phase node) and TestExecutePlanCreatePhaseFailureCancelsCreatePhase
(a Create-phase failure must still fail-fast its Create-phase
siblings, same as before this change). Both verified by ablation.
Full e2e suite re-run clean against a locally built binary -- same 3
pre-existing, environment-specific failures as the stack's baseline,
no new ones.
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
…deadline
Copilot review finding on #14290: waitDependency decided whether a
DeadlineExceeded on an optional dependency wait was safe to tolerate
using a bare boolean -- whether the caller had configured a timeout
for this wait at all (exec.waitTimeout > 0, or legacy waitDependencies'
timeout > 0). That can't distinguish THIS call's own timeout actually
firing from an earlier deadline inherited from the caller's own ctx
also firing: context.WithTimeout(parent, waitTimeout) still makes both
parent and child report DeadlineExceeded once the parent's earlier
deadline elapses, even though this wait's own timer never got there.
An unsatisfied optional wait could therefore swallow a real external
deadline (the process's own context, a test harness timeout) and
report success instead of propagating it.
Replaced the bool with an origCtx snapshot -- ctx as it stood right
before this call's own conditional context.WithTimeout wrap -- threaded
from executor.go's OpWaitCondition dispatch through execWaitCondition
down to waitDependency, and from waitDependencies' own wrap in
service_containers.go. waitDependency now checks origCtx.Err() == nil
at the moment ctx.Done() fires: only when the pre-wrap context hasn't
failed on its own is this wait's own timeout the one responsible.
TestExecutePlanOptionalWaitDoesNotSwallowInheritedDeadline pins the
negative case (inherited deadline must still propagate even though
exec.waitTimeout is set and the dependency is optional) and
TestExecutePlanOptionalWaitTimeoutIsToleratedWithoutInheritedDeadline
pins the positive one (this wait's own timeout, no inherited deadline,
still tolerated as before) -- both verified by ablation.
Verified: go build, go test ./pkg/compose/... (full suite, not just the
new tests) and golangci-lint clean. Local Copilot CLI review confirmed
the origCtx threading is correct and race-free across all three call
sites; its one actionable finding (missing positive-path coverage for
the plan-executor route specifically) is addressed by the second new
test above.
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
…-wait
Copilot review finding (low) on #14290: with --wait-timeout supplied but
not --wait, a dependency timeout in upDetached returned the raw "context
deadline exceeded" instead of the actionable "timeout waiting for
dependencies" the legacy start()+waitDependencies path already reports
for the exact same failure. The --wait-specific "application not healthy
after %s" translation only ever covered the Wait=true case, leaving the
Wait=false, WaitTimeout>0 case (a legal CLI combination -- --wait-timeout
alone is a hang guard) with no translation at all.
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this PR does, in one sentence
docker compose up -dnow runs its Create and Start phases as a single plan over a single daemon snapshot, instead of the oldcreate()+start()sequence — two separate snapshots, the second blind to what the first had just done.Context
Detached
uphas always run two unrelated things back to back:create()converges the project (networks, volumes, containers) and plans aPlan, thenstart()re-lists containers from the daemon from scratch and runs the older imperative engine to start them. That second listing can't see anythingcreate()'s plan changed in the same call — it only sees whatever the daemon looked like before this invocation ofupbegan, modulo its own separate converge from the first listing's point of view.Lot 1 of epic #14081 already taught the reconciler and executor to plan and execute a Start phase in the same
Planas the Create phase (ReconcileOptions.Scope: ScopeCreateStart), precisely so a caller like detachedupcould stop needing a second pass. Until this PR, nothing actually requested that scope — it shipped as inert code with no consumer.What the PR brings
preparePlantakes an explicit scope now (create()passesScopeCreate, byte-identical to its previous implicit behavior). A newupDetachedrunspreparePlan(..., ScopeCreateStart)+executePlanonce forup -d;--waitstays a final, post-plan verification (re-listing containers once execution is done, since the plan may have just created or recreated some) on the samewaitDependenciesprimitivestart()already uses. Interactiveupis untouched — its own create/start phase boundary is a separate, riskier item of the same lot.Making
ScopeCreateStarta real, exercised path for the first time surfaced two latent bugs in the recreate machinery that nothing had ever driven end to end: a recreated container's Start-phase progress events (and a dependent's health-wait events) kept reporting the container's internal temporary rename-dance name instead of its final one. Both are fixed here, each pinned by a regression test — see the commit message for the detail; caught and confirmed via the full e2e suite (TestRestartWithDependencies) against a locally built binary, not just unit tests.The switchover is also where the wait semantics of the Start phase get exercised for real, so a few guardrails are pinned by tests:
--wait-timeoutgives each dependency wait its own window and still reports adepends_ontimeout as such (and an inherited deadline is never blamed on--wait-timeout); arequired: falsedependency that times out is skipped rather than failingup(this applies to the sharedwaitDependencyprimitive, so the imperative callers —restart,run,start,--wait— share it); Create is a hard barrier before Start, and a failure in one phase never cancels unrelated in-flight work of the other.Part of #14081.
🤖 Generated with Claude Code