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
//! Worktree adoption at project registration, shared by BOTH transports
//! (#7357).
//!
//! Why: registration exists twice — the `project_register` MCP tool
//! ([`crate::daemon::mcp_project::project_register`]) and
//! `POST /api/v1/projects` /
//! `mpm.projects.registry.register`
//! ([`crate::daemon::managed_routes::project_registry_routes::register_project_registry_op`],
//! which is what `tm projects register` calls). #7357's first round added the
//! backfill to the MCP body only, so the CLI path registered a project and
//! adopted nothing — the exact bug, still reachable by the command an operator
//! is most likely to run. One function, called from both bodies, is what makes
//! that class of divergence unreachable rather than merely fixed once.
//! What: [`adopt_pre_existing_worktrees`] resolves the project's local checkout
//! and runs [`crate::project::backfill_checkout`] against the registry data
//! directory under the DAEMON's framework root (never `$HOME`, which an
//! isolated-managed daemon does not use), and [`RegisterProjectResponse`] is the
//! response body both transports return so the report reaches the caller.
//! Test: `parity_register_adopts_the_same_worktrees_across_transports`,
//! `register_response_names_a_skipped_worktree`.
use std::sync::Arc;
use serde::Serialize;
use crate::daemon::state::DaemonState;
use crate::project::{BackfillReport, Project};
/// The body both registration transports return (#7357).
///
/// Why: registration used to answer with the [`Project`] alone, so a caller
/// could not tell a checkout whose worktrees were all adopted from one where
/// every candidate was skipped or held by another project. Registration must
/// still SUCCEED in both cases — the report is the signal, not a failure — so
/// the record is flattened and the report rides beside it under one key. A
/// consumer that only knows `Project` (the `tm` HTTP client does) ignores the
/// extra key.
/// What: the persisted record's own fields, plus `adoption`.
/// Test: `register_response_names_a_skipped_worktree`,
/// `parity_projects_registry_register_agrees_across_transports`.
#[derive(Debug, Serialize)]
pub struct RegisterProjectResponse {
/// The persisted project record, inlined at the top level.
#[serde(flatten)]
pub project: Project,
/// What registration did with the checkout's pre-existing worktrees.
pub adoption: BackfillReport,
}
/// Adopt the worktrees a project's checkout ALREADY has (#7357).
///
/// Why: before #7357 registration wrote a [`Project`] record and nothing else,
/// so a repo that had agent activity before it was registered stayed invisible
/// to `reconcile-worktrees`, `prune-worktrees --merged-prs`, `tm doctor` and the
/// Disk survey — all four read one scan, and that scan reaches a project only at
/// `<repos_root>/<owner>/<repo>`. The operator's only remaining move was
/// `git worktree remove --force` by hand. Running the backfill here is what
/// makes a pre-existing worktree indistinguishable, to every later pass, from
/// one provisioned after registration.
/// What: resolves the project's LOCAL checkout — `repo_url` when it names an
/// existing directory — and hands it to [`crate::project::backfill_checkout`],
/// which writes one adoption record per worktree it can read. A `repo_url` that
/// is a remote URL needs nothing: its checkout lives at
/// `<repos_root>/<owner>/<repo>`, which the walk already covers, and yields an
/// empty report.
///
/// NOT A GATE. Every failure below is reported, never raised: registration is
/// bookkeeping and must not fail because a checkout moved, a disk is unreadable,
/// or git will not answer. What #7357's review changed is that a skip is no
/// longer invisible — it reaches the caller in
/// [`BackfillReport::unrecorded`].
/// Test: `project_register_backfills_pre_existing_worktrees`,
/// `project_register_backfill_is_idempotent`,
/// `project_register_succeeds_when_the_checkout_is_not_a_repository`,
/// `parity_register_adopts_the_same_worktrees_across_transports`.
pub(crate) fn adopt_pre_existing_worktrees(
state: &Arc<DaemonState>,
project: &Project,
) -> BackfillReport {
let checkout = std::path::Path::new(&project.repo_url);
if !checkout.is_dir() {
return BackfillReport::default();
}
// #8511: exclude the harness files and move legacy markers out of the trees.
crate::session_manager::worktree_marker_migration::prepare_checkout(checkout);
let store_dir = crate::project::registry_data_dir_under(state.framework_root());
let report =
crate::project::backfill_checkout(&store_dir, &project.name, checkout, chrono::Utc::now());
if report != BackfillReport::default() {
tracing::info!(
project = %project.name,
checkout = %checkout.display(),
recorded = report.recorded,
already_recorded = report.already_recorded,
claimed_by_another = report.claimed_by_another,
skipped = report.skipped,
"#7357: adopted pre-existing worktrees at project registration"
);
}
report
}
/// The daemon's one-shot startup marker migration (#8511).
///
/// Why: registration covers a project registered from now on; this pass covers
/// every project registered before the marker moved, once per daemon start.
/// What: refuses on a scratch framework root (a test process, #6348), then runs
/// [`crate::session_manager::worktree_marker_migration::migrate_registered_projects`]
/// over the orphan-GC's repos root and the adopted anchors on a blocking thread,
/// awaited by a small task that logs the tally or a panic (`JoinError`).
/// Test: the pass itself is `the_fleet_pass_migrates_every_tree_and_excludes_once`.
pub(crate) fn spawn_startup_marker_migration(state: &Arc<DaemonState>) {
if let Some(reason) = crate::daemon::host_state_refusal(state) {
tracing::info!("startup marker migration: skipped — {reason}");
return;
}
let repos_root = crate::daemon::managed_routes::inproject::repos_root();
let adopted = crate::project::adopted_anchors_under(state.framework_root());
let pass = tokio::task::spawn_blocking(move || {
crate::session_manager::worktree_marker_migration::migrate_registered_projects(
&repos_root,
&adopted,
)
});
tokio::spawn(async move {
match pass.await {
Ok(tally) => tracing::info!(
migrated = tally.migrated,
kept = tally.kept,
"startup marker migration finished (#8511)"
),
Err(e) => tracing::warn!("startup marker migration did not finish: {e} (#8511)"),
}
});
}
#[cfg(test)]
#[path = "project_adoption_tests.rs"]
mod project_adoption_tests;