1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
//! Base-lane wiring for opt-in upstream integration.
//!
//! Every method here short-circuits when no coordinator is installed, which is
//! what makes the default-off path a hard compatibility boundary: a disabled run
//! never fetches, merges, verifies, pushes, or emits upstream evidence.
//!
//! Checkpoints are requested only at the deterministic boundaries the spec
//! defines. There is no scheduler-loop polling and no time-based polling: the
//! scheduler asks at an edge, and [`crate::upstream::CheckpointScheduler`]
//! decides whether the checkpoint starts, batches, or defers.
use crate::error::{OrchestratorError, Result};
use crate::upstream::checkpoint::{BaseLaneState, CheckpointTrigger};
use crate::upstream::coordinator::{
scan_pending_publications, FinalizeOutcome, PublicationOutcome, SchedulerOutcome,
UpstreamStepOutcome,
};
use crate::upstream::git_ops::GitUpstreamOps;
use crate::upstream::PublicationEvidence;
use super::ParallelExecutor;
/// What a change-scoped publication produced for the base lane.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PublicationLaneOutcome {
/// Remote observation confirms cumulative HEAD contains this change.
Confirmed { head: String },
/// The bounded cycle did not converge. The change is not published, the base
/// lane stays closed to later results, and retry resumes from Git evidence.
Unpublished { reason: String },
}
impl ParallelExecutor {
/// Whether this run opted in to upstream integration.
pub(super) fn upstream_enabled(&self) -> bool {
self.upstream.is_some()
}
/// Observe base-lane safety for a checkpoint request.
///
/// `lane_owned` is true when the caller already holds the project base lane
/// (the global merge lock) and has confirmed the base is clean.
async fn observe_base_lane(&self, lane_owned: bool) -> BaseLaneState {
if lane_owned {
return BaseLaneState::clean();
}
let base_dirty_reason = super::merge::base_dirty_reason(&self.repo_root)
.await
.unwrap_or_else(|err| Some(format!("base state unavailable: {}", err)));
let lane_busy_reason = if base_dirty_reason.is_some() {
None
} else if super::global_merge_lock().try_lock().is_err() {
Some("project base lane is owned by another base operation".to_string())
} else {
None
};
BaseLaneState {
base_dirty_reason,
lane_busy_reason,
}
}
/// Run an upstream checkpoint at a deterministic boundary.
///
/// A deferred or batched checkpoint is not an error: the requesting edge
/// simply keeps its result queued. A stall is an error so the base lane stays
/// closed and later base-dependent dispatch is blocked.
pub(super) async fn run_upstream_checkpoint(
&self,
trigger: CheckpointTrigger,
pending_result: Option<&str>,
lane_owned: bool,
) -> Result<()> {
let Some(upstream) = self.upstream.clone() else {
return Ok(());
};
let lane = self.observe_base_lane(lane_owned).await;
let mut coordinator = upstream.lock().await;
match coordinator
.checkpoint(trigger, &lane, pending_result)
.await?
{
UpstreamStepOutcome::Stalled { reason } => Err(OrchestratorError::GitCommand(format!(
"upstream checkpoint stalled: {}",
reason
))),
UpstreamStepOutcome::NoOp { .. }
| UpstreamStepOutcome::Integrated { .. }
| UpstreamStepOutcome::Deferred { .. } => Ok(()),
}
}
/// Run the complete verification command after a completed change result
/// merged into cumulative base.
///
/// The caller holds the base lane, so a failure keeps it closed.
pub(super) async fn run_upstream_base_result_verification(
&self,
change_id: &str,
) -> Result<()> {
let Some(upstream) = self.upstream.clone() else {
return Ok(());
};
let mut coordinator = upstream.lock().await;
match coordinator.verify_base_result(change_id).await? {
UpstreamStepOutcome::Stalled { reason } => Err(OrchestratorError::GitCommand(format!(
"upstream verification blocked base integration: {}",
reason
))),
_ => Ok(()),
}
}
/// Selected remote and cumulative base branch, when upstream is installed.
///
/// Used only to label change-scoped publication events; it is never routing
/// input.
pub(super) async fn upstream_identity(&self) -> Option<(String, String)> {
let upstream = self.upstream.clone()?;
let coordinator = upstream.lock().await;
Some((
coordinator.config().remote.clone(),
coordinator.branch().to_string(),
))
}
/// Publication-required integrations that are not proven remote-reachable.
///
/// This is repository evidence, not process memory, so it answers the same
/// way after a restart as it does mid-run. A disabled run never calls it.
pub(super) async fn pending_publications(&self) -> Vec<PublicationEvidence> {
if !self.upstream_enabled() {
return Vec::new();
}
let git = GitUpstreamOps::new(&self.repo_root);
match scan_pending_publications(&git).await {
Ok(evidence) => evidence,
Err(err) => {
// An unreadable repository must not silently unblock the lane;
// the caller treats "unknown" as "nothing pending" only because
// the merge path re-checks base cleanliness independently.
tracing::warn!(error = %err, "Pending publication scan unavailable");
Vec::new()
}
}
}
/// Change ID of a pending publication owned by a *different* change.
///
/// While this is `Some`, no later completed result may enter cumulative base:
/// the prior change's published revision is not yet known, so attribution of
/// a later publication would be ambiguous.
pub(super) async fn blocking_publication_change(&self, current: &str) -> Option<String> {
self.pending_publications()
.await
.into_iter()
.map(|evidence| evidence.trailers.change_id)
.find(|change_id| change_id != current)
}
/// Whether this change already has durable publication-required evidence.
///
/// True means the change is already integrated into cumulative base and owes
/// only publication, so retry must resume at the publication boundary instead
/// of merging it a second time.
pub(super) async fn has_pending_publication_for(&self, change_id: &str) -> bool {
self.pending_publications()
.await
.iter()
.any(|evidence| evidence.trailers.change_id == change_id)
}
/// Record durable publication-required identity for a locally integrated
/// change, before it may be treated as integrated.
pub(super) async fn record_publication_intent(&self, change_id: &str) -> Result<()> {
let Some(upstream) = self.upstream.clone() else {
return Ok(());
};
let mut coordinator = upstream.lock().await;
let marker = coordinator.record_publication_intent(change_id).await?;
tracing::info!(
change_id = %change_id,
marker = %marker,
"Recorded publication-required identity for cumulative base integration"
);
Ok(())
}
/// Run one change-scoped publication cycle while the base lane is held.
pub(super) async fn publish_completed_change(
&self,
change_id: &str,
) -> Result<PublicationLaneOutcome> {
let Some(upstream) = self.upstream.clone() else {
return Ok(PublicationLaneOutcome::Confirmed {
head: String::new(),
});
};
let mut coordinator = upstream.lock().await;
Ok(match coordinator.publish_change(change_id).await? {
PublicationOutcome::Published { head }
| PublicationOutcome::AlreadyConfirmed { head } => {
PublicationLaneOutcome::Confirmed { head }
}
PublicationOutcome::Stalled { reason } => {
PublicationLaneOutcome::Unpublished { reason }
}
})
}
/// Resume publication for every change that repository evidence shows is
/// integrated but unpublished.
///
/// This is the restart and explicit-retry entry point. It creates no apply or
/// acceptance dispatch: the change is already in cumulative base, so the only
/// outstanding work is verification, native push, and remote confirmation.
/// The base lane is taken for each attempt and released when it finishes, so
/// a stalled publication keeps later results waiting rather than silently
/// letting them integrate.
///
/// Every attempt is attributed to the change ID recorded in its marker, so
/// resumption produces the same change-scoped `PushStarted`/`PushCompleted`
/// pair a fresh integration does. Nothing here may quietly give up: an
/// abandoned marker would let run-final publication push the same cumulative
/// HEAD with no change attribution, and the run would report completion for
/// a change the reducer never saw confirmed.
///
/// Returns the change IDs that are still unpublished afterwards.
pub(super) async fn resume_pending_publications(&mut self) -> Vec<String> {
/// Base-lane contention and transient base dirtiness are both expected
/// to clear; a bounded wait is the difference between "resumed" and
/// "silently skipped".
const BASE_LANE_ATTEMPTS: u32 = 30;
const BASE_LANE_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(100);
/// Bounded so a wedged lane holder surfaces as owed publication — which
/// withholds run completion — instead of hanging the caller forever.
const BASE_LANE_ACQUIRE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
let pending: Vec<String> = self
.pending_publications()
.await
.into_iter()
.map(|evidence| evidence.trailers.change_id)
.collect();
let mut unpublished = Vec::new();
for change_id in pending {
// Wait for the base lane rather than skipping: this runs before the
// scheduler dispatches and at run finalization, so contention here is
// another base operation finishing, not a permanent condition.
let lane =
tokio::time::timeout(BASE_LANE_ACQUIRE_TIMEOUT, super::global_merge_lock().lock())
.await;
let Ok(_lane) = lane else {
tracing::error!(
change_id = %change_id,
"Base lane never became available; publication remains owed"
);
unpublished.push(change_id);
break;
};
let mut base_ready = false;
for attempt in 1..=BASE_LANE_ATTEMPTS {
match super::merge::base_dirty_reason(&self.repo_root).await {
Ok(None) => {
base_ready = true;
break;
}
Ok(Some(reason)) => {
tracing::warn!(
change_id = %change_id,
reason = %reason,
attempt,
"Cumulative base is not clean; retrying publication resumption"
);
}
Err(err) => {
tracing::warn!(
change_id = %change_id,
error = %err,
attempt,
"Base state unavailable; retrying publication resumption"
);
}
}
tokio::time::sleep(BASE_LANE_RETRY_DELAY).await;
}
if !base_ready {
tracing::error!(
change_id = %change_id,
"Cumulative base never became usable; publication remains owed"
);
unpublished.push(change_id);
// Later markers depend on this one's published revision for
// unambiguous attribution, so stop here rather than publishing
// a cumulative HEAD that carries an unconfirmed change.
break;
}
tracing::info!(
change_id = %change_id,
"Resuming unpublished cumulative-base integration from repository evidence"
);
if let Err(err) = self.publish_base_integration(&change_id, None, true).await {
tracing::warn!(
change_id = %change_id,
error = %err,
"Publication resumption did not complete; change remains resumable"
);
// A stalled publication keeps the lane closed for later results
// by leaving its marker in place. Attribution of any later
// marker would be ambiguous while this one is unconfirmed.
unpublished.push(change_id);
break;
}
// Repository evidence, not the call's return value, decides whether
// the marker is discharged.
if self.has_pending_publication_for(&change_id).await {
tracing::warn!(
change_id = %change_id,
"Publication resumption reported success but the marker is still unpublished"
);
unpublished.push(change_id);
break;
}
}
unpublished
}
/// Own finalization for an opted-in run.
///
/// Returns `true` when the run may report completion. Only a successful
/// drain can reach verification, push, and remote confirmation.
pub(super) async fn finalize_upstream(&self, outcome: SchedulerOutcome) -> bool {
let Some(upstream) = self.upstream.clone() else {
// Disabled runs keep their existing completion semantics.
return true;
};
let mut coordinator = upstream.lock().await;
match coordinator.finalize(outcome).await {
Ok(FinalizeOutcome::Completed { pushed_head }) => {
tracing::info!(
pushed_head = %pushed_head,
"Upstream integration published verified cumulative base"
);
true
}
Ok(FinalizeOutcome::NoWork) => {
tracing::info!("Upstream integration completed with no work to publish");
true
}
Ok(FinalizeOutcome::Skipped { reason }) => {
tracing::warn!(reason = %reason, "Upstream finalization skipped");
false
}
Ok(FinalizeOutcome::Stalled { reason }) => {
tracing::error!(reason = %reason, "Upstream finalization stalled");
false
}
Err(err) => {
tracing::error!(error = %err, "Upstream finalization failed");
false
}
}
}
}