Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
52eced1
ADFA-4128: qb 08/12 core-orchestration — Core slice 4: the session st…
fryanpan Aug 21, 2026
7390c95
ADFA-4128: qb 08 review fixes — cancel sequencing, provision tail, de…
fryanpan Aug 22, 2026
98e9719
ADFA-4128 (8/11): address CodeRabbit review
fryanpan Aug 27, 2026
93fd10d
ADFA-4128: qb-08 review fixes - foreground policy, slot-busy park, na…
fryanpan Sep 1, 2026
2fe0e61
ADFA-4128: 0902 review round on quickbuild:core orchestration
fryanpan Sep 3, 2026
e961a13
ADFA-4128: five session-lifecycle fixes from the 0903 review round
fryanpan Sep 3, 2026
1fa42d8
ADFA-4128: name the guard on the Provisioning to Ready edge in the st…
fryanpan Sep 3, 2026
5c46dad
ADFA-4128: a daemon that will not start reports a named message, not …
fryanpan Sep 3, 2026
41a60e4
ADFA-4128: take the layout's tree walks off the session dispatcher
fryanpan Sep 4, 2026
31b8604
ADFA-4128: forget the death reporter when a respawn fails or a rebuil…
fryanpan Sep 4, 2026
f2105e4
ADFA-4128: seed the executor's live generation from the baseline stamp
fryanpan Sep 4, 2026
1ac2601
ADFA-4128: take the asset packaging and the artifact check off the se…
fryanpan Sep 4, 2026
cbaeb1a
ADFA-4128: adopt the suspend scratch/store contracts and surface comp…
fryanpan Sep 4, 2026
9eab47c
ADFA-4128: remove the scratch tree when provisioning fails after prep…
fryanpan Sep 5, 2026
0e40eab
ADFA-4128: cite the follow-up tickets from the session manager and th…
fryanpan Sep 5, 2026
c60eea6
ADFA-4128: move the watch set with the baseline on a proxy app rebuild
fryanpan Sep 7, 2026
5ee960a
ADFA-4128: park a stopped rebaseline for retry instead of tearing the…
fryanpan Sep 7, 2026
80421af
ADFA-4128: round 5 session-manager fixes: death-reporter ownership, s…
fryanpan Sep 8, 2026
1313559
ADFA-4128: re-key the connection registry on the reinstalled proxy ap…
fryanpan Sep 8, 2026
2b234da
ADFA-4128: round 5 doc fixes on the session state diagram
fryanpan Sep 8, 2026
f84b012
style: spotless reformat, no functional change
fryanpan Sep 8, 2026
65c7e5e
ADFA-4128: camel-case the rebuilt-uid test constant for ktlint
fryanpan Sep 8, 2026
0308fe0
ADFA-4128: carry a consumed tap through the invalidation onto the reb…
fryanpan Sep 9, 2026
d30c789
ADFA-4128: keep the held set when a parked retry starts another rebuild
fryanpan Sep 9, 2026
339331a
ADFA-4128: withdraw the ask on a rebaseline stop and report only a re…
fryanpan Sep 9, 2026
87f8eb9
ADFA-4128: report a skipped relaunch as null, not as a failed one
fryanpan Sep 9, 2026
97456ae
ADFA-4128: share one daemon executor across RecordingIoDispatcher ins…
fryanpan Sep 9, 2026
7290fe2
ADFA-4128: say why onWatcherBatch needs no lock
fryanpan Sep 9, 2026
b4f63e9
ADFA-4128: withdraw the deferred foreground ask when a rebaseline is …
fryanpan Sep 10, 2026
bd8d050
ADFA-4128: sweep every session state and event for the reducer's inva…
fryanpan Sep 10, 2026
d007b6e
ADFA-4128: keep the tap when an invalidation lands where the ask lives
fryanpan Sep 10, 2026
465661f
ADFA-4128: carry an outstanding ask through an automatic reprovision
fryanpan Sep 10, 2026
40b4416
ADFA-4128: withdraw the ask when a parked invalidation is stopped
fryanpan Sep 10, 2026
6a29203
ADFA-4128: make every per-state reducer when exhaustive over SessionE…
fryanpan Sep 10, 2026
c4cc220
ADFA-4128: name every field and its source at the three parks
fryanpan Sep 10, 2026
0600a4b
ADFA-4128: clear the orchestrator's three copies of the ask in one place
fryanpan Sep 10, 2026
cd57f1e
ADFA-4128: give the user's ask one owner, PendingAsk
fryanpan Sep 10, 2026
d864615
ADFA-4128: pin PendingAsk's two unobvious promises with tests of its own
fryanpan Sep 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,17 @@ data class ProxyAppInfo(
val supportsComponentInfo: Boolean
get() = schema >= COMPONENT_SCHEMA_VERSION

/**
* Proxy class of the activity that carries MAIN/LAUNCHER, or null when none does; a null
* makes the launcher fall back to the package's default launch intent, which also resolves
* an `<activity-alias>` launcher.
*
* The restart deploy, the rebuild relaunch and the foreground switch all launch this, so
* the three cannot drift apart.
*/
val launcherProxyClass: String?
get() = components.firstOrNull { it.kind == ComponentKind.ACTIVITY && it.launcher }?.proxyClass

companion object {
private val log = LoggerFactory.getLogger("QB-ProxyAppInfo")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,7 @@ class QuickBuildScratch(
* the space guard. Never throws - a failure comes back as [Preparation.Failed]
* with the message provisioning surfaces to the user.
*
* A provision that fails after this returned [Preparation.Ready] leaves the tree behind:
* [remove] is keyed off the live layout, which a failed provision never sets, so the tree
* waits for the next session manager start's [sweep]. Tracked as a followup under
* ADFA-5423 rather than fixed here, since the failure paths that leave it live in the
* provisioner.
* A provision that fails after [Preparation.Ready] [remove]s the tree itself.
*
* @param projectRoot the project's root directory.
* @return [Preparation.Ready] with the tree, or [Preparation.Failed] on a space shortfall or
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,15 @@ sealed interface BuildOutcome {
* total beside the timing line; falls back to the build's own start with no trigger stamp.
* @property restarted true when the deploy took the process-restart path (a service,
* provider or Application class changed) instead of a hot swap.
* @property diagnostics the warnings the compile still produced, in the compiler's order;
* empty when it reported none or the route compiled nothing. Never an ERROR - that is a
* [CompileError].
*/
data class Success(
val generation: Long,
val durationMillis: Long,
val restarted: Boolean = false,
val diagnostics: List<BuildDiagnostic> = emptyList(),
) : BuildOutcome

/**
Expand Down Expand Up @@ -147,6 +151,15 @@ sealed interface BuildOutcome {
val message: String,
val daemonDied: Boolean = false,
) : BuildOutcome

companion object {
/**
* Fallback copy for a messageless throwable escaping the pipeline. The class name is
* in the ERROR log line the catch already writes, where it helps; on the status
* surface it reads as gibberish.
*/
const val UNEXPECTED_FAILURE = "Quick Build stopped unexpectedly"
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ class LiveReloadOrchestrator(
private val wallClock: () -> Long = System::currentTimeMillis,
/** Reads a file's mtime (epoch millis, 0 when missing or unreadable); injectable for tests. */
private val fileLastModified: (File) -> Long = File::lastModified,
/**
* Whether a Quick Build tap is waiting to see the app - the session's one
* [org.appdevforall.cotg.quickbuild.domain.session.PendingAsk], read and never written here.
* Read at build start so the deploy may open a closed app, and at the tap's deadline. Must
* not block: it is called under the internal lock.
*/
private val askOutstanding: () -> Boolean = { false },
/**
* Receives every event, delivered outside the internal lock on the caller's context so a
* handler may call back in; it must not throw, as an exception propagates into the caller.
Expand All @@ -55,23 +62,6 @@ class LiveReloadOrchestrator(
private var pending: ChangedFiles = ChangedFiles.Known.EMPTY
private var pendingForced = false

/**
* Set by a Quick Build tap and nothing else, because it decides whether the user is pulled out
* of the editor into the proxy app - unlike [pendingForced], which the reconnect catch-up also
* sets and a failed build re-arms. A failed build does NOT re-arm this one: the tap was already
* answered, with an error. It never outlives the pending set it asked about, or a later
* automatic save would be reported as something the user asked for.
*/
private var pendingUserInitiated = false

/**
* A user tap whose save-all wrote something, waiting for the watcher batch those writes will
* produce. Consumed by the first non-empty batch (which then carries the ask as
* [pendingUserInitiated]) or by [consumeUnansweredTap]'s deadline, whichever comes first -
* never both, so the tap is answered exactly once. Cleared wherever [pendingUserInitiated]
* is force-cleared, for the same reason: the ask must not outlive the work it was about.
*/
private var tapAwaitingChanges = false
private var inFlight: InFlightBuild? = null
private var nextBuildId = 1L
private var invalidationReported = false
Expand Down Expand Up @@ -154,11 +144,6 @@ class LiveReloadOrchestrator(
val forced: Boolean,
val autoFollowUp: Boolean,
val route: BuildRoute,
/**
* Mutable because a tap landing MID-BUILD is satisfied by this build's deploy
* rather than by a second one: the tap has nothing to add except the ask itself.
*/
var userInitiated: Boolean = false,
/**
* Cancellation handle. [onCancelRequested] leaves a warm compile alone, since the user
* never asked for it; a proxy app rebuild supersedes and cancels any route.
Expand All @@ -180,12 +165,6 @@ class LiveReloadOrchestrator(
if (awaitingAbsorption != null && remainder.isEmpty) return@withEvents
markBatchArrivalLocked()
pending = unionPendingLocked(pending, remainder)
if (tapAwaitingChanges && !pending.isEmpty) {
// The batch the tap's save-all promised has arrived; the build it produces
// answers the tap, so its deploy may bring the proxy app forward.
tapAwaitingChanges = false
pendingUserInitiated = true
}
maybeStartBuildLocked(events)
}
}
Expand All @@ -195,12 +174,15 @@ class LiveReloadOrchestrator(
*
* A user tap never forces a blind rebuild (the F7 echo fix): with work already pending it
* starts a correctly-routed build whose deploy answers the tap; with nothing pending and
* [expectChanges] set it arms the tap on the watcher batch the save-all's writes will
* [expectChanges] set it leaves the tap to the watcher batch the save-all's writes will
* deliver; with nothing pending and nothing written the caller switches immediately - the
* deployed app is already current. Only the non-user reconnect catch-up still forces
* ([BuildRequest.forced]): the app is provably behind and there is no changed-set to route,
* and a failed forced build re-arms the flag so the eventual retry is forced too.
*
* The ask itself is not held here: the caller recorded it on the session's PendingAsk before
* calling, and [maybeStartBuildLocked] reads it through [askOutstanding] when a build starts.
*
* @param userInitiated whether a human asked - only a tap passes true, since the reconnect
* catch-up would otherwise drag the user into the proxy app unprompted.
* @param expectChanges tap-only: the tap's save-all wrote at least one file, so a watcher
Expand All @@ -227,20 +209,18 @@ class LiveReloadOrchestrator(
// Accumulated work: build it now, routed by the classifier as any save
// would be; the deploy answers the tap.
markBatchArrivalLocked()
pendingUserInitiated = true
maybeStartBuildLocked(events)
outcome = deployAnsweringOutcomeLocked()
}

expectChanges -> {
// The tap's save-all wrote something, so its watcher batch is already on
// the way (the coalescer emits within 250 ms of the last event). Arm the
// tap on that batch instead of building an empty set behind it; the
// the way (the coalescer emits within 250 ms of the last event). Leave the
// tap to that batch instead of building an empty set behind it; the
// caller runs the deadline fallback for the case where every written
// file was watcher-irrelevant and no batch ever comes. Deliberately no
// queue-clock stamp: if no batch comes, a stamp here would charge the
// dead wait to the next unrelated save's build (the T16 shape).
tapAwaitingChanges = true
outcome = LiveReloadRequestOutcome.AWAITS_CHANGES
}

Expand Down Expand Up @@ -272,19 +252,19 @@ class LiveReloadOrchestrator(
}

/**
* Disarms a tap still waiting for its save-all's watcher batch and says whether it was
* waiting - the deadline half of the arm-on-batch tap protocol.
* Whether an outstanding tap has nothing left here to answer it - the deadline half of the
* tap-on-batch protocol.
*
* Called by the session manager's fallback timer. True means no batch arrived (the save-all
* wrote only watcher-irrelevant files, e.g. a `.md`), so the caller answers the tap by
* switching now; false means a batch already consumed the tap and its build's deploy
* answers it, so the caller must do nothing - either way, exactly once.
* Called by the session manager's fallback timer. True means the ask still stands and no
* batch arrived for it (the save-all wrote only watcher-irrelevant files, e.g. a `.md`), so
* the caller answers the tap by switching now; false means either the tap was already
* answered or a batch arrived and a build, a queued set or a Gradle rebuild owes the answer,
* so the caller must do nothing - either way, exactly once.
*/
suspend fun consumeUnansweredTap(): Boolean =
suspend fun askHasNoAnswerComing(): Boolean =
mutex.withLock {
val wasArmed = tapAwaitingChanges
tapAwaitingChanges = false
wasArmed
val realBuildInFlight = inFlight?.let { it.route !is BuildRoute.WarmCompile } ?: false
askOutstanding() && !realBuildInFlight && pending.isEmpty && awaitingAbsorption == null && !pendingForced
}

/**
Expand All @@ -300,7 +280,6 @@ class LiveReloadOrchestrator(
if (flight == null || flight.route is BuildRoute.WarmCompile) {
false
} else {
flight.userInitiated = true
// The request already left with userInitiated false, so the executor has to
// hear about the promotion separately or this build's deploy would still
// refuse to open a closed app - and the tap would do nothing at all.
Expand All @@ -316,6 +295,7 @@ class LiveReloadOrchestrator(
* Two limits: the daemon has no cancel op, so the compile runs to completion unheard and may
* delay the next build; and a stop in the deploy's own scheduler turn can report a cancel for a
* payload the proxy app already took, leaving the status line one generation behind.
* Tracked as ADFA-5456.
*
* @return true when a build was abandoned; false when there was nothing to cancel or it was a
* warm compile the user never asked for, on which the caller must report no cancellation.
Expand All @@ -325,12 +305,11 @@ class LiveReloadOrchestrator(
mutex.withLock {
val flight = inFlight ?: return@withLock
if (flight.route is BuildRoute.WarmCompile) return@withLock
// A stop withdraws the abandoned build's forced flag too, so it cannot redeploy
// later; the ask itself is the reducer's to withdraw, and it does so on the same
// CancelRequested.
inFlight = null
// A stop withdraws the ask, so neither the abandoned build's forced flag nor a tap
// queued behind it - answered or still armed - may survive to redeploy later.
pendingForced = false
pendingUserInitiated = false
tapAwaitingChanges = false
// And the abandoned build's t0 goes with it: the returning batch now waits on the
// user, not on a queue, so the next arrival stamps its own. A mid-build save already
// owns the clock and keeps it - that save really did queue behind this build.
Expand Down Expand Up @@ -409,18 +388,24 @@ class LiveReloadOrchestrator(
mutex.withLock {
val superseded = inFlight
absorptionStartedAtMillis = wallClock()
awaitingAbsorption = unionPendingLocked(superseded?.batch ?: ChangedFiles.Known.EMPTY, pending)
// Unioned onto whatever is already held, not assigned over it: a retry after an
// unconfirmed reinstall starts here again while the first rebuild's set is still
// held (the manager skips onProxyAppRebuildFailed on that path). Replacing the held
// set kept only the park-period saves, so a failed retry returned only those to
// pending and the next code-only save exited the park with the gradle or manifest
// change never installed.
awaitingAbsorption =
unionPendingLocked(
unionPendingLocked(awaitingAbsorption ?: ChangedFiles.Known.EMPTY, superseded?.batch ?: ChangedFiles.Known.EMPTY),
pending,
)
pending = ChangedFiles.Known.EMPTY
// Gradle owns this batch now, and a rebuild runs for minutes. Keeping the clock would
// charge all of it to whichever build picked the batch back up if the rebuild failed.
pendingSince = null
pendingForced = false
// The tap this recorded asked about the very set Gradle is now absorbing, so that
// build answers it. Left armed, it would tag some later unrelated save as the user's
// ask and pull them out of the editor into the proxy app. Same for a tap still
// waiting on its batch: the rebuild reads the tap's saves off disk anyway.
pendingUserInitiated = false
tapAwaitingChanges = false
// A tap that asked about this set stays the session's outstanding ask, and the
// rebuild's relaunch answers it; nothing here to hand over.
inFlight = null
// Nulling inFlight only discards the late RESULT; the coroutine runs on and would
// deploy a payload compiled against the old baseline into an app Gradle is
Expand All @@ -444,9 +429,6 @@ class LiveReloadOrchestrator(
pending = ChangedFiles.Known.EMPTY
pendingSince = null
pendingForced = false
// Dropped with the set it asked about; see onProxyAppRebuildStarted.
pendingUserInitiated = false
tapAwaitingChanges = false
inFlight = null
superseded?.job?.cancel()
}
Expand Down Expand Up @@ -640,18 +622,15 @@ class LiveReloadOrchestrator(

val batch = pending
val forced = pendingForced
val userInitiated = pendingUserInitiated
// A batch with no clock was not queueing - it is a failed build's batch that has been
// sitting on the user, picked up by a path that starts a build without an arrival of its
// own. Its t0 is this build's own start, which reports the wait as the zero it was.
val triggeredAtMillis = pendingSince ?: now()
pending = ChangedFiles.Known.EMPTY
pendingSince = null
pendingForced = false
pendingUserInitiated = false
val buildId = nextBuildId++
val flight =
InFlightBuild(buildId, batch, forced, autoFollowUp, route, userInitiated = userInitiated)
val flight = InFlightBuild(buildId, batch, forced, autoFollowUp, route)
inFlight = flight
events += OrchestratorEvent.BuildStarted(buildId, route, batch)

Expand All @@ -662,7 +641,10 @@ class LiveReloadOrchestrator(
route = route,
forced = forced,
triggeredAtMillis = triggeredAtMillis,
userInitiated = userInitiated,
// Snapshot of the session's ask: a build that starts while a tap is waiting
// is the one whose deploy may open a closed app. A tap landing mid-build
// promotes it through markInFlightUserInitiated instead.
userInitiated = askOutstanding(),
)
// Assigned while still holding the lock, so a cancel can never see a null handle for a
// build that is already running. Nothing suspends in between, and the launched
Expand Down Expand Up @@ -713,7 +695,7 @@ class LiveReloadOrchestrator(
throw e
} catch (e: Throwable) {
log.error("Quick build #{} threw instead of reporting an outcome", buildId, e)
BuildOutcome.InfrastructureFailure(e.message ?: e.javaClass.name)
BuildOutcome.InfrastructureFailure(e.message ?: BuildOutcome.UNEXPECTED_FAILURE)
}
onBuildFinished(buildId, outcome)
}
Expand Down Expand Up @@ -743,13 +725,7 @@ class LiveReloadOrchestrator(
is BuildOutcome.Success -> {
lastCompileDiagnostics = null
clearFailureTallyLocked()
events +=
OrchestratorEvent.BuildSucceeded(
buildId,
outcome,
flight.route,
userInitiated = flight.userInitiated,
)
events += OrchestratorEvent.BuildSucceeded(buildId, outcome, flight.route)
// Saves that landed mid-build start the coalesced follow-up now.
maybeStartBuildLocked(events, autoFollowUp = true)
}
Expand All @@ -766,9 +742,8 @@ class LiveReloadOrchestrator(
// stamp is already the pending clock and wins.
if (!newSavesArrivedMidBuild) pendingSince = null
pendingForced = pendingForced || flight.forced
// pendingUserInitiated is deliberately NOT re-armed: the tap was already
// answered, with the failure. The save that fixes the code is not a new
// ask, so it must not drag the user out of the editor (see the field).
// The ask is not this class's to settle: the reducer withdraws it on the
// BuildFailed this becomes, since the tap was answered with the failure.

val diagnostics = (outcome as? BuildOutcome.CompileError)?.diagnostics
val relinkStuck =
Expand Down Expand Up @@ -819,8 +794,7 @@ class LiveReloadOrchestrator(
buildId,
outcome,
)
events +=
OrchestratorEvent.InvalidationRequired(InvalidationReason.RELOAD_PIPELINE_FAILED)
events += OrchestratorEvent.InvalidationRequired(InvalidationReason.RELOAD_PIPELINE_FAILED)
} else if (newSavesArrivedMidBuild) {
// A mid-build save may be the fix; rebuild from the accumulated set.
maybeStartBuildLocked(events, autoFollowUp = true)
Expand Down Expand Up @@ -908,8 +882,8 @@ enum class LiveReloadRequestOutcome {
AWAITS_DEPLOY,

/**
* The tap is armed on the save-all's incoming watcher batch; the caller must run the
* deadline fallback via [LiveReloadOrchestrator.consumeUnansweredTap].
* The tap is left to the save-all's incoming watcher batch; the caller must run the
* deadline fallback via [LiveReloadOrchestrator.askHasNoAnswerComing].
*/
AWAITS_CHANGES,
}
Expand Down Expand Up @@ -941,11 +915,6 @@ sealed interface OrchestratorEvent {
val result: BuildOutcome.Success,
/** What the build was for - a [BuildRoute.WarmCompile] success deployed nothing. */
val route: BuildRoute,
/**
* True when this build answers a Quick Build tap, so the proxy app should be brought
* forward as the deploy lands; false for a build a file write triggered.
*/
val userInitiated: Boolean = false,
) : OrchestratorEvent

/**
Expand Down
Loading
Loading